Skip to content

Commit 03cd235

Browse files
CasJamBrian Caselclaude
authored
Add iOS standalone (Home Screen PWA) freeze mitigations (#10)
When the app is saved to the iPhone Home Screen and run in standalone mode, it runs under a tighter WebKit memory ceiling with no browser chrome to silently reload from, so a discarded/long-suspended WebKit process leaves a frozen, dead JS context. Bake the mitigations into the template: - Disable Inertia encrypt_history (less memory + Web Crypto per navigation). - standalone-recovery.ts: client-only guard that reloads on bfcache restore and on resume after >30 min hidden, only when running standalone. - Ref-counted blockAutoReload registry + useBlockAutoReload hook so unsaved edits suppress the reload; wired into the RichTextField primitive. - Top-level ErrorBoundary with a Reload button around the Inertia <App>. - iOS standalone meta tags (viewport-fit=cover, status-bar-style, web-app-title). Recovery module is installed only from the client entrypoint, never SSR. Co-authored-by: Brian Casel <brian@briancasel.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent bf03e24 commit 03cd235

7 files changed

Lines changed: 190 additions & 3 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import * as React from "react"
2+
import { Button } from "@/components/ui/button"
3+
4+
interface ErrorBoundaryProps {
5+
children: React.ReactNode
6+
}
7+
8+
interface ErrorBoundaryState {
9+
hasError: boolean
10+
}
11+
12+
/**
13+
* Top-level error boundary wrapping the Inertia <App>. Without it, an uncaught
14+
* render error leaves a dead, unscrollable shell — indistinguishable from a
15+
* frozen tab, with no browser chrome to reload from in a standalone PWA. The
16+
* "Reload" button gives the user a guaranteed way out.
17+
*/
18+
export class ErrorBoundary extends React.Component<
19+
ErrorBoundaryProps,
20+
ErrorBoundaryState
21+
> {
22+
constructor(props: ErrorBoundaryProps) {
23+
super(props)
24+
this.state = { hasError: false }
25+
}
26+
27+
static getDerivedStateFromError(): ErrorBoundaryState {
28+
return { hasError: true }
29+
}
30+
31+
componentDidCatch(error: Error, info: React.ErrorInfo) {
32+
console.error("Uncaught render error:", error, info)
33+
}
34+
35+
render() {
36+
if (this.state.hasError) {
37+
return (
38+
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-page px-6 text-center">
39+
<h1>Something went wrong</h1>
40+
<p>The app hit an unexpected error. Reloading usually fixes it.</p>
41+
<Button onClick={() => window.location.reload()}>Reload</Button>
42+
</div>
43+
)
44+
}
45+
46+
return this.props.children
47+
}
48+
}

app/frontend/components/ui/rich-text-field.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { toggleLinkCommand } from "@milkdown/kit/component/link-tooltip";
1616
import "@milkdown/crepe/theme/common/style.css";
1717
import "@milkdown/crepe/theme/frame.css";
1818
import { cn } from "@/lib/utils";
19+
import { useBlockAutoReload } from "@/hooks/use-block-auto-reload";
1920

2021
export interface RichTextFieldProps {
2122
defaultValue?: string;
@@ -181,10 +182,19 @@ const RichTextField = React.forwardRef<HTMLDivElement, RichTextFieldProps>(
181182
onChangeRef.current = onChange;
182183
}, [onChange]);
183184

185+
// Hold a standalone-recovery block while the editor has unsaved edits so the
186+
// iOS PWA recovery guard never reloads away typed-but-unsaved content. Goes
187+
// dirty on the first real edit; resets when the parent passes a new
188+
// `defaultValue` (i.e. a save landed) and the editor re-initializes.
189+
const [dirty, setDirty] = React.useState(false);
190+
useBlockAutoReload(dirty);
191+
184192
React.useEffect(() => {
185193
const root = localRef.current;
186194
if (!root) return;
187195

196+
setDirty(false);
197+
188198
const crepe = new Crepe({
189199
root,
190200
defaultValue,
@@ -260,6 +270,7 @@ const RichTextField = React.forwardRef<HTMLDivElement, RichTextFieldProps>(
260270
crepe.create().then(() => {
261271
crepe.on((listener) => {
262272
listener.markdownUpdated((_, markdown) => {
273+
if (markdown !== defaultValue) setDirty(true);
263274
onChangeRef.current?.(markdown);
264275
});
265276
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { useEffect } from "react"
2+
import { blockAutoReload } from "@/lib/standalone-recovery"
3+
4+
/**
5+
* Hold a standalone-recovery auto-reload block while `active` is true, releasing
6+
* it on cleanup. Wire this into any surface with unsaved edits (rich-text /
7+
* markdown editors, autosave/debounced forms, anything with `useForm`/`isDirty`):
8+
* pass `active = true` from the first edit until the save lands. While any such
9+
* block is held, the iOS standalone recovery guard will not reload the page, so
10+
* unsaved work is never destroyed.
11+
*
12+
* Ref-counted under the hood, so multiple dirty surfaces compose correctly.
13+
*/
14+
export function useBlockAutoReload(active: boolean): void {
15+
useEffect(() => {
16+
if (!active) return
17+
return blockAutoReload()
18+
}, [active])
19+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
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+
}

app/javascript/entrypoints/inertia.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
import { createInertiaApp } from '@inertiajs/react'
22
import { createElement, type ReactNode } from 'react'
33
import { createRoot } from 'react-dom/client'
4+
import { ErrorBoundary } from '@/components/ErrorBoundary'
5+
import { installStandaloneRecovery } from '@/lib/standalone-recovery'
46

57
type ResolvedComponent = {
68
default: ReactNode
79
layout?: (page: ReactNode) => ReactNode
810
}
911

12+
// Recover iOS Home Screen (standalone PWA) sessions from a dead/suspended
13+
// WebKit context. Client-only — never imported by the SSR entrypoint.
14+
installStandaloneRecovery()
15+
1016
createInertiaApp({
1117
resolve: (name) => {
1218
const pages = import.meta.glob<ResolvedComponent>('../pages/**/*.tsx', {
@@ -21,7 +27,9 @@ createInertiaApp({
2127

2228
setup({ el, App, props }) {
2329
if (el) {
24-
createRoot(el).render(createElement(App, props))
30+
createRoot(el).render(
31+
createElement(ErrorBoundary, null, createElement(App, props)),
32+
)
2533
} else {
2634
console.error(
2735
'Missing root element. Move `vite_typescript_tag "inertia"` into an Inertia-specific layout.',

app/views/layouts/application.html.erb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22
<html>
33
<head>
44
<title inertia><%= content_for(:title) || "Build New" %></title>
5-
<meta name="viewport" content="width=device-width,initial-scale=1">
5+
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
66
<meta name="apple-mobile-web-app-capable" content="yes">
77
<meta name="mobile-web-app-capable" content="yes">
8+
<meta name="apple-mobile-web-app-status-bar-style" content="default">
9+
<meta name="apple-mobile-web-app-title" content="Build New">
810
<%= csrf_meta_tags %>
911
<%= csp_meta_tag %>
1012

config/initializers/inertia_rails.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22

33
InertiaRails.configure do |config|
44
config.version = ViteRuby.digest
5-
config.encrypt_history = true
5+
# Encrypting full page props into history.state on every navigation accumulates
6+
# memory + Web Crypto work that pushes iOS standalone (Home Screen PWA) sessions
7+
# toward WebKit's tighter memory ceiling, causing mid-session freezes. Leave off
8+
# unless an app genuinely needs encrypted history for a security reason.
9+
config.encrypt_history = false
610
config.always_include_errors_hash = true
711

812
# Server-side rendering. Inertia POSTs the page name + props to the SSR

0 commit comments

Comments
 (0)