From ebef8aa0619c2d331b2a6195ba5c3019c9be3aeb Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Sun, 7 Jun 2026 22:51:16 +0200 Subject: [PATCH] fix(orbit): show the reconnect prompt under React StrictMode The startup preflight set its one-shot ref before the async ran, so under StrictMode's dev double-invoke (mount -> cleanup -> mount) the cancelled first run blocked setCandidate while the second mount short-circuited on the ref, and the prompt never appeared in dev builds. The decided-flag is now set only after the cancellation guard so the second (real) mount completes the check. Adds a regression test that renders the modal under StrictMode. --- src/components/OrbitReconnectModal.test.tsx | 118 ++++++++++++++++++++ src/components/OrbitReconnectModal.tsx | 31 ++--- 2 files changed, 136 insertions(+), 13 deletions(-) create mode 100644 src/components/OrbitReconnectModal.test.tsx diff --git a/src/components/OrbitReconnectModal.test.tsx b/src/components/OrbitReconnectModal.test.tsx new file mode 100644 index 00000000..9a5aedd7 --- /dev/null +++ b/src/components/OrbitReconnectModal.test.tsx @@ -0,0 +1,118 @@ +import { StrictMode } from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { makeInitialOrbitState } from '../api/orbit'; + +const { authState, orbitState, orbitApi } = vi.hoisted(() => ({ + authState: { isLoggedIn: true, activeServerId: 'srv-1' as string | null, username: 'bob' as string | undefined }, + orbitState: { role: null as 'host' | 'guest' | null }, + orbitApi: { + readOrbitLastSession: vi.fn(), + findSessionPlaylistId: vi.fn(), + readOrbitState: vi.fn(), + clearOrbitLastSession: vi.fn(), + resumeOrbitSessionAsHost: vi.fn(), + joinOrbitSession: vi.fn(), + }, +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (k: string) => k }), +})); +vi.mock('../store/authStore', () => { + const hook = (sel: (s: typeof authState) => unknown) => sel(authState); + (hook as unknown as { getState: () => unknown }).getState = () => ({ + ...authState, + getActiveServer: () => (authState.username ? { username: authState.username } : undefined), + }); + return { useAuthStore: hook }; +}); +vi.mock('../store/orbitStore', () => { + const hook = (sel: (s: typeof orbitState) => unknown) => sel(orbitState); + (hook as unknown as { getState: () => unknown }).getState = () => orbitState; + return { useOrbitStore: hook }; +}); +vi.mock('../utils/ui/toast', () => ({ showToast: vi.fn() })); +vi.mock('../utils/orbit', () => ({ + ...orbitApi, + ORBIT_RECONNECT_COUNTDOWN_S: 30, + ORBIT_RECONNECT_MAX_AGE_MS: 30 * 60_000, +})); + +import OrbitReconnectModal from './OrbitReconnectModal'; + +const hostBreadcrumb = { + sid: 'feedface', + sessionPlaylistId: 'pl-1', + outboxPlaylistId: 'ob-1', + role: 'host' as const, + sessionName: 'Night Run', + hostUsername: 'bob', + serverId: 'srv-1', + savedAt: 1, +}; + +beforeEach(() => { + vi.clearAllMocks(); + authState.isLoggedIn = true; + authState.activeServerId = 'srv-1'; + authState.username = 'bob'; + orbitState.role = null; + orbitApi.readOrbitLastSession.mockReturnValue(hostBreadcrumb); + orbitApi.findSessionPlaylistId.mockResolvedValue('pl-1'); + orbitApi.readOrbitState.mockResolvedValue( + makeInitialOrbitState({ sid: 'feedface', host: 'bob', name: 'Night Run' }), + ); +}); + +describe('OrbitReconnectModal', () => { + // Regression: StrictMode double-invokes effects (mount → cleanup → mount). + // The old one-shot guard let the cancelled first run block the second, so the + // prompt never appeared in dev builds. This must survive the double-invoke. + it('shows the reconnect prompt under StrictMode for a live host session', async () => { + render( + + + , + ); + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); + expect(orbitApi.clearOrbitLastSession).not.toHaveBeenCalled(); + }); + + it('does not prompt when the breadcrumb is for a different server', async () => { + authState.activeServerId = 'other-server'; + render( + + + , + ); + await Promise.resolve(); + await Promise.resolve(); + expect(screen.queryByRole('dialog')).toBeNull(); + expect(orbitApi.findSessionPlaylistId).not.toHaveBeenCalled(); + }); + + it('wipes the breadcrumb and stays silent when the session is gone', async () => { + orbitApi.findSessionPlaylistId.mockResolvedValue(null); + render( + + + , + ); + await waitFor(() => expect(orbitApi.clearOrbitLastSession).toHaveBeenCalled()); + expect(screen.queryByRole('dialog')).toBeNull(); + }); + + it('does not prompt while already bound to a session', async () => { + orbitState.role = 'host'; + render( + + + , + ); + await Promise.resolve(); + await Promise.resolve(); + expect(screen.queryByRole('dialog')).toBeNull(); + expect(orbitApi.findSessionPlaylistId).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/OrbitReconnectModal.tsx b/src/components/OrbitReconnectModal.tsx index 632cbac2..bb7927a4 100644 --- a/src/components/OrbitReconnectModal.tsx +++ b/src/components/OrbitReconnectModal.tsx @@ -36,15 +36,19 @@ export default function OrbitReconnectModal() { const [candidate, setCandidate] = useState(null); const [secondsLeft, setSecondsLeft] = useState(ORBIT_RECONNECT_COUNTDOWN_S); const [busy, setBusy] = useState(false); - const ranRef = useRef(false); + const decidedRef = useRef(false); const firedRef = useRef(false); // One-shot startup preflight: is there a live session worth offering? + // + // StrictMode-safe: `decidedRef` is set only *after* the `cancelled` guard + // inside the async, never up front. React's dev double-invoke (mount → + // cleanup → mount) cancels the first run before it can decide, so the second + // (real) mount still runs the check to completion. A network error leaves + // `decidedRef` false so a later dependency change retries. useEffect(() => { - if (ranRef.current) return; - if (!isLoggedIn || !activeServerId) return; // wait for auth hydration - if (orbitRole !== null) return; // already bound to a session - ranRef.current = true; + if (decidedRef.current) return; + if (!isLoggedIn || !activeServerId || orbitRole !== null) return; const rec = readOrbitLastSession(); if (!rec || rec.serverId !== activeServerId) return; // none / different server @@ -53,22 +57,23 @@ export default function OrbitReconnectModal() { void (async () => { try { const sessionPlaylistId = await findSessionPlaylistId(rec.sid); - if (!sessionPlaylistId) { clearOrbitLastSession(); return; } + if (cancelled) return; + if (!sessionPlaylistId) { decidedRef.current = true; clearOrbitLastSession(); return; } const state = await readOrbitState(sessionPlaylistId); - if (!state || state.ended) { clearOrbitLastSession(); return; } + if (cancelled) return; + if (!state || state.ended) { decidedRef.current = true; clearOrbitLastSession(); return; } // Too long since the last host snapshot → treat as dead, don't offer. if (Date.now() - (state.positionAt ?? 0) > ORBIT_RECONNECT_MAX_AGE_MS) { - clearOrbitLastSession(); - return; + decidedRef.current = true; clearOrbitLastSession(); return; } // A host breadcrumb only resumes if we're still the session's host. if (rec.role === 'host' && state.host !== useAuthStore.getState().getActiveServer()?.username) { - clearOrbitLastSession(); - return; + decidedRef.current = true; clearOrbitLastSession(); return; } - if (!cancelled) setCandidate(rec); + decidedRef.current = true; + setCandidate(rec); } catch { - /* network hiccup — keep the breadcrumb, just skip the prompt this launch */ + /* network hiccup — keep the breadcrumb + retry on the next dep change */ } })(); return () => { cancelled = true; };