refactor(settings): extract changelog check logic to reusable utility
Move changelog version check effect from SettingsModal to a new scheduleChangelogCheck utility for improved modularity and testability. Add unit tests for changelog check scheduling behavior.
This commit is contained in:
parent
beb854cb39
commit
0787d2e88a
3 changed files with 175 additions and 48 deletions
|
|
@ -34,6 +34,7 @@ import { useRouter } from 'next/navigation';
|
||||||
import { showPrivacyModal } from '@/components/PrivacyModal';
|
import { showPrivacyModal } from '@/components/PrivacyModal';
|
||||||
import { deleteDocuments, mimeTypeForDoc, uploadDocuments } from '@/lib/client/api/documents';
|
import { deleteDocuments, mimeTypeForDoc, uploadDocuments } from '@/lib/client/api/documents';
|
||||||
import { postChangelogVersionCheck } from '@/lib/client/api/user-state';
|
import { postChangelogVersionCheck } from '@/lib/client/api/user-state';
|
||||||
|
import { scheduleChangelogCheck } from '@/lib/client/changelog-check';
|
||||||
import { cacheStoredDocumentFromBytes, clearDocumentCache } from '@/lib/client/cache/documents';
|
import { cacheStoredDocumentFromBytes, clearDocumentCache } from '@/lib/client/cache/documents';
|
||||||
import { clearAllDocumentPreviewCaches, clearInMemoryDocumentPreviewCache } from '@/lib/client/cache/previews';
|
import { clearAllDocumentPreviewCaches, clearInMemoryDocumentPreviewCache } from '@/lib/client/cache/previews';
|
||||||
import { resolveTtsSettingsViewModel } from '@/lib/client/settings/tts-settings';
|
import { resolveTtsSettingsViewModel } from '@/lib/client/settings/tts-settings';
|
||||||
|
|
@ -253,54 +254,21 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
||||||
}, [authEnabled, checkFirstVist]);
|
}, [authEnabled, checkFirstVist]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
return scheduleChangelogCheck({
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
authEnabled,
|
||||||
const run = async () => {
|
isSessionPending,
|
||||||
const sessionUserId = session?.user?.id ?? null;
|
sessionUserId: session?.user?.id,
|
||||||
if (authEnabled && (isSessionPending || !sessionUserId)) return;
|
appVersion: runtimeConfig.appVersion,
|
||||||
|
completedRef: changelogVersionCheckKeyRef,
|
||||||
const currentVersion = normalizeVersion(runtimeConfig.appVersion || '');
|
inFlightRef: changelogVersionCheckInFlightRef,
|
||||||
if (!currentVersion) return;
|
postCheck: async (currentVersion) => postChangelogVersionCheck(currentVersion),
|
||||||
|
onShouldOpen: () => {
|
||||||
const userKey = sessionUserId ?? 'server-unclaimed';
|
setIsOpen(true);
|
||||||
const checkKey = `${userKey}:${currentVersion}`;
|
setIsChangelogOpen(true);
|
||||||
if (changelogVersionCheckKeyRef.current === checkKey) return;
|
},
|
||||||
if (changelogVersionCheckInFlightRef.current === checkKey) return;
|
delayMs: 120,
|
||||||
changelogVersionCheckInFlightRef.current = checkKey;
|
retryDelayMs: 400,
|
||||||
|
});
|
||||||
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);
|
|
||||||
};
|
|
||||||
}, [authEnabled, isSessionPending, session?.user?.id, runtimeConfig.appVersion]);
|
}, [authEnabled, isSessionPending, session?.user?.id, runtimeConfig.appVersion]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
78
src/lib/client/changelog-check.ts
Normal file
78
src/lib/client/changelog-check.ts
Normal file
|
|
@ -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<ChangelogVersionCheckResponse>;
|
||||||
|
onShouldOpen: () => void;
|
||||||
|
retryDelayMs?: number;
|
||||||
|
sleep?: (ms: number) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function runChangelogCheck(args: RunChangelogCheckArgs): Promise<void> {
|
||||||
|
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<void>((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);
|
||||||
|
};
|
||||||
|
}
|
||||||
81
tests/unit/changelog-check.spec.ts
Normal file
81
tests/unit/changelog-check.spec.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { scheduleChangelogCheck } from '../../src/lib/client/changelog-check';
|
||||||
|
|
||||||
|
function wait(ms: number): Promise<void> {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue