From 56b676b04a48681f50916ed4fb7eac4444f3033d Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:48:49 +0100 Subject: [PATCH] perf(startup): defer offline sync and backup status until the grid is interactive Both fired during hydration and competed with rendering the grid, and the layout waited for the user store before it could show a signed-in shell even though the server already knew who the user was. Seed the user from the layout data, and queue the offline sync and backup refresh behind the grid's interactive mark; an explicit refresh, a reconnect or an edit still runs straight away. Backup refreshes now share one in-flight request per generation, and a change of account cancels the pending startup refresh and clears the stale status. --- src/lib/stores/backupStatus.ts | 14 +++++++++++++- src/lib/stores/offlineSync.ts | 9 +++++++-- src/routes/+layout.svelte | 15 ++++++++++++--- src/routes/+layout.ts | 1 + tests/unit/backupStatus.test.ts | 14 +++++++++++++- 5 files changed, 46 insertions(+), 7 deletions(-) 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;