From 2013a24b6a48949123ea1dd1a504109ec23f0a64 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 23 Jun 2026 09:36:32 +0000 Subject: [PATCH] fix(web): use a UUID participantId for session viewers (not useId) Web session viewers set participantId via React useId(), which returns opaque ids like ":r0:". /api/livekit/token (and the signal routes) validate participantId with z.string().uuid(), so opening a desktop join link on the web SFU path failed with "Invalid participant ID" / Connection Failed. (It "worked earlier" only because P2P viewers never hit the token route; the SFU host migration routed web viewers through it.) Generate a real, render-stable UUID instead: useState(() => crypto.randomUUID()). Applied to both the P2P and SFU viewers (signal routes validate uuid too). Tests: assert each viewer is handed a UUID participantId. Co-Authored-By: Claude Opus 4.8 --- apps/web/src/app/session/[id]/page.test.tsx | 65 ++++++++++++++++++++- apps/web/src/app/session/[id]/page.tsx | 12 +++- 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/apps/web/src/app/session/[id]/page.test.tsx b/apps/web/src/app/session/[id]/page.test.tsx index d8d0a2a5..362718b6 100644 --- a/apps/web/src/app/session/[id]/page.test.tsx +++ b/apps/web/src/app/session/[id]/page.test.tsx @@ -26,8 +26,25 @@ const mockUseWebRTC = { toggleMic: mockToggleMic, }; +// Capture the options each WebRTC hook is called with so we can assert the +// participantId is a valid UUID (the token/signal routes require z.uuid()). +const { mockUseWebRTCFn, mockUseWebRTCSFUFn } = vi.hoisted(() => ({ + mockUseWebRTCFn: vi.fn(), + mockUseWebRTCSFUFn: vi.fn(), +})); + vi.mock('@/hooks/useWebRTC', () => ({ - useWebRTC: () => mockUseWebRTC, + useWebRTC: (opts: { participantId: string }) => { + mockUseWebRTCFn(opts); + return mockUseWebRTC; + }, +})); + +vi.mock('@/hooks/useWebRTCSFU', () => ({ + useWebRTCSFU: (opts: { participantId: string }) => { + mockUseWebRTCSFUFn(opts); + return mockUseWebRTC; + }, })); vi.mock('@/hooks/useSessionPresence', () => ({ @@ -504,4 +521,50 @@ describe('SessionViewerPage', () => { expect(screen.queryByTestId('session-settings-panel')).not.toBeInTheDocument(); }); }); + + describe('participant identity', () => { + const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + const baseSession = { + id: 'session-123', + join_code: 'ABC123', + status: 'active', + settings: { quality: 'medium', allowControl: false, maxParticipants: 5 }, + created_at: '2024-01-01T00:00:00Z', + session_participants: [], + }; + + it('passes a UUID participantId to the SFU viewer (token route requires uuid)', async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ data: { ...baseSession, mode: 'sfu' } }), + } as Response); + + await act(async () => { + renderWithSuspense(); + }); + await waitFor(() => { + expect(mockUseWebRTCSFUFn).toHaveBeenCalled(); + }); + + const opts = mockUseWebRTCSFUFn.mock.calls[0]?.[0] as { participantId: string }; + expect(opts.participantId).toMatch(UUID_RE); + }); + + it('passes a UUID participantId to the P2P viewer', async () => { + vi.mocked(global.fetch).mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ data: { ...baseSession, mode: 'p2p' } }), + } as Response); + + await act(async () => { + renderWithSuspense(); + }); + await waitFor(() => { + expect(mockUseWebRTCFn).toHaveBeenCalled(); + }); + + const opts = mockUseWebRTCFn.mock.calls[0]?.[0] as { participantId: string }; + expect(opts.participantId).toMatch(UUID_RE); + }); + }); }); diff --git a/apps/web/src/app/session/[id]/page.tsx b/apps/web/src/app/session/[id]/page.tsx index 0ce09d96..1435366e 100644 --- a/apps/web/src/app/session/[id]/page.tsx +++ b/apps/web/src/app/session/[id]/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect, use, useId, useRef, useCallback, useMemo } from 'react'; +import { useState, useEffect, use, useRef, useCallback, useMemo } from 'react'; import Link from 'next/link'; import { Users, @@ -155,7 +155,10 @@ interface SessionViewerWrapperProps { } function P2PSessionViewer({ sessionId, session }: SessionViewerWrapperProps) { - const participantId = useId(); + // A real UUID — /api/livekit/token and the signal routes validate + // participantId with z.string().uuid(). useId() returns React opaque ids + // (":r0:"), which the SFU token route rejects ("Invalid participant ID"). + const [participantId] = useState(() => crypto.randomUUID()); const [remoteCursors, setRemoteCursors] = useState>(new Map()); const handleCursorUpdate = useCallback((cursor: CursorPositionMessage) => { @@ -189,7 +192,10 @@ function P2PSessionViewer({ sessionId, session }: SessionViewerWrapperProps) { } function SFUSessionViewer({ sessionId, session }: SessionViewerWrapperProps) { - const participantId = useId(); + // A real UUID — /api/livekit/token and the signal routes validate + // participantId with z.string().uuid(). useId() returns React opaque ids + // (":r0:"), which the SFU token route rejects ("Invalid participant ID"). + const [participantId] = useState(() => crypto.randomUUID()); const [remoteCursors, setRemoteCursors] = useState>(new Map()); const handleCursorUpdate = useCallback((cursor: CursorPositionMessage) => {