Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions app/frontend/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import * as React from "react"
import { Button } from "@/components/ui/button"

interface ErrorBoundaryProps {
children: React.ReactNode
}

interface ErrorBoundaryState {
hasError: boolean
}

/**
* Top-level error boundary wrapping the Inertia <App>. Without it, an uncaught
* render error leaves a dead, unscrollable shell — indistinguishable from a
* frozen tab, with no browser chrome to reload from in a standalone PWA. The
* "Reload" button gives the user a guaranteed way out.
*/
export class ErrorBoundary extends React.Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
constructor(props: ErrorBoundaryProps) {
super(props)
this.state = { hasError: false }
}

static getDerivedStateFromError(): ErrorBoundaryState {
return { hasError: true }
}

componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error("Uncaught render error:", error, info)
}

render() {
if (this.state.hasError) {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-page px-6 text-center">
<h1>Something went wrong</h1>
<p>The app hit an unexpected error. Reloading usually fixes it.</p>
<Button onClick={() => window.location.reload()}>Reload</Button>
</div>
)
}

return this.props.children
}
}
11 changes: 11 additions & 0 deletions app/frontend/components/ui/rich-text-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { toggleLinkCommand } from "@milkdown/kit/component/link-tooltip";
import "@milkdown/crepe/theme/common/style.css";
import "@milkdown/crepe/theme/frame.css";
import { cn } from "@/lib/utils";
import { useBlockAutoReload } from "@/hooks/use-block-auto-reload";

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

// Hold a standalone-recovery block while the editor has unsaved edits so the
// iOS PWA recovery guard never reloads away typed-but-unsaved content. Goes
// dirty on the first real edit; resets when the parent passes a new
// `defaultValue` (i.e. a save landed) and the editor re-initializes.
const [dirty, setDirty] = React.useState(false);
useBlockAutoReload(dirty);

React.useEffect(() => {
const root = localRef.current;
if (!root) return;

setDirty(false);

const crepe = new Crepe({
root,
defaultValue,
Expand Down Expand Up @@ -260,6 +270,7 @@ const RichTextField = React.forwardRef<HTMLDivElement, RichTextFieldProps>(
crepe.create().then(() => {
crepe.on((listener) => {
listener.markdownUpdated((_, markdown) => {
if (markdown !== defaultValue) setDirty(true);
onChangeRef.current?.(markdown);
});
});
Expand Down
19 changes: 19 additions & 0 deletions app/frontend/hooks/use-block-auto-reload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { useEffect } from "react"
import { blockAutoReload } from "@/lib/standalone-recovery"

/**
* Hold a standalone-recovery auto-reload block while `active` is true, releasing
* it on cleanup. Wire this into any surface with unsaved edits (rich-text /
* markdown editors, autosave/debounced forms, anything with `useForm`/`isDirty`):
* pass `active = true` from the first edit until the save lands. While any such
* block is held, the iOS standalone recovery guard will not reload the page, so
* unsaved work is never destroyed.
*
* Ref-counted under the hood, so multiple dirty surfaces compose correctly.
*/
export function useBlockAutoReload(active: boolean): void {
useEffect(() => {
if (!active) return
return blockAutoReload()
}, [active])
}
95 changes: 95 additions & 0 deletions app/frontend/lib/standalone-recovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// Standalone (iOS Home Screen PWA) recovery guard.
//
// Browser-only. Install this from the CLIENT Inertia entrypoint only — never
// from the SSR entrypoint, since it touches `window`/`document`.
//
// Why this exists: when an app is saved to the iPhone Home Screen and run in
// standalone mode, it runs under a tighter WebKit memory ceiling and has no
// browser chrome to silently reload from. When iOS discards or long-suspends
// the backgrounded WebKit process, the app can return to a dead JS context —
// frozen, unscrollable, untappable. Regular Safari tabs hide this by
// transparently reloading; standalone apps don't. This guard reloads the page
// when it's likely returning to a dead context: (a) a bfcache restore, and
// (b) a resume after being hidden longer than RESUME_RELOAD_AFTER_MS.
//
// The reload is intentionally suppressed while unsaved edits exist (see the
// ref-counted block registry below) — never destroy unsaved work.

const RESUME_RELOAD_AFTER_MS = 30 * 60 * 1000 // 30 minutes

// Reference-counted opt-out. Each active edit surface holds one reference;
// the guard reloads only when the count is zero. Ref-counting (not a single
// boolean) lets multiple dirty editors compose, and ensures an unmount can't
// strand the block held by a sibling.
let blockCount = 0

/**
* Register an unsaved-work block. Returns a release function; call it once the
* work is saved (or the surface unmounts). Calling release more than once is a
* no-op. Prefer the `useBlockAutoReload` React hook over calling this directly.
*/
export function blockAutoReload(): () => void {
blockCount += 1
let released = false
return () => {
if (released) return
released = true
blockCount = Math.max(0, blockCount - 1)
}
}

function isStandalone(): boolean {
if (typeof window === "undefined") return false
const matches =
typeof window.matchMedia === "function" &&
window.matchMedia("(display-mode: standalone)").matches
const iosStandalone =
(window.navigator as Navigator & { standalone?: boolean }).standalone === true
return matches || iosStandalone
}

function reloadBlocked(): boolean {
if (blockCount > 0) return true
// Escape hatch for non-React surfaces that hold unsaved state.
return (
(window as unknown as { __blockAutoReload?: boolean }).__blockAutoReload === true
)
}

let installed = false

/**
* Install the standalone recovery listeners. No-op unless running standalone,
* and safe to call more than once (idempotent). Browser-only.
*/
export function installStandaloneRecovery(): void {
if (installed || typeof window === "undefined") return
if (!isStandalone()) return
installed = true

// (a) bfcache restore: a page restored from the back/forward cache may be
// running a stale/dead JS context. Reload to get a fresh one.
window.addEventListener("pageshow", (event) => {
if ((event as PageTransitionEvent).persisted && !reloadBlocked()) {
window.location.reload()
}
})

// (b) resume after a long background suspension: track when we went hidden
// and reload if we come back visible after more than the threshold.
let hiddenAt: number | null = null
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") {
hiddenAt = Date.now()
} else if (document.visibilityState === "visible") {
if (
hiddenAt !== null &&
Date.now() - hiddenAt > RESUME_RELOAD_AFTER_MS &&
!reloadBlocked()
) {
window.location.reload()
}
hiddenAt = null
}
})
}
10 changes: 9 additions & 1 deletion app/javascript/entrypoints/inertia.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import { createInertiaApp } from '@inertiajs/react'
import { createElement, type ReactNode } from 'react'
import { createRoot } from 'react-dom/client'
import { ErrorBoundary } from '@/components/ErrorBoundary'
import { installStandaloneRecovery } from '@/lib/standalone-recovery'

type ResolvedComponent = {
default: ReactNode
layout?: (page: ReactNode) => ReactNode
}

// Recover iOS Home Screen (standalone PWA) sessions from a dead/suspended
// WebKit context. Client-only — never imported by the SSR entrypoint.
installStandaloneRecovery()

createInertiaApp({
resolve: (name) => {
const pages = import.meta.glob<ResolvedComponent>('../pages/**/*.tsx', {
Expand All @@ -21,7 +27,9 @@ createInertiaApp({

setup({ el, App, props }) {
if (el) {
createRoot(el).render(createElement(App, props))
createRoot(el).render(
createElement(ErrorBoundary, null, createElement(App, props)),
)
} else {
console.error(
'Missing root element. Move `vite_typescript_tag "inertia"` into an Inertia-specific layout.',
Expand Down
4 changes: 3 additions & 1 deletion app/views/layouts/application.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@
<html>
<head>
<title inertia><%= content_for(:title) || "Build New" %></title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="Build New">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>

Expand Down
6 changes: 5 additions & 1 deletion config/initializers/inertia_rails.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@

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

# Server-side rendering. Inertia POSTs the page name + props to the SSR
Expand Down
Loading