fix(session-sharing): replace local presentation after takeover (#1500)

This commit is contained in:
ZacharyZcR authored and GitHub committed 2026-09-30 09:45:34 -05:00
1 parent fb28cd1f21
commit f6ea218100
2 files changed
+113

No files matched your search

@@ -145,6 +145,7 @@ export function CollabRoomTab({
const draftRef = useRef<PresentDraft | null>(null);
draftRef.current = draft;
const stageKeyRef = useRef<string | null>(null);
const presenterRef = useRef<string | null>(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.
@@ -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<typeof fixture>) => Promise<void>;
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();
});
});