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.
This commit is contained in:
Psychotoxical
2026-06-07 22:51:16 +02:00
parent 9de56bf638
commit ebef8aa061
2 changed files with 136 additions and 13 deletions
+118
View File
@@ -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(
<StrictMode>
<OrbitReconnectModal />
</StrictMode>,
);
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(
<StrictMode>
<OrbitReconnectModal />
</StrictMode>,
);
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(
<StrictMode>
<OrbitReconnectModal />
</StrictMode>,
);
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(
<StrictMode>
<OrbitReconnectModal />
</StrictMode>,
);
await Promise.resolve();
await Promise.resolve();
expect(screen.queryByRole('dialog')).toBeNull();
expect(orbitApi.findSessionPlaylistId).not.toHaveBeenCalled();
});
});
+18 -13
View File
@@ -36,15 +36,19 @@ export default function OrbitReconnectModal() {
const [candidate, setCandidate] = useState<OrbitLastSession | null>(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; };