From b71ce3dd5eeebdc45e979663bcc95a72398575c2 Mon Sep 17 00:00:00 2001 From: jubnl Date: Tue, 5 May 2026 21:49:07 +0200 Subject: [PATCH] fix: prevent cold-path hang when Dexie write transactions stall after external IDB clear When DevTools "Clear site data" deletes the IDB while the tab is open, Dexie receives a versionchange event and closes its connection. On reopen, read transactions work (toArray completes after ~400ms), but write transactions can stall indefinitely, causing the cold-path 'await refresh' to never resolve. Two changes: - Make upsertTrip calls fire-and-forget in the IIFE so network data is returned immediately without blocking on potentially-stuck IDB writes. - Add a 2-second timeout to the initial offlineDb.trips.toArray() call so that if the read also stalls, the cold path falls through to the network fetch. - Reduce the outer dashboard timeout from 12s to 5s now that the inner path cannot stall for more than ~2s + network RTT. --- client/src/pages/DashboardPage.tsx | 6 +++++- client/src/repo/tripRepo.ts | 14 ++++++++------ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/client/src/pages/DashboardPage.tsx b/client/src/pages/DashboardPage.tsx index dfffdcc0..814ff15f 100644 --- a/client/src/pages/DashboardPage.tsx +++ b/client/src/pages/DashboardPage.tsx @@ -744,7 +744,11 @@ export default function DashboardPage(): React.ReactElement { const loadTrips = async () => { setIsLoading(true) try { - const { trips, archivedTrips, refresh } = await tripRepo.list() + const listOrTimeout = Promise.race([ + tripRepo.list(), + new Promise((_, reject) => setTimeout(() => reject(new Error('trips-load-timeout')), 5_000)), + ]) + const { trips, archivedTrips, refresh } = await listOrTimeout setTrips(sortTrips(trips)) setArchivedTrips(sortTrips(archivedTrips)) setIsLoading(false) diff --git a/client/src/repo/tripRepo.ts b/client/src/repo/tripRepo.ts index 06bc9e5e..28d0918e 100644 --- a/client/src/repo/tripRepo.ts +++ b/client/src/repo/tripRepo.ts @@ -8,7 +8,12 @@ type TripRefresh = Promise<{ trip: Trip } | null> export const tripRepo = { async list(): Promise<{ trips: Trip[]; archivedTrips: Trip[]; refresh: TripsRefresh }> { - const all = await offlineDb.trips.toArray() + // 2-second guard: if Dexie is in a bad state (e.g. externally deleted while tab + // was open), toArray() may hang. Fall back to the cold/network path. + const all = await Promise.race([ + offlineDb.trips.toArray(), + new Promise(resolve => setTimeout(() => resolve([]), 2000)), + ]) const refresh: TripsRefresh = (async () => { try { @@ -16,6 +21,8 @@ export const tripRepo = { tripsApi.list(), tripsApi.list({ archived: 1 }), ]) + // Fire-and-forget IDB writes: returning data immediately unblocks the cold + // path even when Dexie write transactions stall after an external DB clear. Promise.all([ ...active.trips.map(t => upsertTrip(t)), ...archived.trips.map(t => upsertTrip(t)), @@ -36,11 +43,6 @@ export const tripRepo = { const fresh = await refresh if (!fresh) return { trips: [], archivedTrips: [], refresh: Promise.resolve(null) } - // Await upserts on cold path so next mount reads from IDB instead of hitting network again - await Promise.all([ - ...fresh.trips.map(t => upsertTrip(t)), - ...fresh.archivedTrips.map(t => upsertTrip(t)), - ]).catch(() => {}) return { ...fresh, refresh: Promise.resolve(null) } },