|
| 1 | +// Standalone (iOS Home Screen PWA) recovery guard. |
| 2 | +// |
| 3 | +// Browser-only. Install this from the CLIENT Inertia entrypoint only — never |
| 4 | +// from the SSR entrypoint, since it touches `window`/`document`. |
| 5 | +// |
| 6 | +// Why this exists: when an app is saved to the iPhone Home Screen and run in |
| 7 | +// standalone mode, it runs under a tighter WebKit memory ceiling and has no |
| 8 | +// browser chrome to silently reload from. When iOS discards or long-suspends |
| 9 | +// the backgrounded WebKit process, the app can return to a dead JS context — |
| 10 | +// frozen, unscrollable, untappable. Regular Safari tabs hide this by |
| 11 | +// transparently reloading; standalone apps don't. This guard reloads the page |
| 12 | +// when it's likely returning to a dead context: (a) a bfcache restore, and |
| 13 | +// (b) a resume after being hidden longer than RESUME_RELOAD_AFTER_MS. |
| 14 | +// |
| 15 | +// The reload is intentionally suppressed while unsaved edits exist (see the |
| 16 | +// ref-counted block registry below) — never destroy unsaved work. |
| 17 | + |
| 18 | +const RESUME_RELOAD_AFTER_MS = 30 * 60 * 1000 // 30 minutes |
| 19 | + |
| 20 | +// Reference-counted opt-out. Each active edit surface holds one reference; |
| 21 | +// the guard reloads only when the count is zero. Ref-counting (not a single |
| 22 | +// boolean) lets multiple dirty editors compose, and ensures an unmount can't |
| 23 | +// strand the block held by a sibling. |
| 24 | +let blockCount = 0 |
| 25 | + |
| 26 | +/** |
| 27 | + * Register an unsaved-work block. Returns a release function; call it once the |
| 28 | + * work is saved (or the surface unmounts). Calling release more than once is a |
| 29 | + * no-op. Prefer the `useBlockAutoReload` React hook over calling this directly. |
| 30 | + */ |
| 31 | +export function blockAutoReload(): () => void { |
| 32 | + blockCount += 1 |
| 33 | + let released = false |
| 34 | + return () => { |
| 35 | + if (released) return |
| 36 | + released = true |
| 37 | + blockCount = Math.max(0, blockCount - 1) |
| 38 | + } |
| 39 | +} |
| 40 | + |
| 41 | +function isStandalone(): boolean { |
| 42 | + if (typeof window === "undefined") return false |
| 43 | + const matches = |
| 44 | + typeof window.matchMedia === "function" && |
| 45 | + window.matchMedia("(display-mode: standalone)").matches |
| 46 | + const iosStandalone = |
| 47 | + (window.navigator as Navigator & { standalone?: boolean }).standalone === true |
| 48 | + return matches || iosStandalone |
| 49 | +} |
| 50 | + |
| 51 | +function reloadBlocked(): boolean { |
| 52 | + if (blockCount > 0) return true |
| 53 | + // Escape hatch for non-React surfaces that hold unsaved state. |
| 54 | + return ( |
| 55 | + (window as unknown as { __blockAutoReload?: boolean }).__blockAutoReload === true |
| 56 | + ) |
| 57 | +} |
| 58 | + |
| 59 | +let installed = false |
| 60 | + |
| 61 | +/** |
| 62 | + * Install the standalone recovery listeners. No-op unless running standalone, |
| 63 | + * and safe to call more than once (idempotent). Browser-only. |
| 64 | + */ |
| 65 | +export function installStandaloneRecovery(): void { |
| 66 | + if (installed || typeof window === "undefined") return |
| 67 | + if (!isStandalone()) return |
| 68 | + installed = true |
| 69 | + |
| 70 | + // (a) bfcache restore: a page restored from the back/forward cache may be |
| 71 | + // running a stale/dead JS context. Reload to get a fresh one. |
| 72 | + window.addEventListener("pageshow", (event) => { |
| 73 | + if ((event as PageTransitionEvent).persisted && !reloadBlocked()) { |
| 74 | + window.location.reload() |
| 75 | + } |
| 76 | + }) |
| 77 | + |
| 78 | + // (b) resume after a long background suspension: track when we went hidden |
| 79 | + // and reload if we come back visible after more than the threshold. |
| 80 | + let hiddenAt: number | null = null |
| 81 | + document.addEventListener("visibilitychange", () => { |
| 82 | + if (document.visibilityState === "hidden") { |
| 83 | + hiddenAt = Date.now() |
| 84 | + } else if (document.visibilityState === "visible") { |
| 85 | + if ( |
| 86 | + hiddenAt !== null && |
| 87 | + Date.now() - hiddenAt > RESUME_RELOAD_AFTER_MS && |
| 88 | + !reloadBlocked() |
| 89 | + ) { |
| 90 | + window.location.reload() |
| 91 | + } |
| 92 | + hiddenAt = null |
| 93 | + } |
| 94 | + }) |
| 95 | +} |
0 commit comments