fix: resolve iOS Safari loading issues with database initialization

- Added comprehensive error handling for PGLite IndexedDB initialization
- Implemented iOS Safari detection and in-memory database fallback
- Added timeout handling to prevent infinite loading states
- Improved error boundaries with user-friendly error messages
- Enhanced migration robustness with detailed error logging
- Provides 'Retry' functionality for transient failures

Fixes iPhone app showing only spinner and TabBar without content loading
This commit is contained in:
2026-03-12 17:54:39 +01:00
parent 8a8270dbae
commit e4c7baab73
3 changed files with 314 additions and 81 deletions

View File

@@ -11,18 +11,91 @@ async function initDb(db: PGlite): Promise<PGlite> {
return db
}
/** Get the singleton PGlite instance. Safe to call multiple times — only creates once. */
/** Detect if we're on iOS Safari which has IndexedDB limitations */
function isIOSSafari(): boolean {
const userAgent = navigator.userAgent
const isIOS = /iPad|iPhone|iPod/.test(userAgent)
const isSafari =
/Safari/.test(userAgent) && !/Chrome|CriOS|FxiOS/.test(userAgent)
return isIOS || (isSafari && "ontouchstart" in window)
}
/** Get the singleton PGlite instance with iOS fallback. Safe to call multiple times — only creates once. */
export function getDb(): Promise<PGlite> {
if (instance) return Promise.resolve(instance)
if (!initPromise) {
initPromise = initDb(new PGlite("idb://agw")).then((db) => {
instance = db
return db
})
initPromise = createDbWithFallback()
.then((db) => {
instance = db
return db
})
.catch((error) => {
console.error("Database initialization failed:", error)
// Reset promise to allow retry
initPromise = null
throw error
})
}
return initPromise
}
async function createDbWithFallback(): Promise<PGlite> {
// Add timeout to prevent hanging
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(
() => reject(new Error("Database initialization timeout")),
10000,
)
})
try {
// First try IndexedDB with timeout
const dbPromise = Promise.race([
initDb(new PGlite("idb://agw")),
timeoutPromise,
])
const db = await dbPromise
console.log("Database initialized successfully with IndexedDB")
return db
} catch (error) {
console.warn(
"IndexedDB initialization failed, falling back to memory:",
error,
)
// iOS Safari fallback: use in-memory database
if (isIOSSafari()) {
console.log("iOS Safari detected, using in-memory database")
return await initDb(new PGlite())
}
// For other browsers, try one more time with shorter timeout
try {
const shortTimeoutPromise = new Promise<never>((_, reject) => {
setTimeout(
() => reject(new Error("Database initialization timeout (retry)")),
5000,
)
})
const retryDb = await Promise.race([
initDb(new PGlite("idb://agw")),
shortTimeoutPromise,
])
console.log("Database initialized successfully with IndexedDB (retry)")
return retryDb
} catch (retryError) {
console.warn(
"IndexedDB retry failed, using in-memory database:",
retryError,
)
return await initDb(new PGlite())
}
}
}
/** Create a fresh in-memory PGlite for tests. */
export async function createTestDb(): Promise<PGlite> {
return initDb(new PGlite())

View File

@@ -3,87 +3,121 @@ import type { PGlite } from "@electric-sql/pglite"
/** One-time migration: read existing localStorage data, insert into PGlite, remove localStorage keys. */
export async function migrateFromLocalStorage(db: PGlite): Promise<void> {
// device ID
const deviceId = localStorage.getItem(STORAGE_KEYS.deviceId)
if (deviceId) {
await db.query(
"INSERT INTO device (id) VALUES ($1) ON CONFLICT DO NOTHING",
[deviceId],
)
localStorage.removeItem(STORAGE_KEYS.deviceId)
}
console.log("Starting localStorage migration...")
// follows
const followsRaw = localStorage.getItem(STORAGE_KEYS.follows)
if (followsRaw) {
try {
const follows = JSON.parse(followsRaw) as Array<{
type: string
entity_id: number
label: string
}>
for (const f of follows) {
try {
// device ID
const deviceId = localStorage.getItem(STORAGE_KEYS.deviceId)
if (deviceId) {
try {
await db.query(
"INSERT INTO follows (type, entity_id, label) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
[f.type, f.entity_id, f.label],
"INSERT INTO device (id) VALUES ($1) ON CONFLICT DO NOTHING",
[deviceId],
)
localStorage.removeItem(STORAGE_KEYS.deviceId)
console.log("Migrated device ID")
} catch (error) {
console.warn("Failed to migrate device ID:", error)
}
} catch {
// corrupt data — skip
}
localStorage.removeItem(STORAGE_KEYS.follows)
}
// feed cache
const feedRaw = localStorage.getItem(STORAGE_KEYS.feedCache)
if (feedRaw) {
try {
const parsed = JSON.parse(feedRaw) as {
items: unknown[]
updatedAt: number
// follows
const followsRaw = localStorage.getItem(STORAGE_KEYS.follows)
if (followsRaw) {
try {
const follows = JSON.parse(followsRaw) as Array<{
type: string
entity_id: number
label: string
}>
for (const f of follows) {
try {
await db.query(
"INSERT INTO follows (type, entity_id, label) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
[f.type, f.entity_id, f.label],
)
} catch (error) {
console.warn("Failed to migrate follow item:", f, error)
}
}
localStorage.removeItem(STORAGE_KEYS.follows)
console.log("Migrated follows")
} catch (error) {
console.warn("Failed to migrate follows:", error)
}
if (Array.isArray(parsed.items)) {
}
// feed cache
const feedRaw = localStorage.getItem(STORAGE_KEYS.feedCache)
if (feedRaw) {
try {
const parsed = JSON.parse(feedRaw) as {
items: unknown[]
updatedAt: number
}
if (Array.isArray(parsed.items)) {
await db.query(
`INSERT INTO feed_cache (id, data, updated_at) VALUES ('feed_items', $1, to_timestamp($2 / 1000.0))
ON CONFLICT (id) DO UPDATE SET data = $1, updated_at = to_timestamp($2 / 1000.0)`,
[JSON.stringify({ items: parsed.items }), parsed.updatedAt],
)
}
localStorage.removeItem(STORAGE_KEYS.feedCache)
console.log("Migrated feed cache")
} catch (error) {
console.warn("Failed to migrate feed cache:", error)
}
}
// geo cache
const geoRaw = localStorage.getItem(STORAGE_KEYS.geoCache)
if (geoRaw) {
try {
const cache = JSON.parse(geoRaw) as Record<
string,
{ timestamp: number; result: unknown }
>
for (const [bundesland, entry] of Object.entries(cache)) {
try {
await db.query(
`INSERT INTO geo_cache (bundesland, data, cached_at) VALUES ($1, $2, to_timestamp($3 / 1000.0))
ON CONFLICT (bundesland) DO UPDATE SET data = $2, cached_at = to_timestamp($3 / 1000.0)`,
[bundesland, JSON.stringify(entry.result), entry.timestamp],
)
} catch (error) {
console.warn(
"Failed to migrate geo cache entry:",
bundesland,
error,
)
}
}
localStorage.removeItem(STORAGE_KEYS.geoCache)
console.log("Migrated geo cache")
} catch (error) {
console.warn("Failed to migrate geo cache:", error)
}
}
// push enabled
const pushEnabled = localStorage.getItem(STORAGE_KEYS.pushEnabled)
if (pushEnabled) {
try {
await db.query(
`INSERT INTO feed_cache (id, data, updated_at) VALUES ('feed_items', $1, to_timestamp($2 / 1000.0))
ON CONFLICT (id) DO UPDATE SET data = $1, updated_at = to_timestamp($2 / 1000.0)`,
[JSON.stringify({ items: parsed.items }), parsed.updatedAt],
`INSERT INTO push_state (key, value) VALUES ('enabled', $1)
ON CONFLICT (key) DO UPDATE SET value = $1`,
[pushEnabled],
)
localStorage.removeItem(STORAGE_KEYS.pushEnabled)
console.log("Migrated push enabled state")
} catch (error) {
console.warn("Failed to migrate push enabled state:", error)
}
} catch {
// corrupt data — skip
}
localStorage.removeItem(STORAGE_KEYS.feedCache)
}
// geo cache
const geoRaw = localStorage.getItem(STORAGE_KEYS.geoCache)
if (geoRaw) {
try {
const cache = JSON.parse(geoRaw) as Record<
string,
{ timestamp: number; result: unknown }
>
for (const [bundesland, entry] of Object.entries(cache)) {
await db.query(
`INSERT INTO geo_cache (bundesland, data, cached_at) VALUES ($1, $2, to_timestamp($3 / 1000.0))
ON CONFLICT (bundesland) DO UPDATE SET data = $2, cached_at = to_timestamp($3 / 1000.0)`,
[bundesland, JSON.stringify(entry.result), entry.timestamp],
)
}
} catch {
// corrupt data — skip
}
localStorage.removeItem(STORAGE_KEYS.geoCache)
}
// push enabled
const pushEnabled = localStorage.getItem(STORAGE_KEYS.pushEnabled)
if (pushEnabled) {
await db.query(
`INSERT INTO push_state (key, value) VALUES ('enabled', $1)
ON CONFLICT (key) DO UPDATE SET value = $1`,
[pushEnabled],
)
localStorage.removeItem(STORAGE_KEYS.pushEnabled)
console.log("localStorage migration completed")
} catch (error) {
console.error("Migration failed with critical error:", error)
// Don't throw - let the app continue with empty database
}
}

View File

@@ -1,20 +1,146 @@
import type { PGlite } from "@electric-sql/pglite"
import { type ReactNode, createContext, use } from "react"
import {
Component,
type ErrorInfo,
type ReactNode,
Suspense,
createContext,
use,
} from "react"
import { getDb } from "./client"
import { migrateFromLocalStorage } from "./migrate-from-localstorage"
const dbPromise = getDb().then(async (db) => {
await migrateFromLocalStorage(db)
return db
})
const dbPromise = getDb()
.then(async (db) => {
try {
await migrateFromLocalStorage(db)
console.log("Database migration completed successfully")
return db
} catch (error) {
console.warn("Database migration failed, continuing anyway:", error)
// Continue with database even if migration fails
return db
}
})
.catch((error) => {
console.error("Database initialization completely failed:", error)
// Re-throw to be caught by ErrorBoundary
throw error
})
const DbContext = createContext<PGlite | null>(null)
export function DbProvider({ children }: { children: ReactNode }) {
interface ErrorBoundaryState {
hasError: boolean
error?: Error
}
class DbErrorBoundary extends Component<
{ children: ReactNode; onReset?: () => void },
ErrorBoundaryState
> {
constructor(props: { children: ReactNode; onReset?: () => void }) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("DbErrorBoundary caught error:", error, errorInfo)
}
handleReset = () => {
this.setState({ hasError: false, error: undefined })
if (this.props.onReset) {
this.props.onReset()
} else {
// Default: reload the page
window.location.reload()
}
}
render() {
if (this.state.hasError) {
return (
<div className="flex-1 flex flex-col items-center justify-center p-6 text-center">
<div className="w-16 h-16 rounded-full bg-red-100 dark:bg-red-900/20 flex items-center justify-center mb-4">
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-8 h-8 text-red-600 dark:text-red-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
role="img"
aria-label="Error icon"
>
<title>Error</title>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<h2 className="text-lg font-semibold mb-2">
Datenbank-Initialisierung fehlgeschlagen
</h2>
<p className="text-sm text-muted-foreground mb-4 max-w-sm">
Die App konnte nicht geladen werden. Dies kann auf iOS Safari
vorkommen.
</p>
<button
type="button"
onClick={this.handleReset}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
>
Erneut versuchen
</button>
{this.state.error && (
<details className="mt-4 text-xs text-gray-600 max-w-sm">
<summary className="cursor-pointer">Technische Details</summary>
<pre className="mt-2 p-2 bg-gray-100 dark:bg-gray-800 rounded text-left text-xs overflow-auto">
{this.state.error.message}
</pre>
</details>
)}
</div>
)
}
return this.props.children
}
}
function DbProviderInner({ children }: { children: ReactNode }) {
const db = use(dbPromise)
return <DbContext value={db}>{children}</DbContext>
}
export function DbProvider({ children }: { children: ReactNode }) {
return (
<DbErrorBoundary>
<Suspense
fallback={
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-3" />
<p className="text-sm text-muted-foreground">
Datenbank wird geladen...
</p>
</div>
</div>
}
>
<DbProviderInner>{children}</DbProviderInner>
</Suspense>
</DbErrorBoundary>
)
}
export function useDb(): PGlite {
const db = use(DbContext)
if (!db) throw new Error("useDb must be used within DbProvider")