diff --git a/src/lib/stores/backupStatus.ts b/src/lib/stores/backupStatus.ts index 5071186..130e0f3 100644 --- a/src/lib/stores/backupStatus.ts +++ b/src/lib/stores/backupStatus.ts @@ -24,7 +24,19 @@ export function setBackupStatus(integrations: IntegrationSummary[]): void { backupsNeedingReconnect.set(integrations.filter((i) => !i.enabled).map((i) => i.provider)); } -export async function refreshBackupStatus(): Promise { +let refreshInFlight: { generation: number; promise: Promise } | null = null; + +export function refreshBackupStatus(): Promise { + if (refreshInFlight?.generation === mutationGeneration) return refreshInFlight.promise; + const generation = mutationGeneration; + const promise = performRefresh().finally(() => { + if (refreshInFlight?.promise === promise) refreshInFlight = null; + }); + refreshInFlight = { generation, promise }; + return promise; +} + +async function performRefresh(): Promise { if (typeof window === 'undefined' || !navigator.onLine) return; const sequence = ++refreshSequence; const generation = mutationGeneration; diff --git a/src/lib/stores/offlineSync.ts b/src/lib/stores/offlineSync.ts index a6fa049..271a3b9 100644 --- a/src/lib/stores/offlineSync.ts +++ b/src/lib/stores/offlineSync.ts @@ -1,3 +1,4 @@ +import { afterCriticalPageWork } from '$lib/utils/criticalPageWork'; import { writable } from 'svelte/store'; import { PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER } from '$env/static/public'; import type { OfflineSnapshot } from '$lib/models/OfflineSnapshot'; @@ -246,15 +247,19 @@ export function startOfflineSync(getUserId: () => string | null): () => void { }, 1_000); }; // Explicit requests (edits, Retry, a new sign-in) and reconnecting always fetch a new copy. - const schedule = () => scheduleSync(false); + const schedule = () => { + cancelStartup(); + scheduleSync(false); + }; // Best effort: ask the browser not to evict the offline artwork cache under storage pressure. void navigator.storage?.persist?.().catch(() => undefined); window.addEventListener(SYNC_EVENT, schedule); window.addEventListener('online', schedule); - scheduleSync(true); + const cancelStartup = afterCriticalPageWork(() => scheduleSync(true)); return () => { stopped = true; + cancelStartup(); if (timer !== null) window.clearTimeout(timer); window.removeEventListener(SYNC_EVENT, schedule); window.removeEventListener('online', schedule); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index a0e9677..d4217fa 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -2,6 +2,7 @@ // The only app stylesheet: Vite bundles, minifies and content-hashes it so it is cached for good. // static/output.css is built separately for the credential-free offline.html page only. import '../app.css'; + import { afterCriticalPageWork } from '$lib/utils/criticalPageWork'; import { onDestroy, onMount } from 'svelte'; import { user } from '$lib/stores/user.js'; import { type User } from '@supabase/auth-js'; @@ -27,18 +28,21 @@ let { supabase } = data; $: ({ supabase } = data); - let localUser = null as User | null; + let localUser: User | null = data.user ?? null; + let userStoreReady = false; const unsubscribe = user.subscribe((value) => { - localUser = value; + if (userStoreReady) localUser = value; }); onDestroy(unsubscribe); let authSubscription: { unsubscribe: () => void } | null = null; + let cancelBackupStartup: (() => void) | null = null; let stopOfflineSync: (() => void) | null = null; let isOnline = true; let signOutError = ''; onMount(() => { + userStoreReady = true; isOnline = navigator.onLine; const updateOnlineState = () => { isOnline = navigator.onLine; @@ -60,7 +64,9 @@ void getUser() .then(async () => { if (localUser) { - void refreshBackupStatus(); + cancelBackupStartup = afterCriticalPageWork(() => { + if (localUser) void refreshBackupStatus(); + }); await claimOfflineData(localUser.id); } stopOfflineSync = startOfflineSync(() => localUser?.id ?? null); @@ -77,6 +83,8 @@ // SIGNED_IN also fires when a tab regains focus, so only a change of account fetches a new // offline copy. Page loads and token refreshes reuse the saved one while it is fresh. if (event === 'SIGNED_IN' && session.user.id !== previousUserId) { + cancelBackupStartup?.(); + clearBackupStatus(); void claimOfflineData(session.user.id) .then(requestOfflineSync) .catch((error) => console.error('Unable to claim offline data', error)); @@ -96,6 +104,7 @@ return () => { authSubscription?.unsubscribe(); stopOfflineSync?.(); + cancelBackupStartup?.(); window.removeEventListener('online', updateOnlineState); window.removeEventListener('offline', updateOnlineState); for (const name of ['click', 'submit', 'input', 'change', 'keydown']) { diff --git a/src/routes/+layout.ts b/src/routes/+layout.ts index 72058fb..e3c7c6c 100644 --- a/src/routes/+layout.ts +++ b/src/routes/+layout.ts @@ -60,6 +60,7 @@ export const load: LayoutLoad = async ({ fetch, data, depends }) => { return { supabase, session, + user: data.user, recoveryIntent: !!session && (hashRecoveryCallback || recoveryExchangeSucceeded) }; }; diff --git a/tests/unit/backupStatus.test.ts b/tests/unit/backupStatus.test.ts index 1161fc0..606380e 100644 --- a/tests/unit/backupStatus.test.ts +++ b/tests/unit/backupStatus.test.ts @@ -112,13 +112,25 @@ describe('refreshBackupStatus', () => { const json = (body: unknown) => new Response(JSON.stringify(body)); - it('ignores a response overtaken by a newer refresh', async () => { + it('coalesces concurrent refreshes for the same account state', async () => { + const slow = deferredResponse(); + fetchMock.mockReturnValueOnce(slow.promise); + const first = refreshBackupStatus(); + const second = refreshBackupStatus(); + expect(second).toBe(first); + expect(fetchMock).toHaveBeenCalledTimes(1); + slow.resolve(json([])); + await Promise.all([first, second]); + }); + + it('ignores a response overtaken by a new account refresh', async () => { const slow = deferredResponse(); fetchMock .mockReturnValueOnce(slow.promise) .mockResolvedValueOnce(json([{ provider: 'google_drive', enabled: true }])); const first = refreshBackupStatus(); + clearBackupStatus(); await refreshBackupStatus(); slow.resolve(json([{ provider: 'google_drive', enabled: false }])); await first;