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; };