mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 14:21:46 +00:00
fix: resolve splash hang, dashboard skeleton, and sync-stuck regressions
- TripPlannerPage: change splash effect dep from `trip` (object ref) to `trip?.id` (primitive) — background refreshes no longer reset the 1500 ms timer on every new object reference, fixing the forever-splash on SPA nav - tripRepo.list: await upserts on the cold-IDB path so the next mount reads from Dexie instead of hitting the network again, fixing the remount skeleton - tripSyncManager: add stale-flag detection (>2 min resets _syncing), 90 s hard timeout via Promise.race, parallel post-sync prefetch via Promise.allSettled, and updated header comment to reflect manual-only policy - OfflineTab: guard handleResync with a 120 s client-side timeout that interrupts and clears the spinner if syncAll stalls
This commit is contained in:
@@ -123,7 +123,12 @@ export default function OfflineTab(): React.ReactElement {
|
||||
async function handleResync() {
|
||||
setSyncing(true)
|
||||
try {
|
||||
await tripSyncManager.syncAll()
|
||||
const timeout = new Promise<'timeout'>(resolve => setTimeout(() => resolve('timeout'), 120_000))
|
||||
const result = await Promise.race([tripSyncManager.syncAll().then(() => 'done' as const), timeout])
|
||||
if (result === 'timeout') {
|
||||
tripSyncManager.interrupt()
|
||||
console.warn('[OfflineTab] sync timed out after 120 s')
|
||||
}
|
||||
await load()
|
||||
} finally {
|
||||
setSyncing(false)
|
||||
|
||||
@@ -735,7 +735,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
const timer = setTimeout(() => setSplashDone(true), 1500)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [isLoading, trip])
|
||||
}, [isLoading, trip?.id])
|
||||
// Show escape hatch after 12 seconds on splash (covers slow first-load scenarios)
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setSlowLoad(true), 12000)
|
||||
|
||||
@@ -36,7 +36,11 @@ export const tripRepo = {
|
||||
|
||||
const fresh = await refresh
|
||||
if (!fresh) return { trips: [], archivedTrips: [], refresh: Promise.resolve(null) }
|
||||
// Data came straight from network — no background re-fetch needed
|
||||
// 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) }
|
||||
},
|
||||
|
||||
|
||||
@@ -5,10 +5,8 @@
|
||||
* Eviction: trips where end_date < today - 7 days.
|
||||
* File blobs: all non-photo files (MIME type != image/*) for cached trips.
|
||||
*
|
||||
* Call syncAll() on:
|
||||
* - login success
|
||||
* - trip list refresh (DashboardPage)
|
||||
* - WS reconnect (phase 7)
|
||||
* syncAll() is manual-only — triggered via Settings → Offline tab.
|
||||
* No automatic sync on login, dashboard load, or WS reconnect.
|
||||
*/
|
||||
import { tripsApi, tagsApi, categoriesApi } from '../api/client'
|
||||
import {
|
||||
@@ -135,81 +133,104 @@ async function cacheFilesForTrip(files: TripFile[]): Promise<void> {
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
const SYNC_TIMEOUT_MS = 90_000
|
||||
const SYNC_STALE_MS = 120_000
|
||||
|
||||
let _syncing = false
|
||||
let _interrupted = false
|
||||
let _syncStartedAt = 0
|
||||
|
||||
export const tripSyncManager = {
|
||||
/**
|
||||
* Sync all cache-eligible trips.
|
||||
* Evicts stale trips. Caches file blobs in the background.
|
||||
* No-ops when offline.
|
||||
* No-ops when offline or already syncing (unless stale flag).
|
||||
*/
|
||||
async syncAll(): Promise<void> {
|
||||
if (_syncing || !navigator.onLine) return
|
||||
// Treat a _syncing flag that's been set for >2 minutes as stale (e.g. page unload mid-sync)
|
||||
if (_syncing && Date.now() - _syncStartedAt < SYNC_STALE_MS) return
|
||||
if (!navigator.onLine) return
|
||||
_syncing = true
|
||||
_syncStartedAt = Date.now()
|
||||
_interrupted = false
|
||||
|
||||
const timeout = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('syncAll timeout')), SYNC_TIMEOUT_MS)
|
||||
)
|
||||
|
||||
try {
|
||||
const { trips } = await tripsApi.list() as { trips: Trip[] }
|
||||
|
||||
// Evict stale trips first
|
||||
const stale = trips.filter(isStale)
|
||||
await Promise.all(stale.map(t => clearTripData(t.id).catch(console.error)))
|
||||
|
||||
// Sync eligible trips — stop early if interrupted (e.g. user navigated to a trip page)
|
||||
const toSync = trips.filter(shouldCache)
|
||||
for (const trip of toSync) {
|
||||
if (_interrupted) break
|
||||
try {
|
||||
await syncTrip(trip.id)
|
||||
} catch (err) {
|
||||
if (isQuotaError(err)) {
|
||||
console.warn(`[tripSync] quota exceeded for trip ${trip.id}, clearing trip data and retrying`)
|
||||
try {
|
||||
await clearTripData(trip.id)
|
||||
await syncTrip(trip.id)
|
||||
} catch (retryErr) {
|
||||
if (isQuotaError(retryErr)) {
|
||||
// Trip data + blob cache — free largest storage first before nuking everything
|
||||
console.warn('[tripSync] quota still exceeded — clearing blob cache and retrying')
|
||||
await clearBlobCache()
|
||||
try {
|
||||
await syncTrip(trip.id)
|
||||
} catch {
|
||||
console.warn('[tripSync] quota still exceeded after blob eviction — clearing all IDB data')
|
||||
await clearAll()
|
||||
return
|
||||
}
|
||||
} else {
|
||||
console.error(`[tripSync] failed for trip ${trip.id} after eviction:`, retryErr)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error(`[tripSync] failed for trip ${trip.id}:`, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (_interrupted) return
|
||||
|
||||
// Cache global user data (tags + categories) — fire-and-forget
|
||||
tagsApi.list().then(d => upsertTags(d.tags)).catch(() => {})
|
||||
categoriesApi.list().then(d => upsertCategories(d.categories)).catch(() => {})
|
||||
|
||||
// Cache file blobs + map tiles in background (don't block syncAll)
|
||||
const tileUrl = useSettingsStore.getState().settings.map_tile_url || undefined
|
||||
for (const trip of toSync) {
|
||||
if (_interrupted) break
|
||||
const files = await offlineDb.tripFiles.where('trip_id').equals(trip.id).toArray()
|
||||
cacheFilesForTrip(files).catch(console.error)
|
||||
|
||||
const places = await offlineDb.places.where('trip_id').equals(trip.id).toArray()
|
||||
prefetchTilesForTrip(trip.id, places, tileUrl).catch(console.error)
|
||||
await Promise.race([this._doSync(), timeout])
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message === 'syncAll timeout') {
|
||||
console.warn('[tripSync] syncAll timed out after 90 s — interrupting')
|
||||
_interrupted = true
|
||||
}
|
||||
} finally {
|
||||
_syncing = false
|
||||
}
|
||||
},
|
||||
|
||||
async _doSync(): Promise<void> {
|
||||
const { trips } = await tripsApi.list() as { trips: Trip[] }
|
||||
|
||||
// Evict stale trips first
|
||||
const stale = trips.filter(isStale)
|
||||
await Promise.all(stale.map(t => clearTripData(t.id).catch(console.error)))
|
||||
|
||||
// Sync eligible trips — stop early if interrupted (e.g. user navigated to a trip page)
|
||||
const toSync = trips.filter(shouldCache)
|
||||
for (const trip of toSync) {
|
||||
if (_interrupted) return
|
||||
try {
|
||||
await syncTrip(trip.id)
|
||||
} catch (err) {
|
||||
if (isQuotaError(err)) {
|
||||
console.warn(`[tripSync] quota exceeded for trip ${trip.id}, clearing trip data and retrying`)
|
||||
try {
|
||||
await clearTripData(trip.id)
|
||||
await syncTrip(trip.id)
|
||||
} catch (retryErr) {
|
||||
if (isQuotaError(retryErr)) {
|
||||
console.warn('[tripSync] quota still exceeded — clearing blob cache and retrying')
|
||||
await clearBlobCache()
|
||||
try {
|
||||
await syncTrip(trip.id)
|
||||
} catch {
|
||||
console.warn('[tripSync] quota still exceeded after blob eviction — clearing all IDB data')
|
||||
await clearAll()
|
||||
return
|
||||
}
|
||||
} else {
|
||||
console.error(`[tripSync] failed for trip ${trip.id} after eviction:`, retryErr)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error(`[tripSync] failed for trip ${trip.id}:`, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (_interrupted) return
|
||||
|
||||
// Cache global user data (tags + categories) — fire-and-forget
|
||||
tagsApi.list().then(d => upsertTags(d.tags)).catch(() => {})
|
||||
categoriesApi.list().then(d => upsertCategories(d.categories)).catch(() => {})
|
||||
|
||||
// Cache file blobs + map tiles for all synced trips in parallel (fire-and-forget)
|
||||
const tileUrl = useSettingsStore.getState().settings.map_tile_url || undefined
|
||||
const prefetchWork = toSync
|
||||
.filter(() => !_interrupted)
|
||||
.map(async trip => {
|
||||
const [files, places] = await Promise.all([
|
||||
offlineDb.tripFiles.where('trip_id').equals(trip.id).toArray(),
|
||||
offlineDb.places.where('trip_id').equals(trip.id).toArray(),
|
||||
])
|
||||
cacheFilesForTrip(files).catch(console.error)
|
||||
prefetchTilesForTrip(trip.id, places, tileUrl).catch(console.error)
|
||||
})
|
||||
await Promise.allSettled(prefetchWork)
|
||||
},
|
||||
|
||||
/**
|
||||
* Signal syncAll to stop after the current in-flight bundle request.
|
||||
* Call when the user navigates to a trip page so loadTrip gets priority.
|
||||
@@ -222,5 +243,6 @@ export const tripSyncManager = {
|
||||
_resetSyncing(): void {
|
||||
_syncing = false
|
||||
_interrupted = false
|
||||
_syncStartedAt = 0
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user