UI Toolkit (Web) 2.4.5-1 — Captions & Subsession toolbar buttons do not return after leaving a subsession back to main session

SDK: Video SDK UI Toolkit for Web, version 2.4.5-1 (prebuilt composite, component view, loaded from source.zoom.us/uitoolkit/2.4.5-1).

Expected: After a participant joins a subsession (breakout room) and then returns to the main session, the main-session toolbar is restored to its full set — including the Captions and Subsession buttons.

Actual: On return to the main session, only the Settings button remains in the toolbar. The Captions and Subsession buttons are gone. They only reappear after fully leaving and rejoining the entire session.

Repro:

  1. Host joins a session via the UI Toolkit composite (component view).
  2. Open a subsession and join it.
  3. Leave the subsession / return to the main session.
  4. Observe: main-session toolbar shows only Settings; Captions + Subsession buttons are missing.

Notes: This looks related to the earlier report “Show captions button disappears when in a breakout room”, but the buttons are not being rebuilt on the return transition, not just hidden while inside the subsession. Is there a supported featuresOptions key or API to force the toolbar to re-render on the subsession→main transition, or is a fix planned?

Root cause found — it is in useResponsiveFooter, not in the subsession gate. (Correcting my earlier posts in this thread.) Reproduced consistently on UI Toolkit 2.4.5-1, component view.

Symptom: After a host opens a subsession, joins it, and returns to the main session, the Subsession, Whiteboard and Record buttons are gone from the footer — and they are not in the ⋯ overflow menu either. They are absent from the DOM entirely. Only a full leave + rejoin restores them.

Evidence — live React hook state read off the mounted Footer, in the broken state:

enabledButtons  = ["participants","chat","whiteboard","record","realTimeMediaStreams","caption","subsession"]
visibleButtons  = ["participants","chat","realTimeMediaStreams","caption"]
overflowButtons = []
width state (n) = 0

subsession, whiteboard and record are present in enabledButtons but in neither visibleButtons nor overflowButtons — so nothing renders them anywhere.

All gate terms are satisfied at that same moment, so this is not the button’s enable condition:

session.isHost                                   = true
session.config.featuresOptions.subsession.enable = true
subsession.isSubsessionEnabled                   = true
getSubsessionClient().isSubsessionEnabled()      = true   (SDK agrees)

Mechanism. In useResponsiveFooter:

const [n, setN] = useState(0);                        // measured width
const [state, setState] = useState({
  visibleButtons: new Set(enabledButtons),
  overflowButtons: []
});

useEffect(() => {
  const el = containerRef.current;
  if (!el) return;                                    // (1) early return -> no observer
  const ro = new ResizeObserver(entries => { ...setN(width)... });
  ro.observe(el);
  const w = el.offsetWidth;
  if (w > 0) setN(w);
  return () => ro.disconnect();
}, [containerRef]);                                   // (2) dep is the stable ref -> never re-runs

useEffect(() => {
  if (n > 0) setState(compute(n));                    // (3) gated on n > 0
}, [n, compute]);

Returning to the main session is a full reconnect (connection-change{ state: "Reconnecting", reason: "back to main session" }), which unmounts and remounts the Footer. On that remount the container is not laid out yet, so containerRef.current is null when the effect runs: (1) early-returns and no ResizeObserver is ever created. Because the dependency is the stable ref object, (2) the effect never runs again. n therefore stays 0 permanently, so (3) never fires.

That leaves the visible/overflow split frozen at its mount-time initial value. At that instant only 4 buttons had satisfied gates, so visibleButtons froze at those 4 and overflowButtons stayed []. whiteboard, record and subsession satisfy their gates a moment later and are correctly appended to enabledButtons — but because the split never recomputes, they are stranded in neither bucket and cannot render. That is also why they never appear in the ⋯ menu.

Confirmation: dispatching ui/setFooterEnable false → true (forcing only the Footer to remount, with the container now laid out) restores all three buttons immediately, with no rejoin. That isolates the defect to this hook.

Suggested fixes (any one resolves it; 1 is the most robust):

  1. Derive the split instead of storing it. Compute { visible, overflow } with useMemo from enabledButtons + width, treating an unmeasured width as “show all” rather than freezing. This structurally removes the “button in neither bucket” state.
  2. Do not gate the recompute on n > 0. Recompute whenever enabledButtons changes, falling back to containerRef.current?.offsetWidth.
  3. Make observer attachment resilient. Use a callback ref (or re-run the effect when enabledButtons changes) so a null containerRef.current on mount is retried instead of permanently skipped.

Invariant worth enforcing regardless: every entry in enabledButtons should always appear in exactly one of visibleButtons / overflowButtons. Today a button can silently exist in neither and become unreachable to the user.

Separately, in the same reconnect path: after returning to the main session, session.isJoined stays false, isJoinResolved stays false and sessionId stays null, while status is "Connected" and sessionInfo.isInMeeting is true. The Reconnecting branch resets session state, but the Connected branch never re-dispatches the join action.