Black screen on remote participant after screen share ends

The Problem

When one participant stops sharing their screen, the other participant sees a black screen in the main video area. However, if the affected participant then clicks the PIP thumbnail to swap views, the remote video appears correctly.

This strongly suggests the issue is not a video stream problem (the stream is active) but rather an attachment/target timing issue in the active-share-change handler.

Our Analysis

The active-share-change Inactive Handler

When screen sharing ends, the Zoom Video SDK fires active-share-change with state: “Inactive” on both participants. Our handler (in VideoCall.jsx ) does the following:

// Re-attach both feeds to whichever layout is currently pinned
const nextPinnedView = pinnedViewRef.current;
const allUsers = client.getAllUser();
const myId = client.getCurrentUserInfo().userId;
const remoteUser = allUsers.find((u) => u.userId !== myId);

await new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve)),
);

// Re-attach local video
const localTargetSelector = nextPinnedView === "local"
? "#local-video-player"
: "#local-video-pip-player";
const localTarget = await waitForVideoPlayerReady(localTargetSelector, 3000);
if (localTarget) {
try { await stream.detachVideo(myId); } catch (_) {}
try {
await stream.attachVideo(
myId, VideoQuality.Video_720P, localTarget,
);
} catch (e) {
console.warn("[VideoCall] Failed to re-attach local video after share ended:", e);
}
}

// Re-attach remote video
if (remoteUser) {
const remoteTargetSelector = nextPinnedView === "local"
? "#remote-video-thumb-player"
: "#remote-video-player";
const remoteTarget = await waitForVideoPlayerReady(remoteTargetSelector, 3000);
try { await stream.detachVideo(remoteUser.userId); } catch (_) {}

if (remoteUser.bVideoOn && remoteTarget) {
try {
await stream.attachVideo(
remoteUser.userId, VideoQuality.Video_720P, remoteTarget,
);
} catch (e) {
console.warn("[VideoCall] Failed to re-attach remote video after share ended:", e);
}
}
}
}

Suspected Root Cause #1: bVideoOn Stale State

When screen sharing stops, the remote sharer’s bVideoOn property (checked via client.getAllUser()) may briefly be false during the SDK’s internal transition from share mode back to camera mode. Our guard:

// javascript
if (remoteUser.bVideoOn && remoteTarget) {

prevents the attachVideo call if bVideoOn is false at that moment. The video is never re-attached to the main player, leaving a black screen.

The user then manually swaps the view (triggers handlePinnedViewChange), which re-reads bVideoOn — by then it’s true — and attaches successfully.

This is our leading hypothesis. The bVideoOn check was intended to avoid attaching a null video, but it creates a race where the brief window between share-end and camera-restore causes a permanent black screen.

Suspected Root Cause #2: stopShareView() Timing

stopShareView() is called before re-attaching video feeds. If the SDK hasn’t fully cleaned up the share view before attachVideo runs, the attachment may silently fail or target a transitional state.

Suspected Root Cause #3: React CSS Transition Race

The main video container has a CSS transition:

// jsx
className={absolute inset-0 transition-all duration-300 ${ callStarted && !(remoteIsSharing || isScreenSharing) && pinnedView === "remote" ? "opacity-100" : "pointer-events-none opacity-0" }}

When setRemoteIsSharing(false) triggers a re-render immediately inside the Inactive handler, the container’s opacity transitions from 0 to 1 over 300ms. The attachVideo call runs before this transition completes — the element exists but is transitioning. While the SDK should render regardless of CSS opacity, this timing could compound with issues #1 or #2.

Questions

  1. Is there a documented state transition for bVideoOn when screen sharing stops? Specifically, does getAllUser().bVideoOn briefly return false between “sharing stopped” and “camera resumed”?
  2. Should stopShareView() be awaited before calling attachVideo() / detachVideo()? Is there a required cooldown or lifecycle step after stopShareView() before video streams can be re-established?
  3. Can you suggest the recommended pattern for re-attaching video feeds after screen sharing ends? Specifically:
  • Should we use a peer-video-state-change event with action: “Start” as a trigger to re-attach, rather than doing it immediately in the active-share-change Inactive handler?
  • Is there an event that fires specifically when the sharer’s camera feed is fully restored after sharing ends?
  1. What is the expected value of bVideoOn for a participant who was sharing and has an active camera? Does bVideoOn remain true during sharing (since camera is still active) or does it toggle off temporarily?

Relevant Code for Context
The DOM layout on the receiver’s side when screen sharing is active (from VideoCallInCall.jsx):

{/ Remote video — hidden during share, shown when share ends /}

{/ Always-present canvas for remote share view /}
<canvas id="screen-share-view-canvas" className={ absolute inset-0 h-full w-full ${
remoteIsSharing ? "" : "pointer-events-none opacity-0"
}} />

Hey @Gatsby

Thanks for your feedback.

  1. Is there a documented state transition for bVideoOn when screen sharing stops? Specifically, does getAllUser().bVideoOn briefly return false between “sharing stopped” and “camera resumed”?

bVideoOn does not change when screen sharing starts or stops, because video and screen sharing are two separate features.

  1. Should stopShareView() be awaited before calling attachVideo() / detachVideo()? Is there a required cooldown or lifecycle step after stopShareView() before video streams can be re-established?

There is no need to wait.

  1. Can you suggest the recommended pattern for re-attaching video feeds after screen sharing ends?

I’m not sure about your use case, but when screen sharing starts, do you actively call stopVideo to turn off video? If not, then peer-video-state-change will not be triggered. If you need to re-render, you should still decide whether to render the video based on the value returned by getAllUser().bVideoOn.

  1. What is the expected value of bVideoOn for a participant who was sharing and has an active camera? Does bVideoOn remain true during sharing (since camera is still active) or does it toggle off temporarily?

If stopVideo is not called, the bVideoOn value will always remain true.

If possible, could you provide the affected session IDs so we can use the logs to identify the root cause more quickly?

Thanks
Vic

Hi Vi,

Thank you for the clarification. A few follow-ups based on your answers.

  1. We do NOT call stopVideo() during screen sharing

To answer your question: no, we do not call stopVideo() when screen sharing starts. The camera remains active the entire time. On the receiver’s side, we simply move the remote participant’s camera feed to a different element using detachVideo() → attachVideo() during the active-share-change “Active” event. Since stopVideo() is never called, we understand that peer-video-state-change will not fire — which is fine.

This also means bVideoOn from getAllUser() remains true throughout, as you confirmed. Our guard accessing bVideoOn is never the blocker.

  1. The real issue: detachVideo + attachVideo fails after “Inactive”

Since the video stream never stops, the problem narrows to this specific sequence:

When active-share-change fires with state: “Inactive” :

  1. stopShareView() // cleanup the share canvas
  2. detachVideo(remoteUserId) // remove from thumbnail player
  3. attachVideo(remoteUserId, 720P, mainPlayer) // re-attach to main player

Step 3 renders a black frame on the main element. No errors are thrown — the SDK reports success, the element is present in the DOM, and customElements.whenDefined(‘video-player’) has already resolved (since the element was defined at initial join).

However, if the user then clicks our “swap view” button — which runs another detachVideo() + attachVideo() cycle — the video appears correctly on the second attempt.

This pattern strongly suggests the Zoom Video SDK’s internal video pipeline does not fully transition from share-mode back to camera-mode after the first attachVideo() call following an active-share-change “Inactive” event.

  1. Regarding the session IDs

Regarding your request for affected session IDs — could you clarify what format or identifier would be most useful?

Our app uses the Zoom Video SDK exclusively (not the Zoom Meetings API). Session names in our system follow a deterministic format based on our internal booking IDs (e.g., gigant-booking-143 ), which we store in our database. We don’t generate or store traditional Zoom meeting numbers since we use the Video SDK’s on-demand sessions.

Would providing our session name string (e.g., gigant-booking-143 ) and the approximate timestamp be sufficient for your team to look up the corresponding logs? Alternatively, would our Zoom Video SDK app credentials (SDK Key / SDK Client ID) help you correlate on your end?

Happy to provide whatever format works best for your system.

Follow-up question:

Is there any known behavior where detachVideo() followed immediately by attachVideo() on a different target renders a black frame when called right after an active-share-change “Inactive” event? The fact that a second detach+attach cycle always works suggests the first may be hitting an internal transitional state in the SDK.

Alternatively, is there a recommended approach for this scenario where we can avoid the detach/re-attach entirely — for example, keeping the camera feed on its original element during sharing and simply layering the share view on top?

Best regards,
Rod

Hey @Gatsby

Thanks for your information.

Regarding the session ID, you can find it in the web portal under Dashboard > Past Sessions.

Regarding the video not displaying, I’m not sure how you’re hiding the video. Are you also removing the remote user video’s video-player-container node?

If you’re using a destroy-and-recreate approach, our recommendation is to hide it via CSS and reuse the video-player-container as much as possible.

Also, based on your mention of startShareView and stopShareView, it looks like you’re not using attachShareView when rendering the share view. If you plan to migrate to that method later, one important thing to keep in mind is that you should not mix the video video-player-container with the share view’s video-player-container.

We may need you to share the session IDs so we can investigate this issue more easily.

Thanks
Vic

Hi Vic,

  1. How we handle the video-player-container

We do NOT remove or destroy the <video-player-container> elements at any point. All containers are always present in the DOM when callStarted is true — we only toggle their visibility via CSS classes using opacity-0/opacity-100 and pointer-events-none/pointer-events-auto. We never recreate or re-insert DOM nodes.

The video feeds are moved between existing containers via detachVideo()attachVideo(), but the containers themselves are never removed.

  1. Regarding startShareView

We use startShareView(htmlCanvasElement, userId) with a plain <canvas id="screen-share-view-canvas"> element — not a <video-player-container> . So there is no mixing of video containers with the share view. We understand that if we migrate to attachShareView in the future we must keep separate containers, but for now our setup already follows that separation.

  1. Session IDs

Here are two session IDs from our Zoom Video SDK sessions where the black screen issue was reproduced:

Session Name Session ID
gigant-booking-123 O/+Za+u8QPmmPDkCypSHnw==
gigant-booking-125 ZLgBIPOASd+kNFrQOrRIbQ==

Please let us know if you need anything else from our side.

Best regards,
Rod

Hi @Gatsby

Thank you for the explanation and for providing the session ID. After analyzing the logs, we’d like to confirm our findings with you.

O/+Za+u8QPmmPDkCypSHnw==

In this session, we found similar call logs, which match the logic you mentioned for switching from the thumbnail view to the main view after the share view stops. However, we did not find any call related to manually swapping the view, so we still need your confirmation.

06:57:26.791  bShareOn:false for 16787456          <- share ended
06:57:26.793  stopShareView()                  <- share canvas cleanup
06:57:26.811  detachVideo(16788480, null)           self video detached
06:57:26.814  detachVideo success
06:57:26.814  attachVideo(16788480, 3, "#local-video-pip-player")
06:57:26.815  detachVideo(16787456, null)           remote participant (sharer) video detached
06:57:26.815  attachVideo success (local)
06:57:26.821  detachVideo success (remote)
06:57:26.821  attachVideo(16787456, 3, "#remote-video-player")  <- reattached to the main view
06:57:26.823  attachVideo success (remote)

ZLgBIPOASd+kNFrQOrRIbQ==

User NodeId In call Notes
User-1 (receiver, under investigation) 16778240 08:08:42 – 09:00:00 Present for entire meeting
User-3 16781312 08:08:43 – 08:13:53 Left early
User-4 16782336 08:15:06 – 08:24:15 Left via failover
User-5 16783360 → 16784384 08:21:51 → 08:23:19 – 09:00:00 Reconnected via failover at 08:23:19

Timeline of Events (from client-side SDK logs)

Earlier share-stop events — all recovered correctly (only 2 participants present each time):

Time Event Result
08:09:17 User-3 stops sharing → detachVideo(16781312) → attachVideo(16781312, 720P, #remote-video-player) :white_check_mark: Success, video restored
08:11:07 User-1 (self) stops sharing :white_check_mark: N/A (local share, no remote re-attach needed)
08:13:20 User-3 stops sharing (2nd time) → same detach/attach pattern :white_check_mark: Success, video restored
08:15:41 User-4 stops sharing → detachVideo(16782336) → attachVideo(16782336, 720P, #remote-video-player) :white_check_mark: Success, video restored

The failing event — the first moment 3 participants were in the call together:

Time Event
08:17:27.716 Receiver’s browser tab goes to background (Page visibility hidden)
08:23:19 User-5 reconnects via failover → meeting now has 3 participants (self + User-4 + User-5)
08:23:28 – 08:23:38 User-5 (16784384) shares screen
08:23:38.525 User-5 stops sharing (bShareOn:false)
08:23:39.820 Receiver’s tab returns to foreground (Page visibility visible) — ~6 minutes after going hidden
08:23:39.827 – 08:23:39.846 Re-attach handler fires: 3 rapid, back-to-back detachVideo/attachVideo cycles on the receiver’s own (local) video, all within 20ms
08:23:39.846 detachVideo(16782336, null) — i.e., User-4, not User-5, is detached
08:23:39.854 Browser-level error: AbortError: The play() request was interrupted because the media was removed from the document
No attachVideo(16784384, ...) call ever appears again in the log. User-5’s camera feed is never re-attached to the main player.

Root Cause

Primary cause — remoteUser resolution breaks down with more than 2 participants

Your handler resolves the “other” participant like this:

const allUsers = client.getAllUser();
const myId = client.getCurrentUserInfo().userId;
const remoteUser = allUsers.find((u) => u.userId !== myId);

This logic assumes there is exactly one other participant — it simply grabs the first non-self user in the array, regardless of who actually stopped sharing. In a 1:1 call this is harmless, since there’s only one candidate. But as soon as a third participant is present, find() can return the wrong user.

In this session, that’s exactly what happened: when User-5 stopped sharing, allUsers.find() resolved to User-4 (who likely appears earlier in the array, having joined first) instead of User-5. As a result:

  • The handler’s detach/re-attach logic ran against User-4, who wasn’t the one who had just stopped sharing.
  • User-5’s camera video — the one that actually needed to move back to the main player — was never touched again. There is no code path that would ever correct this, so the black screen does not self-heal; it persists until the user manually triggers another re-attach (e.g., via your “swap view” button, which re-reads state fresh and gets it right).

This lines up perfectly with the earlier “clean” events: every prior share-stop in this same session happened while exactly one other participant was present, and every one of those resolved and recovered correctly. The only failure occurred at the first moment a third participant was in the call.

Secondary, compounding cause — requestAnimationFrame stalls while the tab is backgrounded

Independently, we also see the receiver’s browser tab was backgrounded from 08:17:27 to 08:23:39 (~6 minutes), spanning the entire share-stop event. Your handler waits on a double requestAnimationFrame:

await new Promise((resolve) =>
  requestAnimationFrame(() => requestAnimationFrame(resolve)),
);

Most browsers pause/throttle requestAnimationFrame callbacks while a tab is hidden. This means the handler was likely stuck waiting and only resumed once the tab came back to the foreground — at which point we see three detach/attach cycles fire back-to-back within 20ms (rather than one clean cycle), immediately followed by a real AbortError from the browser (play() request was interrupted because the media was removed from the document). This suggests multiple pending Inactive-handler invocations queued up while the tab was hidden and all fired in a burst once visibility resumed, causing the video element’s srcObject to be swapped multiple times in rapid succession.

Thanks
Vic

Hi Vic,

Thank you for the detailed analysis, this is incredibly helpful.


Session 1 (gigant-booking-123) — Confirmation

Yes, I can confirm. In session 1 (gigant-booking-123), the screen was black after sharing ended. Here is a video recording showing the issue:

drive.google.c om/file/d/1bFPt1GlSr80CV7B4QDQKNvK-oscaFT9d/view?usp=sharing

(remove the space in “google.c om” to open the link)

The swap view is our manual workaround — the user clicks the PIP thumbnail to force a fresh detachVideo + attachVideo cycle, which restores the video. You don’t see it in your logs because it’s triggered through our React UI handler (handlePinnedViewChange) rather than the Zoom SDK’s active-share-change event, so it runs outside the SDK’s log scope you reviewed.


Session 2 (gigant-booking-125) — Our Fixes

Based on your root cause analysis, we’ve implemented two fixes targeting the exact issues you identified.

Fix 1: Use payload.userId Instead of allUsers.find()

Root cause: When active-share-change fires with state: "Inactive", our handler was resolving the “other” participant via client.getAllUser().find(u => u.userId !== myId), which assumes a 1:1 call. With 3+ participants (from failover reconnections), find() returns whichever non-self user appears first in the array — not necessarily the user who just stopped sharing.

Before:

const allUsers = client.getAllUser();
const myId = client.getCurrentUserInfo().userId;
const remoteUser = allUsers.find((u) => u.userId !== myId);

After:

const sharerUserId = payload.userId;
const myId = client.getCurrentUserInfo().userId;
const isRemoteSharer = sharerUserId !== myId;

// ... local video re-attach ...

if (isRemoteSharer) {
  const remoteTarget = await waitForVideoPlayerReady(remoteTargetSelector, 3000);
  try { await stream.detachVideo(sharerUserId); } catch (_) { /* no-op */ }
  const sharer = client.getAllUser().find((u) => u.userId === sharerUserId);
  if (sharer?.bVideoOn && remoteTarget) {
    await stream.attachVideo(sharerUserId, VideoQuality.Video_720P, remoteTarget);
  }
}

We now use payload.userId directly from the active-share-change event payload — the exact user who stopped sharing. The const sharer = client.getAllUser().find(...) lookup handles the edge case where the sharer has already left the call (returns undefinedsharer?.bVideoOn is falsy → attach is safely skipped).

Fix 2: Replace double requestAnimationFrame with setTimeout

Root cause: The double requestAnimationFrame stalls indefinitely while the browser tab is backgrounded. Our logs show the tab was hidden for ~6 minutes, causing the handler to freeze until the tab returned to the foreground, at which point multiple queued Inactive handlers fired back-to-back within 20ms, producing the AbortError: play() request was interrupted error.

Before:

await new Promise((resolve) =>
  requestAnimationFrame(() => requestAnimationFrame(resolve)),
);

After:

// Use a short timeout instead of rAF which stalls when the tab is hidden
await new Promise((resolve) => setTimeout(resolve, 50));

This gives React ~50ms to commit any layout changes (opacity transitions, element visibility) before we re-attach video, while always resolving regardless of tab visibility state. No more queued handlers bursting on return.


Architecture Comparison: Our Implementation vs Zoom’s Recommendations

Based on our research of the official Zoom Video SDK Web documentation, we reviewed how our architecture maps to the recommended patterns. Here’s what we found:

┌─────────────────────┐     ┌──────────────────────┐
│   Camera Stream     │     │   Screen Share Stream │
│  (attachVideo API)  │     │  (startShareView API) │
└────────┬────────────┘     └──────────┬───────────┘
         │                             │
         ▼                             ▼
┌──────────────────┐          ┌──────────────────┐
│ <video-player>   │          │ <canvas>         │
│ (web component)  │          │ (plain element)  │
└──────────────────┘          └──────────────────┘

Key finding: The camera and share streams are independent. Starting a screen share does NOT stop the camera — the camera keeps sending video the entire time.

Recommended Pattern (from Zoom docs)

  1. active-share-change { state: "Inactive" } fires
  2. Call stream.stopShareView() to clean up the share canvas
  3. Manually re-attach camera: stream.attachVideo(userId, quality, container) — the SDK does NOT auto-resume camera video

Critical Guidelines

Zoom’s docs explicitly recommend:

“Always await the completion of detachShareView or stopShareView before attempting to initiate a new attachVideo call”

And:

“Maintain a Map<UserId, HTMLElement> registry. Never rely on DOM queries to find which element belongs to which user.”

Our Implementation vs Guidelines

Guideline Our Code Status
await detach before attach :white_check_mark: We do this OK
Call stopShareView() on Inactive :white_check_mark: We do this OK
Maintain Map<UserId, Element> registry :cross_mark: We use document.querySelector() :warning: Weakness
Detach share view before re-attaching camera :white_check_mark: Separate containers (<canvas> for share, <video-player> for camera) OK
Let web component settle before attaching :cross_mark: 50ms timeout + waitForVideoPlayerReady(3000) may not be enough for the internal state transition :warning: Possible Root Cause

Our Theory for Session 1 — Why attachVideo Reports Success but Renders Black

The logs show the entire re-attach sequence completes in 32ms (26.791 → 26.823). We suspect the <video-player> web component has internal states:

  • INACTIVE → element exists but no stream
  • RENDERING_CAMERA → actively decoding and displaying camera feed
  • RENDERING_SHARE → actively displaying a shared screen

When sharing was active, the main #remote-video-player was dormant (the camera was moved to the thumbnail). When share ends, we call attachVideo to move it from dormant back to RENDERING_CAMERA — but the web component’s internal decoder/renderer may not have finished transitioning in that 32ms window.

attachVideo returns “success” because the stream was bound to the element, but the decoder inside the web component may have dropped the first keyframe, showing black.

The swap view workaround works because:

  • By the time the user manually clicks it, the web component has fully settled
  • The second detachVideo + attachVideo cycle runs on a fully initialized renderer

Could you review these changes and let us know if you think they would address both the wrong-user issue and the rAF stall? Also, for session 1 where attachVideo reports success but the frame is black — do you think our theory about the web component’s internal state machine timing could be correct? Any additional recommendations would be greatly appreciated.

Best regards,
Rod

Hi @Gatsby

You don’t see it in your logs because it’s triggered through our React UI handler (handlePinnedViewChange) rather than the Zoom SDK’s active-share-change event, so it runs outside the SDK’s log scope you reviewed.

I’d like to understand: is the swap view you mentioned not implemented through detachVideo / attachVideo, but instead by moving the video-player element within the DOM?

Thanks
Vic

Hi Vic,

The swap view is also implemented through detachVideo / attachVideo — we don’t move the <video-player> elements in the DOM. The elements remain in their fixed positions at all times; we only toggle their CSS visibility (opacity-0 / opacity-100) and call the SDK to attach/detach the video stream to whichever element is currently the “main” vs “thumbnail” target.

When the user clicks the PIP thumbnail, handlePinnedViewChange runs the same detachVideo + attachVideo cycle on both local and remote streams — the same sequence as the Inactive handler, just triggered by a React event rather than the SDK’s active-share-change event.

The fact that the second cycle always works (while the first one after share ends produces a black frame) suggests it’s not an issue with the API calls themselves, but with timing relative to the SDK’s internal state. As we theorized, the <video-player> web component may not have fully transitioned its internal renderer from share mode back to camera mode when the first attachVideo arrives.

Best regards,
Rod

Hi @Gatsby

After reviewing the recording you provided, I tried to reproduce the issue, but I wasn’t able to reproduce it. I’d like to confirm a few details about your setup:

  1. Does this black screen issue only occur in Opera? Can you reproduce it in Chrome or Edge as well?
  2. Are the main video and PIP video using separate video-player-container elements? Is the self video also using its own separate video-player-container? When swapping views, do you append the video-player to the corresponding video-player-container?

Also, could you please check whether the video-player in the DOM has width and height when the issue occurs, and whether the node-id attribute on the video-player matches the correct UserID?

If possible, could you also provide a minimal reproducible sample so we can investigate this issue more deeply?

Thanks
Vic

Hi Vic,

Thank you for the follow-up. Here are our answers:


1. Does the issue only occur in Opera?

We initially mentioned Opera, but we have also tested in Chrome and Edge, where the issue persists as well.


2. Separate video-player-containers

Yes, all video feeds use completely separate <video-player-container> elements, each with their own unique ID. Here’s our DOM structure:

Container ID Player ID Purpose
#local-video-container <video-player id="local-video-player"> Main local camera (shown when local is pinned)
#remote-video-container <video-player id="remote-video-player"> Main remote camera (shown when remote is pinned)
#local-video-pip-container <video-player id="local-video-pip-player"> PIP thumbnail for local camera
#remote-video-thumb-container <video-player id="remote-video-thumb-player"> PIP thumbnail for remote camera

All four containers are always present in the DOM when the call is active. We never remove, append, or move <video-player> elements between containers.

When swapping views (handlePinnedViewChange), we do not move DOM elements. Instead we call the SDK’s detachVideo() + attachVideo() to move the video stream between the fixed containers. The containers themselves stay in place — we only toggle their CSS visibility (opacity-0 / opacity-100).


3. Width/height and node-id attribute

We don’t set any node-id attribute on our <video-player> elements. We rely entirely on CSS id selectors (e.g., #remote-video-player). If the Zoom SDK sets a node-id attribute internally on the <video-player> web component, we haven’t inspected it.

Regarding width/height — the containers have proper CSS sizing through Tailwind classes. We can try to check the <video-player> element dimensions the next time the issue is reproduced and let you know.


Let us know if you need any other information.

Best regards,
Rod

Hi @Gatsby

The node-id attribute on video-player is set by the Video SDK and is used to identify which user’s video that element is rendering. If this attribute does not match the user involved when the issue occurs, that could be the cause.

I also noticed in the logs that when the current user stops sharing the screen, the flow first detaches the thumbnail video and reattaches it once, and then immediately detaches the thumbnail video again before attaching it to the main video. I’m not sure whether this is the expected sequence, but this call pattern does not appear to cause the black screen.

One other possible issue is that the video-player element is passed as a parameter during the call. In React re-renders, the DOM element may have already been re-rendered, while ref.current is still pointing to the previous element. That could also be a possible cause of this issue.

Thanks
Vic

Hi Vic,

Thanks for the continued investigation. Based on your suggestion about React re-renders potentially causing stale DOM references, we reviewed our code and made some changes.

What We Changed

You mentioned that during React re-renders, a <video-player> element may be replaced in the DOM while our code still holds a reference to the old element. Looking at our code, we noticed that in several places we:

  1. Query the DOM element (using document.querySelector() or our waitForVideoPlayerReady wrapper which internally polls for it)
  2. await stream.detachVideo(...) — which is a yield point where React could commit a re-render
  3. Then call attachVideo(userId, quality, storedElement) — where storedElement could potentially be stale

So we added a re-query right before each attachVideo() call, after all the awaits, to get a fresh reference:

// Before:
const target = await waitForVideoPlayerReady(selector, 3000);
await stream.detachVideo(userId);
await stream.attachVideo(userId, quality, target);

// After:
const target = await waitForVideoPlayerReady(selector, 3000);
await stream.detachVideo(userId);
const liveTarget = document.querySelector(selector);
const element = liveTarget || target;  // fallback if element briefly absent
await stream.attachVideo(userId, quality, element);

We applied this to:

  • The active-share-change Inactive handler (local + remote re-attach)
  • The handlePinnedViewChange swap handler (local + remote)
  • The attachRemoteVideoToThumbnail function
  • The userRemoved handler
  • The call:participant-left handler

We haven’t tested this yet, so we’re not sure if it fully addresses the issue. Would appreciate your thoughts on whether this looks like the right direction.


Two Browser Console Warnings

We’re also seeing these warnings in the browser console and wanted to ask if they could be related:

Warning 1 — Self-view rendering

“Rendering self-view on Chromium browser, Android browser without SharedArrayBuffer or iOS browser requires video tag, Please use ‘attachVideo’ or pass a video element instead of a canvas element in the ‘rendervideo’ method”

We don’t call renderVideo() anywhere in our code — we use attachVideo() with <video-player> web components. This warning seems to be coming from inside the Zoom Video SDK itself. Could this be related to the black screen issue?

Warning 2 — startShareView deprecated

“Use the attachShareView method to render the share view. The startShareView method is deprecated.”

We currently use stream.startShareView(canvas, userId) in the Active handler. If startShareView is deprecated, should we migrate to attachShareView(userId)?

Best regards,
Rod

Hi @Gatsby

Would appreciate your thoughts on whether this looks like the right direction.

It looks like this direction is correct, but we still need you to test it.

Two Browser Console Warnings

Regarding the two browser warnings you mentioned, they do not affect functionality.

The first warning is unexpected, and we will remove it in the next release.

For the second one, you can still use startShareView; we will only remove that method in the next major version. The current warning is just a reminder.

Thanks
Vic

Hi Vic,

Thanks for the confirmation on the direction and for clarifying the warnings.

We’ve verified that the re-query changes are already in place in our codebase (all 7 locations across the active-share-change Inactive handler, handlePinnedViewChange, attachRemoteVideoToThumbnail, userRemoved, and call:participant-left handlers). Each one now re-queries the DOM right before attachVideo() to get a fresh element reference.

However, the black screen issue still persists after testing. The re-query pattern alone didn’t resolve it.

Since that approach didn’t fix it, we’re wondering if there’s another angle we should explore. A few thoughts:

  1. Could this be related to the stopShareView() call timing? In our Inactive handler, we call stopShareView() to clean up the share canvas before re-attaching the remote video to the main player. Is there any SDK-internal state that needs to settle between stopShareView() and attachVideo() — something beyond what a DOM re-query would solve?

  2. You mentioned the node-id attribute on <video-player>. When the black screen occurs on the receiver’s side, does the node-id on #remote-video-player correctly match the sharer’s userId? If not, what would cause it to be wrong, and is there a way to reset it programmatically?

  3. Is there a recommended approach to avoid detach/re-attach entirely — for example, keeping the camera feed on #remote-video-player during the share and simply overlaying the share view on top? That would eliminate the re-attach race condition altogether.

Happy to test any other suggestions or provide additional session logs if needed.

Best regards,
Rod

Hi @Gatsby

Could this be related to the stopShareView() call timing?

The two are unrelated. Share view and video rendering are independent.

If not, what would cause it to be wrong, and is there a way to reset it programmatically?

This may be the real root cause. When the issue occurs, you can inspect the video-player node-id attribute in DevTools. If the value is 0 or does not match the expected user ID, that will result in a black screen.

Is there a recommended approach to avoid detach/re-attach entirely

Your methods calls are generally correct, but what still needs to be confirmed is that since you are passing the video-player parameter,just need to make sure that this element is the one actually rendered in the DOM as expected.

Thanks
Vic