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.
This commit is contained in:
Josh Creek
2026-09-15 17:48:49 +01:00
parent 19abb34693
commit 56b676b04a
5 changed files with 46 additions and 7 deletions
+13 -1
View File
@@ -24,7 +24,19 @@ export function setBackupStatus(integrations: IntegrationSummary[]): void {
backupsNeedingReconnect.set(integrations.filter((i) => !i.enabled).map((i) => i.provider)); backupsNeedingReconnect.set(integrations.filter((i) => !i.enabled).map((i) => i.provider));
} }
export async function refreshBackupStatus(): Promise<void> { let refreshInFlight: { generation: number; promise: Promise<void> } | null = null;
export function refreshBackupStatus(): Promise<void> {
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<void> {
if (typeof window === 'undefined' || !navigator.onLine) return; if (typeof window === 'undefined' || !navigator.onLine) return;
const sequence = ++refreshSequence; const sequence = ++refreshSequence;
const generation = mutationGeneration; const generation = mutationGeneration;
+7 -2
View File
@@ -1,3 +1,4 @@
import { afterCriticalPageWork } from '$lib/utils/criticalPageWork';
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
import { PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER } from '$env/static/public'; import { PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER } from '$env/static/public';
import type { OfflineSnapshot } from '$lib/models/OfflineSnapshot'; import type { OfflineSnapshot } from '$lib/models/OfflineSnapshot';
@@ -246,15 +247,19 @@ export function startOfflineSync(getUserId: () => string | null): () => void {
}, 1_000); }, 1_000);
}; };
// Explicit requests (edits, Retry, a new sign-in) and reconnecting always fetch a new copy. // 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. // Best effort: ask the browser not to evict the offline artwork cache under storage pressure.
void navigator.storage?.persist?.().catch(() => undefined); void navigator.storage?.persist?.().catch(() => undefined);
window.addEventListener(SYNC_EVENT, schedule); window.addEventListener(SYNC_EVENT, schedule);
window.addEventListener('online', schedule); window.addEventListener('online', schedule);
scheduleSync(true); const cancelStartup = afterCriticalPageWork(() => scheduleSync(true));
return () => { return () => {
stopped = true; stopped = true;
cancelStartup();
if (timer !== null) window.clearTimeout(timer); if (timer !== null) window.clearTimeout(timer);
window.removeEventListener(SYNC_EVENT, schedule); window.removeEventListener(SYNC_EVENT, schedule);
window.removeEventListener('online', schedule); window.removeEventListener('online', schedule);
+12 -3
View File
@@ -2,6 +2,7 @@
// The only app stylesheet: Vite bundles, minifies and content-hashes it so it is cached for good. // 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. // static/output.css is built separately for the credential-free offline.html page only.
import '../app.css'; import '../app.css';
import { afterCriticalPageWork } from '$lib/utils/criticalPageWork';
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import { user } from '$lib/stores/user.js'; import { user } from '$lib/stores/user.js';
import { type User } from '@supabase/auth-js'; import { type User } from '@supabase/auth-js';
@@ -27,18 +28,21 @@
let { supabase } = data; let { supabase } = data;
$: ({ 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) => { const unsubscribe = user.subscribe((value) => {
localUser = value; if (userStoreReady) localUser = value;
}); });
onDestroy(unsubscribe); onDestroy(unsubscribe);
let authSubscription: { unsubscribe: () => void } | null = null; let authSubscription: { unsubscribe: () => void } | null = null;
let cancelBackupStartup: (() => void) | null = null;
let stopOfflineSync: (() => void) | null = null; let stopOfflineSync: (() => void) | null = null;
let isOnline = true; let isOnline = true;
let signOutError = ''; let signOutError = '';
onMount(() => { onMount(() => {
userStoreReady = true;
isOnline = navigator.onLine; isOnline = navigator.onLine;
const updateOnlineState = () => { const updateOnlineState = () => {
isOnline = navigator.onLine; isOnline = navigator.onLine;
@@ -60,7 +64,9 @@
void getUser() void getUser()
.then(async () => { .then(async () => {
if (localUser) { if (localUser) {
void refreshBackupStatus(); cancelBackupStartup = afterCriticalPageWork(() => {
if (localUser) void refreshBackupStatus();
});
await claimOfflineData(localUser.id); await claimOfflineData(localUser.id);
} }
stopOfflineSync = startOfflineSync(() => localUser?.id ?? null); 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 // 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. // offline copy. Page loads and token refreshes reuse the saved one while it is fresh.
if (event === 'SIGNED_IN' && session.user.id !== previousUserId) { if (event === 'SIGNED_IN' && session.user.id !== previousUserId) {
cancelBackupStartup?.();
clearBackupStatus();
void claimOfflineData(session.user.id) void claimOfflineData(session.user.id)
.then(requestOfflineSync) .then(requestOfflineSync)
.catch((error) => console.error('Unable to claim offline data', error)); .catch((error) => console.error('Unable to claim offline data', error));
@@ -96,6 +104,7 @@
return () => { return () => {
authSubscription?.unsubscribe(); authSubscription?.unsubscribe();
stopOfflineSync?.(); stopOfflineSync?.();
cancelBackupStartup?.();
window.removeEventListener('online', updateOnlineState); window.removeEventListener('online', updateOnlineState);
window.removeEventListener('offline', updateOnlineState); window.removeEventListener('offline', updateOnlineState);
for (const name of ['click', 'submit', 'input', 'change', 'keydown']) { for (const name of ['click', 'submit', 'input', 'change', 'keydown']) {
+1
View File
@@ -60,6 +60,7 @@ export const load: LayoutLoad = async ({ fetch, data, depends }) => {
return { return {
supabase, supabase,
session, session,
user: data.user,
recoveryIntent: !!session && (hashRecoveryCallback || recoveryExchangeSucceeded) recoveryIntent: !!session && (hashRecoveryCallback || recoveryExchangeSucceeded)
}; };
}; };
+13 -1
View File
@@ -112,13 +112,25 @@ describe('refreshBackupStatus', () => {
const json = (body: unknown) => new Response(JSON.stringify(body)); 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(); const slow = deferredResponse();
fetchMock fetchMock
.mockReturnValueOnce(slow.promise) .mockReturnValueOnce(slow.promise)
.mockResolvedValueOnce(json([{ provider: 'google_drive', enabled: true }])); .mockResolvedValueOnce(json([{ provider: 'google_drive', enabled: true }]));
const first = refreshBackupStatus(); const first = refreshBackupStatus();
clearBackupStatus();
await refreshBackupStatus(); await refreshBackupStatus();
slow.resolve(json([{ provider: 'google_drive', enabled: false }])); slow.resolve(json([{ provider: 'google_drive', enabled: false }]));
await first; await first;