From f6ea21810069ce788b2327621601e04fa68f1a0c Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Wed, 30 Sep 2026 22:45:34 +0800 Subject: [PATCH] fix(session-sharing): replace local presentation after takeover (#1500) --- .../src/frontend/CollabRoomTab.tsx | 9 ++ .../tests/frontend/presenter-takeover.test.ts | 104 ++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 plugins/session-sharing/tests/frontend/presenter-takeover.test.ts diff --git a/plugins/session-sharing/src/frontend/CollabRoomTab.tsx b/plugins/session-sharing/src/frontend/CollabRoomTab.tsx index f236a7df8..b6b505e10 100644 --- a/plugins/session-sharing/src/frontend/CollabRoomTab.tsx +++ b/plugins/session-sharing/src/frontend/CollabRoomTab.tsx @@ -145,6 +145,7 @@ export function CollabRoomTab({ const draftRef = useRef(null); draftRef.current = draft; const stageKeyRef = useRef(null); + const presenterRef = useRef(null); const refreshSequence = useRef(0); const refresh = useCallback(async () => { @@ -153,6 +154,14 @@ export function CollabRoomTab({ try { const nextDetail = await getCollabRoom(roomId); if (sequence !== refreshSequence.current) return; + // Only discard a confirmed presentation that another member took over. + if ( + presenterRef.current === nextDetail.me && + nextDetail.stage.presenterUserId !== nextDetail.me + ) { + setDraft(null); + } + presenterRef.current = nextDetail.stage.presenterUserId; setDetail(nextDetail); setLoadError(null); // Presenting locally? The local session is the stage - don't join it. diff --git a/plugins/session-sharing/tests/frontend/presenter-takeover.test.ts b/plugins/session-sharing/tests/frontend/presenter-takeover.test.ts new file mode 100644 index 000000000..699c50cef --- /dev/null +++ b/plugins/session-sharing/tests/frontend/presenter-takeover.test.ts @@ -0,0 +1,104 @@ +import ts from "typescript"; +import { describe, expect, it, vi } from "vitest"; +import roomSource from "../../src/frontend/CollabRoomTab.tsx?raw"; + +// Run the room's real refresh callback across successive server snapshots. +const source = ts.createSourceFile( + "CollabRoomTab.tsx", + roomSource, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, +); +let callback: string | undefined; +function visit(node: ts.Node) { + if ( + ts.isVariableDeclaration(node) && + node.name.getText(source) === "refresh" && + node.initializer && + ts.isCallExpression(node.initializer) + ) { + callback = node.initializer.arguments[0].getText(source); + } + ts.forEachChild(node, visit); +} +visit(source); +if (!callback) throw new Error("Missing room refresh callback"); +const refresh = new Function( + "state", + ts.transpileModule( + ` + const { roomId, refreshSequence, presenterRef, stageKeyRef, draftRef, + getCollabRoom, getCollabStage, setDetail, setLoadError, setDraft, + setStage, getErrorMessage } = state; + return (${callback})(); +`, + { compilerOptions: { target: ts.ScriptTarget.ES2022 } }, + ).outputText, +) as (state: ReturnType) => Promise; + +function snapshot(presenterUserId: string, shareId: string) { + return { + me: "alice", + stage: { presenterUserId, shareId }, + controllerUserId: null, + }; +} + +function fixture() { + const state = { + roomId: "room", + refreshSequence: { current: 0 }, + presenterRef: { current: null as string | null }, + stageKeyRef: { current: null as string | null }, + draftRef: { current: null as object | null }, + getCollabRoom: vi.fn(async () => snapshot("alice", "alice-share")), + getCollabStage: vi.fn(async () => ({ stage: { shareId: "bob-share" } })), + setDetail: vi.fn(), + setLoadError: vi.fn(), + setDraft: vi.fn((draft: object | null) => { + state.draftRef.current = draft; + }), + setStage: vi.fn(), + getErrorMessage: String, + }; + return state; +} + +describe("meeting presenter takeover", () => { + it("replaces the previous presenter's local session with the new stage", async () => { + const state = fixture(); + state.draftRef.current = { protocol: "ssh", host: { id: 1 } }; + await refresh(state); + state.getCollabRoom.mockResolvedValue(snapshot("bob", "bob-share")); + await refresh(state); + + expect(state.draftRef.current).toBeNull(); + expect(state.setStage).toHaveBeenCalledWith({ shareId: "bob-share" }); + expect(state.getCollabStage).toHaveBeenCalledWith("room"); + }); + + it("keeps a new connection being prepared to take over another presenter", async () => { + const state = fixture(); + state.getCollabRoom.mockResolvedValue(snapshot("bob", "bob-share")); + await refresh(state); + const draft = { protocol: "ssh", host: { id: 2 } }; + state.draftRef.current = draft; + await refresh(state); + + expect(state.draftRef.current).toBe(draft); + expect(state.setDraft).not.toHaveBeenCalled(); + }); + + it("keeps the local session during ordinary room refreshes", async () => { + const state = fixture(); + const draft = { protocol: "ssh", host: { id: 1 } }; + state.draftRef.current = draft; + await refresh(state); + await refresh(state); + + expect(state.draftRef.current).toBe(draft); + expect(state.getCollabStage).not.toHaveBeenCalled(); + expect(state.setDraft).not.toHaveBeenCalled(); + }); +});