mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 23:35:44 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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; };
|
||||
|
||||
Reference in New Issue
Block a user