diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 14d8e67..7f76a8f 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -34,6 +34,7 @@ import { useRouter } from 'next/navigation'; import { showPrivacyModal } from '@/components/PrivacyModal'; import { deleteDocuments, mimeTypeForDoc, uploadDocuments } from '@/lib/client/api/documents'; import { postChangelogVersionCheck } from '@/lib/client/api/user-state'; +import { scheduleChangelogCheck } from '@/lib/client/changelog-check'; import { cacheStoredDocumentFromBytes, clearDocumentCache } from '@/lib/client/cache/documents'; import { clearAllDocumentPreviewCaches, clearInMemoryDocumentPreviewCache } from '@/lib/client/cache/previews'; import { resolveTtsSettingsViewModel } from '@/lib/client/settings/tts-settings'; @@ -253,54 +254,21 @@ export function SettingsModal({ className = '' }: { className?: string }) { }, [authEnabled, checkFirstVist]); useEffect(() => { - let active = true; - let timer: ReturnType | null = null; - const run = async () => { - const sessionUserId = session?.user?.id ?? null; - if (authEnabled && (isSessionPending || !sessionUserId)) return; - - const currentVersion = normalizeVersion(runtimeConfig.appVersion || ''); - if (!currentVersion) return; - - const userKey = sessionUserId ?? 'server-unclaimed'; - const checkKey = `${userKey}:${currentVersion}`; - if (changelogVersionCheckKeyRef.current === checkKey) return; - if (changelogVersionCheckInFlightRef.current === checkKey) return; - changelogVersionCheckInFlightRef.current = checkKey; - - try { - for (let attempt = 0; attempt < 2; attempt += 1) { - try { - const result = await postChangelogVersionCheck(currentVersion); - changelogVersionCheckKeyRef.current = checkKey; - if (result.shouldOpen && active) { - setIsOpen(true); - setIsChangelogOpen(true); - } - return; - } catch (error) { - if (attempt === 1) throw error; - await new Promise((resolve) => setTimeout(resolve, 400)); - } - } - } catch (error) { - console.warn('Failed to check changelog version:', error); - } finally { - if (changelogVersionCheckInFlightRef.current === checkKey) { - changelogVersionCheckInFlightRef.current = null; - } - } - }; - - // In React Strict Mode (dev), effects mount/unmount once before the real mount. - // Deferring the network mutation avoids writing lastSeenVersion from the throwaway pass. - timer = setTimeout(() => { - void run(); - }, 120); - return () => { - active = false; - if (timer) clearTimeout(timer); - }; + return scheduleChangelogCheck({ + authEnabled, + isSessionPending, + sessionUserId: session?.user?.id, + appVersion: runtimeConfig.appVersion, + completedRef: changelogVersionCheckKeyRef, + inFlightRef: changelogVersionCheckInFlightRef, + postCheck: async (currentVersion) => postChangelogVersionCheck(currentVersion), + onShouldOpen: () => { + setIsOpen(true); + setIsChangelogOpen(true); + }, + delayMs: 120, + retryDelayMs: 400, + }); }, [authEnabled, isSessionPending, session?.user?.id, runtimeConfig.appVersion]); useEffect(() => { diff --git a/src/lib/client/changelog-check.ts b/src/lib/client/changelog-check.ts new file mode 100644 index 0000000..70b1571 --- /dev/null +++ b/src/lib/client/changelog-check.ts @@ -0,0 +1,78 @@ +import { normalizeVersion } from '@/lib/shared/changelog'; + +export type ChangelogVersionCheckResponse = { + shouldOpen: boolean; + currentVersion: string; + lastSeenVersion: string | null; +}; + +type RefLike = { current: string | null }; + +export type RunChangelogCheckArgs = { + authEnabled: boolean; + isSessionPending: boolean; + sessionUserId: string | null | undefined; + appVersion: string | null | undefined; + completedRef: RefLike; + inFlightRef: RefLike; + postCheck: (currentVersion: string) => Promise; + onShouldOpen: () => void; + retryDelayMs?: number; + sleep?: (ms: number) => Promise; +}; + +export async function runChangelogCheck(args: RunChangelogCheckArgs): Promise { + const sessionUserId = args.sessionUserId ?? null; + if (args.authEnabled && (args.isSessionPending || !sessionUserId)) return; + + const currentVersion = normalizeVersion(args.appVersion || ''); + if (!currentVersion) return; + + const userKey = sessionUserId ?? 'server-unclaimed'; + const checkKey = `${userKey}:${currentVersion}`; + if (args.completedRef.current === checkKey) return; + if (args.inFlightRef.current === checkKey) return; + args.inFlightRef.current = checkKey; + + const retryDelayMs = args.retryDelayMs ?? 400; + const sleep = args.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); + + try { + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + const result = await args.postCheck(currentVersion); + args.completedRef.current = checkKey; + if (result.shouldOpen) { + args.onShouldOpen(); + } + return; + } catch (error) { + if (attempt === 1) throw error; + await sleep(retryDelayMs); + } + } + } catch (error) { + console.warn('Failed to check changelog version:', error); + } finally { + if (args.inFlightRef.current === checkKey) { + args.inFlightRef.current = null; + } + } +} + +export type ScheduleChangelogCheckArgs = RunChangelogCheckArgs & { + delayMs?: number; +}; + +export function scheduleChangelogCheck(args: ScheduleChangelogCheckArgs): () => void { + let active = true; + const timer = setTimeout(() => { + if (!active) return; + void runChangelogCheck(args); + }, args.delayMs ?? 120); + + return () => { + active = false; + clearTimeout(timer); + }; +} diff --git a/tests/unit/changelog-check.spec.ts b/tests/unit/changelog-check.spec.ts new file mode 100644 index 0000000..f95bcbc --- /dev/null +++ b/tests/unit/changelog-check.spec.ts @@ -0,0 +1,81 @@ +import { expect, test } from '@playwright/test'; + +import { scheduleChangelogCheck } from '../../src/lib/client/changelog-check'; + +function wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +test.describe('changelog check scheduling', () => { + test('cancels throwaway mount and runs exactly once on remount', async () => { + const completedRef = { current: null as string | null }; + const inFlightRef = { current: null as string | null }; + let apiCalls = 0; + let openCalls = 0; + + const args = { + authEnabled: true, + isSessionPending: false, + sessionUserId: 'u1', + appVersion: '3.3.0', + completedRef, + inFlightRef, + postCheck: async () => { + apiCalls += 1; + return { + shouldOpen: true, + currentVersion: '3.3.0', + lastSeenVersion: null, + }; + }, + onShouldOpen: () => { + openCalls += 1; + }, + delayMs: 40, + retryDelayMs: 1, + } as const; + + const cleanupThrowaway = scheduleChangelogCheck(args); + cleanupThrowaway(); + + const cleanupReal = scheduleChangelogCheck(args); + await wait(120); + cleanupReal(); + + expect(apiCalls).toBe(1); + expect(openCalls).toBe(1); + expect(completedRef.current).toBe('u1:3.3.0'); + expect(inFlightRef.current).toBeNull(); + }); + + test('does not run when auth is enabled and session is pending', async () => { + const completedRef = { current: null as string | null }; + const inFlightRef = { current: null as string | null }; + let apiCalls = 0; + + const cleanup = scheduleChangelogCheck({ + authEnabled: true, + isSessionPending: true, + sessionUserId: null, + appVersion: '3.3.0', + completedRef, + inFlightRef, + postCheck: async () => { + apiCalls += 1; + return { + shouldOpen: true, + currentVersion: '3.3.0', + lastSeenVersion: null, + }; + }, + onShouldOpen: () => undefined, + delayMs: 20, + }); + + await wait(80); + cleanup(); + expect(apiCalls).toBe(0); + expect(completedRef.current).toBeNull(); + expect(inFlightRef.current).toBeNull(); + }); +});