From fbc0c7611946f233a988038471ec69033eddd586 Mon Sep 17 00:00:00 2001 From: Maseeek Date: Mon, 12 Oct 2026 03:12:02 +0100 Subject: [PATCH 01/10] perf(sync): coalesce Duel reloads and preserve local writes Serialize Duel refreshes and collapse realtime bursts into one bounded reload plus one follow-up when events arrive during a load. Reject reads that overlap queued local writes so stale data cannot undo optimistic UI. Request exact pagination counts once when available and retain complete history under lower server page caps. Remove polling of the retired Apple Health endpoint, which already returns HTTP 410. Refs #63. --- src/lib/coalesced-reload.test.ts | 149 +++++++++++++++++++++++ src/lib/coalesced-reload.ts | 71 +++++++++++ src/lib/duel-sync.test.ts | 131 +++++++++++++++++++++ src/lib/duel-sync.ts | 17 ++- src/lib/store.tsx | 196 +++++++++++++------------------ 5 files changed, 443 insertions(+), 121 deletions(-) create mode 100644 src/lib/coalesced-reload.test.ts create mode 100644 src/lib/coalesced-reload.ts create mode 100644 src/lib/duel-sync.test.ts diff --git a/src/lib/coalesced-reload.test.ts b/src/lib/coalesced-reload.test.ts new file mode 100644 index 0000000..7814b40 --- /dev/null +++ b/src/lib/coalesced-reload.test.ts @@ -0,0 +1,149 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createCoalescedReloader } from './coalesced-reload'; + +const settle = () => new Promise(resolve => setImmediate(resolve)); +const fail = (error: unknown) => { throw error; }; + +function deferred() { + let release: (() => void) | undefined; + const promise = new Promise(resolve => { release = resolve; }); + return { promise, resolve: () => { assert.ok(release); release(); } }; +} + +test('rapid realtime events share one trailing reload', async t => { + t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 0 }); + let executions = 0; + const reloader = createCoalescedReloader(async () => { executions++; }, { onError: fail }); + for (let event = 0; event < 5; event++) { + reloader.schedule(); + t.mock.timers.tick(5); + } + t.mock.timers.tick(44); + assert.equal(executions, 0); + t.mock.timers.tick(1); + assert.equal(executions, 1); + await settle(); + t.mock.timers.tick(1000); + assert.equal(executions, 1); +}); + +test('continuous realtime events cannot postpone a reload beyond the maximum wait', async t => { + t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 0 }); + let executions = 0; + const reloader = createCoalescedReloader(async () => { executions++; }, { onError: fail }); + reloader.schedule(); + for (let event = 0; event < 3; event++) { + t.mock.timers.tick(40); + reloader.schedule(); + } + t.mock.timers.tick(29); + assert.equal(executions, 0); + t.mock.timers.tick(1); + assert.equal(executions, 1); + await settle(); +}); + +test('an immediate reload replaces a pending debounce timer', async t => { + t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 0 }); + let executions = 0; + const reloader = createCoalescedReloader(async () => { executions++; }, { onError: fail }); + reloader.schedule(); + t.mock.timers.tick(20); + reloader.schedule({ immediate: true }); + assert.equal(executions, 1); + await settle(); + t.mock.timers.tick(1000); + assert.equal(executions, 1); +}); + +test('events during a reload request one serialized follow-up', async () => { + const first = deferred(); + const second = deferred(); + let executions = 0; + const reloader = createCoalescedReloader(async () => { + executions++; + await (executions === 1 ? first.promise : second.promise); + }, { onError: fail }); + reloader.schedule({ immediate: true }); + reloader.schedule(); + reloader.schedule(); + reloader.schedule({ immediate: true }); + assert.equal(executions, 1); + first.resolve(); + await settle(); + assert.equal(executions, 2); + second.resolve(); + await settle(); + assert.equal(executions, 2); +}); + +test('reload failures surface and a queued follow-up still runs', async () => { + const first = deferred(); + const expected = new Error('Database connection failed'); + const errors: unknown[] = []; + let executions = 0; + const reloader = createCoalescedReloader(async () => { + executions++; + if (executions === 1) { + await first.promise; + throw expected; + } + }, { onError: error => { errors.push(error); } }); + reloader.schedule({ immediate: true }); + reloader.schedule(); + first.resolve(); + await settle(); + assert.deepEqual(errors, [expected]); + assert.equal(executions, 2); + reloader.schedule({ immediate: true }); + await settle(); + assert.equal(executions, 3); +}); + +test('cleanup stops timers and all later requests', t => { + t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 0 }); + let executions = 0; + const reloader = createCoalescedReloader(async () => { executions++; }, { onError: fail }); + reloader.schedule(); + reloader.cancel(); + assert.equal(reloader.isCancelled, true); + t.mock.timers.tick(1000); + reloader.schedule({ immediate: true }); + reloader.schedule(); + t.mock.timers.tick(1000); + assert.equal(executions, 0); +}); + +test('cleanup during a reload stops follow-ups and late error callbacks', async () => { + const first = deferred(); + let executions = 0; + const errors: unknown[] = []; + const reloader = createCoalescedReloader(async () => { + executions++; + await first.promise; + throw new Error('Late network error'); + }, { onError: error => { errors.push(error); } }); + reloader.schedule({ immediate: true }); + reloader.schedule(); + reloader.cancel(); + first.resolve(); + await settle(); + assert.equal(executions, 1); + assert.deepEqual(errors, []); +}); + +test('successive Duel effects do not share pending reloads', async t => { + t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 0 }); + let executionsA = 0; + let executionsB = 0; + const duelA = createCoalescedReloader(async () => { executionsA++; }, { onError: fail }); + const duelB = createCoalescedReloader(async () => { executionsB++; }, { onError: fail }); + duelA.schedule(); + duelB.schedule(); + duelA.cancel(); + t.mock.timers.tick(50); + await settle(); + assert.equal(executionsA, 0); + assert.equal(executionsB, 1); +}); diff --git a/src/lib/coalesced-reload.ts b/src/lib/coalesced-reload.ts new file mode 100644 index 0000000..e95cf8f --- /dev/null +++ b/src/lib/coalesced-reload.ts @@ -0,0 +1,71 @@ +interface CoalescedReloaderOptions { + onError: (error: unknown) => void; + debounceMs?: number; + maxWaitMs?: number; +} + +// Realtime bursts share one reload. Events during a reload request one follow-up. +export function createCoalescedReloader( + action: () => Promise, + options: CoalescedReloaderOptions, +) { + const debounceMs = options.debounceMs ?? 50; + const maxWaitMs = options.maxWaitMs ?? debounceMs * 3; + let cancelled = false; + let running = false; + let followUp = false; + let timer: ReturnType | undefined; + let burstStartedAt: number | undefined; + + function clearTimer() { + clearTimeout(timer); + timer = undefined; + burstStartedAt = undefined; + } + + async function execute() { + if (cancelled) return; + running = true; + try { + await action(); + } catch (error) { + if (!cancelled) options.onError(error); + } finally { + running = false; + if (followUp && !cancelled) { + followUp = false; + void execute(); + } + } + } + + return { + schedule({ immediate = false }: { immediate?: boolean } = {}) { + if (cancelled) return; + if (running) { + followUp = true; + return; + } + if (immediate) { + clearTimer(); + void execute(); + return; + } + + const now = Date.now(); + burstStartedAt ??= now; + clearTimeout(timer); + const delay = Math.min(debounceMs, Math.max(0, maxWaitMs - (now - burstStartedAt))); + timer = setTimeout(() => { + clearTimer(); + void execute(); + }, delay); + }, + cancel() { + cancelled = true; + followUp = false; + clearTimer(); + }, + get isCancelled() { return cancelled; }, + }; +} diff --git a/src/lib/duel-sync.test.ts b/src/lib/duel-sync.test.ts new file mode 100644 index 0000000..796c872 --- /dev/null +++ b/src/lib/duel-sync.test.ts @@ -0,0 +1,131 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { fetchDuelRows } from './duel-sync'; +import type { SupabaseClient } from '@supabase/supabase-js'; + +describe('Duel Rows Pagination and Count Elimination Safety', () => { + interface MockRow { + id: string; + duel_id: string; + data: { id: string; name: string }; + } + + function createMockSupabaseClient(options: { + totalRows: number; + backendPageCap?: number; + onQuery?: (params: { offset: number; pageSize: number; selectCountOption?: { count?: string } }) => void; + }): { client: SupabaseClient; getQueries: () => Array<{ offset: number; selectCountOption?: { count?: string } }> } { + const allData: MockRow[] = Array.from({ length: options.totalRows }, (_, i) => ({ + id: `row-${String(i).padStart(6, '0')}`, + duel_id: 'duel-test', + data: { id: `item-${i}`, name: `Item ${i}` }, + })); + + const queries: Array<{ offset: number; selectCountOption?: { count?: string } }> = []; + + const mockClient = { + from(table: string) { + assert.ok(table === 'duel_habits' || table === 'duel_check_ins'); + return { + select(columns: string, selectOptions?: { count?: 'exact' }) { + assert.equal(columns, 'data'); + return { + eq(column: string, value: string) { + assert.equal(column, 'duel_id'); + assert.equal(value, 'duel-test'); + return { + order(orderColumn: string) { + assert.equal(orderColumn, 'id'); + return { + range(start: number, end: number) { + queries.push({ offset: start, selectCountOption: selectOptions }); + options.onQuery?.({ offset: start, pageSize: end - start + 1, selectCountOption: selectOptions }); + + // Apply server-side max-rows cap if specified + const requestedCount = end - start + 1; + const effectiveLimit = options.backendPageCap + ? Math.min(requestedCount, options.backendPageCap) + : requestedCount; + + const pageRows = allData.slice(start, start + effectiveLimit); + return Promise.resolve({ + data: pageRows, + count: selectOptions?.count === 'exact' ? allData.length : null, + error: null, + }); + }, + }; + }, + }; + }, + }; + }, + }; + }, + } as unknown as SupabaseClient; + + return { client: mockClient, getQueries: () => queries }; + } + + it('pagination returns complete history for 0 rows', async () => { + const { client, getQueries } = createMockSupabaseClient({ totalRows: 0 }); + const rows = await fetchDuelRows<{ id: string; name: string }>(client, 'duel_habits', 'duel-test'); + + assert.deepStrictEqual(rows, []); + assert.strictEqual(getQueries().length, 1); + assert.strictEqual(getQueries()[0].selectCountOption?.count, 'exact'); + }); + + it('pagination returns complete history for exactly 1000 rows in single query with count', async () => { + const { client, getQueries } = createMockSupabaseClient({ totalRows: 1000 }); + const rows = await fetchDuelRows<{ id: string; name: string }>(client, 'duel_habits', 'duel-test'); + + assert.strictEqual(rows.length, 1000); + assert.strictEqual(rows[0].id, 'item-0'); + assert.strictEqual(rows[999].id, 'item-999'); + assert.strictEqual(getQueries().length, 1, 'Should complete in 1 page when count matches total rows'); + assert.strictEqual(getQueries()[0].selectCountOption?.count, 'exact'); + }); + + it('pagination returns complete history for >1000 rows without repeated count queries', async () => { + const total = 2500; + const { client, getQueries } = createMockSupabaseClient({ totalRows: total }); + const rows = await fetchDuelRows<{ id: string; name: string }>(client, 'duel_check_ins', 'duel-test'); + + assert.strictEqual(rows.length, 2500); + assert.strictEqual(rows[0].id, 'item-0'); + assert.strictEqual(rows[2499].id, 'item-2499'); + + const queries = getQueries(); + assert.strictEqual(queries.length, 3, '2500 rows should take 3 pages (1000, 1000, 500)'); + // First query requests count exact + assert.strictEqual(queries[0].selectCountOption?.count, 'exact', 'First query requests count exact'); + // Subsequent queries must NOT request count exact (safely eliminated!) + assert.strictEqual(queries[1].selectCountOption?.count, undefined, 'Second query eliminates count exact'); + assert.strictEqual(queries[2].selectCountOption?.count, undefined, 'Third query eliminates count exact'); + }); + + it('pagination handles PostgREST backend capping page below 1000 without dropping records', async () => { + // PostgREST max-rows configured to 400, but table has 1250 rows + const total = 1250; + const { client, getQueries } = createMockSupabaseClient({ + totalRows: total, + backendPageCap: 400, + }); + + const rows = await fetchDuelRows<{ id: string; name: string }>(client, 'duel_check_ins', 'duel-test'); + + // Must return all 1250 rows without dropping any records! + assert.strictEqual(rows.length, 1250, 'All 1250 records must be present'); + assert.strictEqual(rows[0].id, 'item-0'); + assert.strictEqual(rows[1249].id, 'item-1249'); + + const queries = getQueries(); + // 400 + 400 + 400 + 50 = 1250 (4 queries) + assert.strictEqual(queries.length, 4); + assert.strictEqual(queries[0].selectCountOption?.count, 'exact', 'Count requested on page 0'); + assert.strictEqual(queries[1].selectCountOption?.count, undefined, 'Subsequent pages omit count'); + assert.strictEqual(queries[2].selectCountOption?.count, undefined); + assert.strictEqual(queries[3].selectCountOption?.count, undefined); + }); +}); diff --git a/src/lib/duel-sync.ts b/src/lib/duel-sync.ts index 701986f..d28ec76 100644 --- a/src/lib/duel-sync.ts +++ b/src/lib/duel-sync.ts @@ -1,15 +1,24 @@ import type { SupabaseClient } from '@supabase/supabase-js'; import type { CheckIn, CouplesReaction, Habit, RestDay, Stake } from './types'; -async function fetchDuelRows(client: SupabaseClient, table: string, duelId: string): Promise { +export async function fetchDuelRows(client: SupabaseClient, table: string, duelId: string): Promise { const rows: T[] = []; const pageSize = 1000; + let totalCount: number | null = null; for (let offset = 0; ;) { - const { data, count, error } = await client.from(table).select('data', { count: 'exact' }).eq('duel_id', duelId) - .order('id').range(offset, offset + pageSize - 1); + const selectOptions: { count?: 'exact' } = totalCount === null ? { count: 'exact' } : {}; + const { data, count, error } = await client + .from(table) + .select('data', selectOptions) + .eq('duel_id', duelId) + .order('id') + .range(offset, offset + pageSize - 1); if (error) throw error; + if (totalCount === null && typeof count === 'number') { + totalCount = count; + } rows.push(...(data || []).map(row => row.data as T)); - if (!data?.length || (count !== null && rows.length >= count) || (count === null && data.length < pageSize)) return rows; + if (!data?.length || (totalCount !== null && rows.length >= totalCount) || (totalCount === null && data.length < pageSize)) return rows; offset += data.length; } } diff --git a/src/lib/store.tsx b/src/lib/store.tsx index 648059a..214cd70 100644 --- a/src/lib/store.tsx +++ b/src/lib/store.tsx @@ -41,6 +41,7 @@ import { soundEngine } from './sound-utils'; import { hapticCelebration, hapticLight, hapticMedium, hapticSuccess } from './haptic-utils'; import { getSupabaseClient } from './supabase'; import { useMultiplayer } from './multiplayer'; +import { createCoalescedReloader } from './coalesced-reload'; import { loadDuelData, saveDuelHabit, deleteDuelHabit, saveDuelCheckIn, deleteDuelCheckIn, saveDuelStake, deleteDuelStake, saveDuelReaction, saveDuelRestDay, deleteDuelRestDay } from './duel-sync'; import { habitToRow, @@ -460,87 +461,99 @@ export function StoreProvider({ children }: { children: React.ReactNode }) { setSyncStatus('local_only'); return; } - let cancelled = false; if (stateDuelRef.current !== duelId) { setLoadedDuelId(null); setSyncStatus('syncing'); } - const reload = async () => { + const reloader = createCoalescedReloader(async () => { const sequence = ++reloadSequenceRef.current; - try { - await duelQueueRef.current; - let data = await loadDuelData(client, duelId); - const restoreKey = `${duelId}:${multiplayer.slot}`; - if ( - multiplayer.slot && - canImportLegacyDatabase(multiplayer.slot, multiplayer.user?.email) && - needsLegacyReplacement(multiplayer.slot, data.habits) && - !restorationInProgressRef.current[restoreKey] - ) { - restorationInProgressRef.current[restoreKey] = true; - const slot = multiplayer.slot; - const restoration = duelQueueRef.current - .then(() => restoreLegacyDuelProgress(client, duelId, slot, data.habits, data.checkIns)) - .finally(() => { - delete restorationInProgressRef.current[restoreKey]; - }); - duelQueueRef.current = restoration.then(() => {}, () => {}); - if (await restoration) data = await loadDuelData(client, duelId); - } - if (cancelled) return; - if (sequence < latestAppliedSequenceRef.current) return; - latestAppliedSequenceRef.current = sequence; - const initial = getInitialState(); - const sameDuel = stateDuelRef.current === duelId && stateSlotRef.current === multiplayer.slot; - stateDuelRef.current = duelId; - stateSlotRef.current = multiplayer.slot; - const partnerPaired = multiplayer.hasPairedPartner; - const visibleHabits = partnerPaired ? data.habits : data.habits.filter(h => h.playerId === multiplayer.slot); - const visibleCheckIns = partnerPaired ? data.checkIns : data.checkIns.filter(c => c.playerId === multiplayer.slot); - const visibleReactions = partnerPaired ? data.reactions : data.reactions.filter(r => r.fromPlayerId === multiplayer.slot && r.toPlayerId === multiplayer.slot); - const visibleRestDays = partnerPaired ? data.restDays : data.restDays.filter(r => r.playerId === multiplayer.slot); - const ownerColor = PLAYER_COLORS.find(color => color.id === duelOwnerColor) || PLAYER_COLORS[0]; - const guestColor = PLAYER_COLORS.find(color => color.id === duelGuestColor) || PLAYER_COLORS[1]; - setState(prev => ({ - ...(sameDuel ? prev : initial), - activePlayerId: multiplayer.slot, - habits: visibleHabits, - checkIns: visibleCheckIns, - stakes: data.stakes, - reactions: visibleReactions, - restDays: visibleRestDays, - players: { - maciek: { ...initial.players.maciek, name: duelOwnerName || initial.players.maciek.name, color: ownerColor.color, accentBg: ownerColor.accentBg, accentBorder: ownerColor.accentBorder, inviteCount: multiplayer.duel?.owner_invites ?? 0 }, - myrna: { ...initial.players.myrna, name: partnerPaired ? (duelGuestName || 'Invited player') : 'Waiting for opponent', color: guestColor.color, accentBg: guestColor.accentBg, accentBorder: guestColor.accentBorder, inviteCount: multiplayer.duel?.guest_invites ?? 0 }, - }, - })); - setLoadedDuelId(duelId); - setSyncStatus('connected'); - setStorageError(null); - } catch (error) { - if (!cancelled) { - setSyncStatus('offline'); - setStorageError(error instanceof Error ? error.message : 'Could not load duel data.'); - } + let pendingWrites = duelQueueRef.current; + await pendingWrites; + if (reloader.isCancelled) return; + let data = await loadDuelData(client, duelId); + if (reloader.isCancelled) return; + // A read started before a local mutation must not replace its optimistic state. + if (pendingWrites !== duelQueueRef.current) { + reloader.schedule(); + return; } - }; - void reload(); + const restoreKey = `${duelId}:${multiplayer.slot}`; + if ( + multiplayer.slot && + canImportLegacyDatabase(multiplayer.slot, multiplayer.user?.email) && + needsLegacyReplacement(multiplayer.slot, data.habits) && + !restorationInProgressRef.current[restoreKey] + ) { + restorationInProgressRef.current[restoreKey] = true; + const slot = multiplayer.slot; + const restoration = duelQueueRef.current + .then(() => restoreLegacyDuelProgress(client, duelId, slot, data.habits, data.checkIns)) + .finally(() => { + delete restorationInProgressRef.current[restoreKey]; + }); + duelQueueRef.current = restoration.then(() => {}, () => {}); + pendingWrites = duelQueueRef.current; + if (await restoration) data = await loadDuelData(client, duelId); + } + if (reloader.isCancelled) return; + if (pendingWrites !== duelQueueRef.current) { + reloader.schedule(); + return; + } + if (sequence < latestAppliedSequenceRef.current) return; + latestAppliedSequenceRef.current = sequence; + const initial = getInitialState(); + const sameDuel = stateDuelRef.current === duelId && stateSlotRef.current === multiplayer.slot; + stateDuelRef.current = duelId; + stateSlotRef.current = multiplayer.slot; + const partnerPaired = multiplayer.hasPairedPartner; + const visibleHabits = partnerPaired ? data.habits : data.habits.filter(h => h.playerId === multiplayer.slot); + const visibleCheckIns = partnerPaired ? data.checkIns : data.checkIns.filter(c => c.playerId === multiplayer.slot); + const visibleReactions = partnerPaired ? data.reactions : data.reactions.filter(r => r.fromPlayerId === multiplayer.slot && r.toPlayerId === multiplayer.slot); + const visibleRestDays = partnerPaired ? data.restDays : data.restDays.filter(r => r.playerId === multiplayer.slot); + const ownerColor = PLAYER_COLORS.find(color => color.id === duelOwnerColor) || PLAYER_COLORS[0]; + const guestColor = PLAYER_COLORS.find(color => color.id === duelGuestColor) || PLAYER_COLORS[1]; + setState(prev => ({ + ...(sameDuel ? prev : initial), + activePlayerId: multiplayer.slot, + habits: visibleHabits, + checkIns: visibleCheckIns, + stakes: data.stakes, + reactions: visibleReactions, + restDays: visibleRestDays, + players: { + maciek: { ...initial.players.maciek, name: duelOwnerName || initial.players.maciek.name, color: ownerColor.color, accentBg: ownerColor.accentBg, accentBorder: ownerColor.accentBorder, inviteCount: multiplayer.duel?.owner_invites ?? 0 }, + myrna: { ...initial.players.myrna, name: partnerPaired ? (duelGuestName || 'Invited player') : 'Waiting for opponent', color: guestColor.color, accentBg: guestColor.accentBg, accentBorder: guestColor.accentBorder, inviteCount: multiplayer.duel?.guest_invites ?? 0 }, + }, + })); + setLoadedDuelId(duelId); + setSyncStatus('connected'); + setStorageError(null); + }, { + debounceMs: 50, + onError: (error) => { + setSyncStatus('offline'); + setStorageError(error instanceof Error ? error.message : 'Could not load duel data.'); + }, + }); + + reloader.schedule({ immediate: true }); let isSubscribed = false; const channel = client.channel(`duel-${duelId}`) - .on('postgres_changes', { event: '*', schema: 'public', table: 'duel_habits', filter: `duel_id=eq.${duelId}` }, () => void reload()) - .on('postgres_changes', { event: '*', schema: 'public', table: 'duel_check_ins', filter: `duel_id=eq.${duelId}` }, () => void reload()) - .on('postgres_changes', { event: '*', schema: 'public', table: 'duel_stakes', filter: `duel_id=eq.${duelId}` }, () => void reload()) - .on('postgres_changes', { event: '*', schema: 'public', table: 'duel_reactions', filter: `duel_id=eq.${duelId}` }, () => void reload()) - .on('postgres_changes', { event: '*', schema: 'public', table: 'duel_rest_days', filter: `duel_id=eq.${duelId}` }, () => void reload()) + .on('postgres_changes', { event: '*', schema: 'public', table: 'duel_habits', filter: `duel_id=eq.${duelId}` }, () => reloader.schedule()) + .on('postgres_changes', { event: '*', schema: 'public', table: 'duel_check_ins', filter: `duel_id=eq.${duelId}` }, () => reloader.schedule()) + .on('postgres_changes', { event: '*', schema: 'public', table: 'duel_stakes', filter: `duel_id=eq.${duelId}` }, () => reloader.schedule()) + .on('postgres_changes', { event: '*', schema: 'public', table: 'duel_reactions', filter: `duel_id=eq.${duelId}` }, () => reloader.schedule()) + .on('postgres_changes', { event: '*', schema: 'public', table: 'duel_rest_days', filter: `duel_id=eq.${duelId}` }, () => reloader.schedule()) .subscribe((status) => { isSubscribed = status === 'SUBSCRIBED'; }); const poll = setInterval(() => { if (!isSubscribed) { - void reload(); + reloader.schedule({ immediate: true }); } }, 30000); - return () => { cancelled = true; clearInterval(poll); void client.removeChannel(channel); }; + return () => { reloader.cancel(); clearInterval(poll); void client.removeChannel(channel); }; }, [isHydrated, multiplayer.configured, duelId, multiplayer.slot, multiplayer.hasPairedPartner, multiplayer.user?.email, duelOwnerName, duelGuestName, duelOwnerColor, duelGuestColor]); const syncDuel = (operation: (client: NonNullable>, id: string) => Promise) => { @@ -1269,57 +1282,6 @@ export function StoreProvider({ children }: { children: React.ReactNode }) { ); - // Automatically pull Apple Health check-ins queued by background iOS Shortcuts - useEffect(() => { - if (!isHydrated || multiplayer.configured) return; - - const pullAppleHealthPending = async () => { - try { - const res = await fetch('/api/sync/apple-health?pending=true&consume=true'); - if (!res.ok) return; - const data = await res.json(); - if (data.pendingCheckIns && Array.isArray(data.pendingCheckIns) && data.pendingCheckIns.length > 0) { - setState((prev) => { - let nextCheckIns = [...prev.checkIns]; - for (const incoming of data.pendingCheckIns) { - if (!incoming || !incoming.habitId) continue; - const idx = nextCheckIns.findIndex( - (c) => c.habitId === incoming.habitId && c.date === incoming.date - ); - if (idx >= 0) { - nextCheckIns[idx] = incoming; - } else { - nextCheckIns.push(incoming); - } - } - nextCheckIns = rebalanceAllWeeklyCheckIns(nextCheckIns, prev.habits); - return { - ...prev, - checkIns: nextCheckIns, - wearableConfig: { - ...prev.wearableConfig, - appleConnected: true, - appleLastSync: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), - }, - }; - }); - soundEngine.playFanfare(); - hapticSuccess(); - } - } catch { - // Silent failure in offline/local - } - }; - - pullAppleHealthPending(); - const handleFocus = () => { - pullAppleHealthPending(); - }; - window.addEventListener('focus', handleFocus); - return () => window.removeEventListener('focus', handleFocus); - }, [isHydrated, multiplayer.configured]); - - const disconnectGoogleHealth = useStableCallback(() => { if (typeof document !== 'undefined') { document.cookie = 'g_fit_connected=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;'; document.cookie = 'g_fit_access_token=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;'; From f8d783729b2eace15d656892526c339ba3ca451e Mon Sep 17 00:00:00 2001 From: Maseeek Date: Mon, 12 Oct 2026 03:13:04 +0100 Subject: [PATCH 02/10] perf(views): remove deferred-view fallback delays Load optional views through stable external-store snapshots so ready chunks render without the measured Suspense fallback delay. Preserve deployment checks on actual activation, failed-preload retries, and server rendering without importing inactive views. Warm tab and Settings imports on pointer entry or keyboard focus. Keep proof and shortcut dialogs deferred with their existing loaded behavior. Refs #63. --- src/app/page.tsx | 20 ++++-- src/components/BottomNav.tsx | 5 +- src/components/DeferredView.tsx | 20 ++++++ src/components/DesktopSidebar.tsx | 8 ++- src/components/HabitCard.tsx | 7 +- src/components/Header.tsx | 4 +- src/components/TodayView.tsx | 7 +- src/lib/deferred-resource.test.ts | 103 ++++++++++++++++++++++++++++++ src/lib/deferred-resource.ts | 48 ++++++++++++++ src/lib/load-current-page.test.ts | 77 +++++++++++++++++++++- 10 files changed, 280 insertions(+), 19 deletions(-) create mode 100644 src/components/DeferredView.tsx create mode 100644 src/lib/deferred-resource.test.ts create mode 100644 src/lib/deferred-resource.ts diff --git a/src/app/page.tsx b/src/app/page.tsx index d6d828f..a574015 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -2,6 +2,7 @@ import React, { Suspense, useCallback, useEffect, useRef, useState } from 'react'; import dynamic from 'next/dynamic'; +import { createDeferredView } from '@/components/DeferredView'; import { loadCurrentPage } from '@/lib/load-current-page'; import { useSearchParams } from 'next/navigation'; import { useStore } from '@/lib/store'; @@ -25,13 +26,18 @@ function ViewLoading() { return

Loading...

; } -const DuelView = dynamic(() => loadCurrentPage(() => import('@/components/DuelView')).then(module => module.DuelView), { loading: ViewLoading }); -const ProgressView = dynamic(() => loadCurrentPage(() => import('@/components/ProgressView')).then(module => module.ProgressView), { loading: ViewLoading }); -const SettingsView = dynamic(() => loadCurrentPage(() => import('@/components/SettingsView')).then(module => module.SettingsView), { loading: ViewLoading }); +const { View: DuelView, preload: preloadDuel } = createDeferredView(() => import('@/components/DuelView').then(module => module.DuelView)); +const { View: ProgressView, preload: preloadProgress } = createDeferredView(() => import('@/components/ProgressView').then(module => module.ProgressView)); +const { View: SettingsView, preload: preloadSettings } = createDeferredView(() => import('@/components/SettingsView').then(module => module.SettingsView)); + +function preloadTab(tab: TabType) { + if (tab === 'duel') preloadDuel(); + if (tab === 'progress') preloadProgress(); +} const MultiplayerGate = dynamic(() => loadCurrentPage(() => import('@/components/MultiplayerGate')).then(module => module.MultiplayerGate), { loading: ViewLoading }); const ExistingDuelInviteGate = dynamic(() => loadCurrentPage(() => import('@/components/ExistingDuelInviteGate')).then(module => module.ExistingDuelInviteGate), { loading: ViewLoading }); const HabitOnboarding = dynamic(() => loadCurrentPage(() => import('@/components/HabitOnboarding')).then(module => module.HabitOnboarding), { loading: ViewLoading }); -const KeyboardShortcutsModal = dynamic(() => loadCurrentPage(() => import('@/components/KeyboardShortcutsModal')).then(module => module.KeyboardShortcutsModal), { loading: ViewLoading }); +const { View: KeyboardShortcutsModal } = createDeferredView(() => import('@/components/KeyboardShortcutsModal').then(module => module.KeyboardShortcutsModal), null); function AppContent() { const { @@ -245,8 +251,8 @@ function AppContent() { {/* App frame */}
Skip to content - { setShowSettings(false); changeTab(tab); }} /> -
setShowSettings(true)} /> + { setShowSettings(false); changeTab(tab); }} /> +
setShowSettings(true)} onWarmSettings={preloadSettings} /> {/* Floating Quick Action Toast */} {toastMessage && (
} - { setShowSettings(false); setOpenPlanner(false); changeTab(tab); }} /> + { setShowSettings(false); setOpenPlanner(false); changeTab(tab); }} /> {isShortcutsModalOpen && void; + onWarmTab?: (tab: TabType) => void; } -export function BottomNav({ activeTab, onChangeTab }: BottomNavProps) { +export function BottomNav({ activeTab, onChangeTab, onWarmTab }: BottomNavProps) { const tabs = [ { id: 'today' as TabType, label: 'Today', icon: CheckCircle2 }, { id: 'duel' as TabType, label: 'Duel', icon: Flame }, @@ -42,6 +43,8 @@ export function BottomNav({ activeTab, onChangeTab }: BottomNavProps) {