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:
@@ -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())
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user