fix: prevent splash-forever on slow first-load after clearing storage

Three changes:
- tripSyncManager: add interrupt() so trip page load can stop competing
  background bundle sync requests; also try clearing blobCache before
  falling back to full clearAll() on QuotaExceededError
- TripPlannerPage: call tripSyncManager.interrupt() when mounting so
  loadTrip gets network priority over background syncAll
- TripPlannerPage: show a 'go back to dashboard' link after 12 seconds
  on the splash screen so users are never stuck with no escape
This commit is contained in:
jubnl
2026-05-05 20:05:10 +02:00
parent 83cba5a9ef
commit 443ae7cb19
18 changed files with 70 additions and 5 deletions
+5
View File
@@ -185,6 +185,11 @@ export async function clearTripData(tripId: number): Promise<void> {
await offlineDb.trips.delete(tripId);
}
/** Clear cached file blobs only — frees significant quota without losing trip data. */
export async function clearBlobCache(): Promise<void> {
await offlineDb.blobCache.clear();
}
/** Wipe the entire offline database (called on logout). */
export async function clearAll(): Promise<void> {
await offlineDb.delete();
+1
View File
@@ -925,6 +925,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'trip.tabs.budget': 'الميزانية',
'trip.tabs.files': 'الملفات',
'trip.loading': 'جارٍ تحميل الرحلة...',
'trip.splash.goBack': 'Taking too long? Go back to dashboard',
'trip.loadingPhotos': 'جارٍ تحميل صور الأماكن...',
'trip.mobilePlan': 'الخطة',
'trip.mobilePlaces': 'الأماكن',
+1
View File
@@ -909,6 +909,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'trip.confirm.deletePlace': 'Tem certeza de que deseja excluir este lugar?',
'trip.confirm.deletePlaces': 'Excluir {count} lugares?',
'trip.toast.placesDeleted': '{count} lugares excluídos',
'trip.splash.goBack': 'Taking too long? Go back to dashboard',
'trip.loadingPhotos': 'Carregando fotos dos lugares...',
// Day Plan Sidebar
+1
View File
@@ -923,6 +923,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'trip.tabs.budget': 'Rozpočet',
'trip.tabs.files': 'Soubory',
'trip.loading': 'Načítání cesty...',
'trip.splash.goBack': 'Taking too long? Go back to dashboard',
'trip.loadingPhotos': 'Načítání fotek míst...',
'trip.mobilePlan': 'Plán',
'trip.mobilePlaces': 'Místa',
+1
View File
@@ -928,6 +928,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'trip.tabs.budget': 'Budget',
'trip.tabs.files': 'Dateien',
'trip.loading': 'Reise wird geladen...',
'trip.splash.goBack': 'Taking too long? Go back to dashboard',
'trip.loadingPhotos': 'Fotos der Orte werden geladen...',
'trip.mobilePlan': 'Planung',
'trip.mobilePlaces': 'Orte',
+1
View File
@@ -1000,6 +1000,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'trip.tabs.files': 'Files',
'trip.loading': 'Loading trip...',
'trip.loadingPhotos': 'Loading place photos...',
'trip.splash.goBack': 'Taking too long? Go back to dashboard',
'trip.mobilePlan': 'Plan',
'trip.mobilePlaces': 'Places',
'trip.toast.placeUpdated': 'Place updated',
+1
View File
@@ -898,6 +898,7 @@ const es: Record<string, string> = {
'trip.tabs.budget': 'Presupuesto',
'trip.tabs.files': 'Archivos',
'trip.loading': 'Cargando viaje...',
'trip.splash.goBack': 'Taking too long? Go back to dashboard',
'trip.loadingPhotos': 'Cargando fotos de los lugares...',
'trip.mobilePlan': 'Plan',
'trip.mobilePlaces': 'Lugares',
+1
View File
@@ -922,6 +922,7 @@ const fr: Record<string, string> = {
'trip.tabs.budget': 'Budget',
'trip.tabs.files': 'Fichiers',
'trip.loading': 'Chargement du voyage…',
'trip.splash.goBack': 'Taking too long? Go back to dashboard',
'trip.loadingPhotos': 'Chargement des photos des lieux...',
'trip.mobilePlan': 'Plan',
'trip.mobilePlaces': 'Lieux',
+1
View File
@@ -937,6 +937,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'trip.confirm.deletePlace': 'Biztosan törölni szeretnéd ezt a helyet?',
'trip.confirm.deletePlaces': '{count} helyet töröl?',
'trip.toast.placesDeleted': '{count} hely törölve',
'trip.splash.goBack': 'Taking too long? Go back to dashboard',
'trip.loadingPhotos': 'Helyek fotóinak betöltése...',
// Napi terv oldalsáv
+1
View File
@@ -983,6 +983,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'trip.tabs.budget': 'Anggaran',
'trip.tabs.files': 'File',
'trip.loading': 'Memuat perjalanan...',
'trip.splash.goBack': 'Taking too long? Go back to dashboard',
'trip.loadingPhotos': 'Memuat foto tempat...',
'trip.mobilePlan': 'Rencana',
'trip.mobilePlaces': 'Tempat',
+1
View File
@@ -937,6 +937,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'trip.confirm.deletePlace': 'Sei sicuro di voler eliminare questo luogo?',
'trip.confirm.deletePlaces': 'Eliminare {count} luoghi?',
'trip.toast.placesDeleted': '{count} luoghi eliminati',
'trip.splash.goBack': 'Taking too long? Go back to dashboard',
'trip.loadingPhotos': 'Caricamento foto dei luoghi...',
// Day Plan Sidebar
+1
View File
@@ -922,6 +922,7 @@ const nl: Record<string, string> = {
'trip.tabs.budget': 'Budget',
'trip.tabs.files': 'Bestanden',
'trip.loading': 'Reis laden...',
'trip.splash.goBack': 'Taking too long? Go back to dashboard',
'trip.loadingPhotos': 'Plaatsfoto laden...',
'trip.mobilePlan': 'Plan',
'trip.mobilePlaces': 'Plaatsen',
+1
View File
@@ -1764,6 +1764,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'login.setNewPassword': 'Ustaw nowe hasło',
'login.setNewPasswordHint': 'Musisz zmienić hasło.',
'atlas.searchCountry': 'Szukaj kraju...',
'trip.splash.goBack': 'Taking too long? Go back to dashboard',
'trip.loadingPhotos': 'Ładowanie zdjęć...',
'places.importNaverList': 'Lista Naver',
'places.importList': 'Import listy',
+1
View File
@@ -922,6 +922,7 @@ const ru: Record<string, string> = {
'trip.tabs.budget': 'Бюджет',
'trip.tabs.files': 'Файлы',
'trip.loading': 'Загрузка поездки...',
'trip.splash.goBack': 'Taking too long? Go back to dashboard',
'trip.loadingPhotos': 'Загрузка фото мест...',
'trip.mobilePlan': 'План',
'trip.mobilePlaces': 'Места',
+1
View File
@@ -922,6 +922,7 @@ const zh: Record<string, string> = {
'trip.tabs.budget': '预算',
'trip.tabs.files': '文件',
'trip.loading': '加载旅行中...',
'trip.splash.goBack': 'Taking too long? Go back to dashboard',
'trip.loadingPhotos': '正在加载地点照片...',
'trip.mobilePlan': '计划',
'trip.mobilePlaces': '地点',
+1
View File
@@ -982,6 +982,7 @@ const zhTw: Record<string, string> = {
'trip.tabs.budget': '預算',
'trip.tabs.files': '檔案',
'trip.loading': '載入旅行中...',
'trip.splash.goBack': 'Taking too long? Go back to dashboard',
'trip.loadingPhotos': '正在載入地點照片...',
'trip.mobilePlan': '計劃',
'trip.mobilePlaces': '地點',
+21
View File
@@ -31,6 +31,7 @@ import { useTranslation } from '../i18n'
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, mapsApi } from '../api/client'
import { accommodationRepo } from '../repo/accommodationRepo'
import { offlineDb } from '../db/offlineDb'
import { tripSyncManager } from '../sync/tripSyncManager'
import { useAuthStore } from '../store/authStore'
import ConfirmDialog from '../components/shared/ConfirmDialog'
import { useResizablePanels } from '../hooks/useResizablePanels'
@@ -328,6 +329,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
// Load trip + files (needed for place inspector file section)
useEffect(() => {
if (tripId) {
// Stop background sync so its bundle requests don't compete with loadTrip
tripSyncManager.interrupt()
tripActions.loadTrip(tripId).catch(() => { toast.error(t('trip.toast.loadError')); navigate('/dashboard') })
tripActions.loadFiles(tripId)
loadAccommodations()
@@ -726,12 +729,18 @@ export default function TripPlannerPage(): React.ReactElement | null {
// Splash screen — show for initial load + a brief moment for photos to start loading
const [splashDone, setSplashDone] = useState(false)
const [slowLoad, setSlowLoad] = useState(false)
useEffect(() => {
if (!isLoading && trip) {
const timer = setTimeout(() => setSplashDone(true), 1500)
return () => clearTimeout(timer)
}
}, [isLoading, trip])
// Show escape hatch after 12 seconds on splash (covers slow first-load scenarios)
useEffect(() => {
const timer = setTimeout(() => setSlowLoad(true), 12000)
return () => clearTimeout(timer)
}, [])
if (isLoading || !splashDone) {
return (
@@ -771,6 +780,18 @@ export default function TripPlannerPage(): React.ReactElement | null {
}} />
))}
</div>
{slowLoad && (
<button
onClick={() => navigate('/dashboard')}
style={{
marginTop: 24, appearance: 'none', border: 'none', cursor: 'pointer',
fontFamily: 'inherit', background: 'transparent',
color: 'var(--text-faint)', fontSize: 13, textDecoration: 'underline',
}}
>
{t('trip.splash.goBack')}
</button>
)}
</div>
)
}
+29 -5
View File
@@ -27,6 +27,7 @@ import {
upsertCategories,
upsertSyncMeta,
clearTripData,
clearBlobCache,
clearAll,
} from '../db/offlineDb'
import { prefetchTilesForTrip } from './tilePrefetcher'
@@ -135,6 +136,7 @@ async function cacheFilesForTrip(files: TripFile[]): Promise<void> {
// ── Public API ────────────────────────────────────────────────────────────────
let _syncing = false
let _interrupted = false
export const tripSyncManager = {
/**
@@ -145,6 +147,7 @@ export const tripSyncManager = {
async syncAll(): Promise<void> {
if (_syncing || !navigator.onLine) return
_syncing = true
_interrupted = false
try {
const { trips } = await tripsApi.list() as { trips: Trip[] }
@@ -152,9 +155,10 @@ export const tripSyncManager = {
const stale = trips.filter(isStale)
await Promise.all(stale.map(t => clearTripData(t.id).catch(console.error)))
// Sync eligible trips
// 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) {
@@ -165,11 +169,19 @@ export const tripSyncManager = {
await syncTrip(trip.id)
} catch (retryErr) {
if (isQuotaError(retryErr)) {
console.warn('[tripSync] quota still exceeded after eviction — clearing all IDB data')
await clearAll()
return
// 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)
}
console.error(`[tripSync] failed for trip ${trip.id} after eviction:`, retryErr)
}
} else {
console.error(`[tripSync] failed for trip ${trip.id}:`, err)
@@ -177,6 +189,8 @@ export const tripSyncManager = {
}
}
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(() => {})
@@ -184,6 +198,7 @@ export const tripSyncManager = {
// 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)
@@ -195,8 +210,17 @@ export const tripSyncManager = {
}
},
/**
* Signal syncAll to stop after the current in-flight bundle request.
* Call when the user navigates to a trip page so loadTrip gets priority.
*/
interrupt(): void {
_interrupted = true
},
/** Reset syncing flag — useful in tests. */
_resetSyncing(): void {
_syncing = false
_interrupted = false
},
}