Skip to content

feat(app): recover from a renderer that fails to start - #841

Merged
astyfx merged 3 commits into
mainfrom
feat/renderer-start-recovery
Oct 28, 2026
Merged

astyfx merged 3 commits into
mainfrom
feat/renderer-start-recovery

Conversation

@astyfx

@astyfx astyfx commented Oct 27, 2026 •

Copy link
Copy Markdown
Member

Summary

When the renderer fails to start, crashes while rendering, or cannot load a lazily loaded chunk, it now shows a recovery screen instead of a blank window. A stale chunk first gets one automatic reload per session. The recovery path has no dependency on i18n, so it still works when i18n itself fails to initialize.

Changes

Features

  • Root error boundary. The boundary sits above every provider, outside StrictMode and StaveDesignProvider. A render error anywhere shows the recovery screen, including during the app shell's first render.
  • Start-up failures. src/main.tsx now imports install-renderer-recovery first. If a later module throws while it evaluates, the entry never runs, and the start-up listener mounts the recovery screen on its own root. markRendererStarted() ends this phase once every import has evaluated.
  • vite:preloadError handler. A chunk or CSS file that cannot be fetched (usually a stale build right after an update) gets one automatic reload per window session.
    • The guard mark lives in sessionStorage, so it survives the reload. A second failure shows the screen instead, so the window cannot reload in a loop.
    • The guard also refuses to reload when storage is unavailable.
  • Recovery screen.
    • It has a title, a one-line explanation, a primary Reload window button, Copy error details, and a polite status line ("Error details copied" or the failure text).
    • Chunk failures use update wording ("Stave needs to reload").
    • It is fixed to the viewport with its own scroll.
    • There is no "More options" section: the renderer has no IPC for opening a logs folder.
  • No i18n in the recovery path. Nothing the recovery module loads statically reaches src/i18n. If i18n fails to initialize, the listeners still install and the screen still renders.
    • Buttons: the actions use the ADS headless ButtonRoot with the ADS focusRing.ring and transition.colors recipes, plus role-token colors at the ADS medium control size. The canonical Button cannot be used here because it imports Loader, which imports @/i18n for its label.
    • No design provider: the screen does not rely on StaveDesignProvider. It publishes the ADS-to-Stave role mapping on its own root, and its surfaces use Stave's role tokens with system-color fallbacks.
    • Locale list: the recovery logic keeps its own copy of the locale list instead of loading src/i18n/locale.ts. A test keeps it equal to APP_LOCALES.
  • Copied details. The report contains the app version (from the existing update-status IPC, 1s timeout), platform, user agent, failure kind, time, message, stack, cause chain (cycle-safe, depth 5) and React component stack. Two kinds of data are redacted:
    • home-directory account names (/Users/<user>, /home/<user>, C:\Users\<user>)
    • credential-shaped values: bearer/basic headers, URL userinfo, secret-named key=value/JSON pairs, and common API token, JWT and access-key shapes
  • i18n. The copy lives in app:rendererRecovery.* in en and ko.
    • The screen reads text through a translator that recovery-i18n.ts registers once the i18n runtime loads. The hard-coded English table (marked i18n-ignore) is used only if that never happens. A test keeps it identical to the en catalog.
    • After a start-up failure, the screen follows the saved language and theme mode from the persisted store, then the system preference. After start-up it keeps what the app applied.

Test Evidence

Commands run on the final branch head (5f20b925):

  • bun run lint: pass (0 errors; existing warnings only)
  • bun run typecheck: pass
  • bun test ./tests/renderer-recovery.test.tsx: 25 pass, 0 fail
  • bun run check:i18n: pass
  • bun run check:design-system: pass
  • bun run check:style-channel, bun run check:max-lines-ratchet: pass
  • bun run build: pass. The entry chunk contains both Vite's vite:preloadError dispatch and the handler.
  • bun run build:pages: pass
  • bun run test:isolated: 8892 pass, 1 fail. The failure is pre-existing and unrelated:
    • Test: tests/claude-sdk-runtime.test.ts, "keeps the generic rate limit wording when the window is unknown".
    • Cause: it sets a reset time of now + 10 minutes. When that time crosses local midnight, the product message includes the date, so the test fails near midnight. The local run happened then.
    • It fails the same way on main.
  • Earlier on this branch, before the i18n-independence fix:
    • bun run build:desktop: pass
    • check:doc-paths, check:reliability-gates, check:switch-exhaustiveness, check:licenses, check:temporary-migrations: pass
    • bun test ./tests/i18n-catalog-check.test.ts ./tests/i18n-hardcoded-strings.test.ts ./tests/i18n-display-change.test.tsx: 25 pass
  • CI on 5f20b925: all checks green, including validate.

tests/renderer-recovery.test.tsx covers:

  • Redaction, cause chains, report content and truncation.
  • The reload guard: one reload, and refusal when storage is missing or throws.
  • Chunk-error detection and failure handling.
  • Window listeners: a preload error reloads, then shows the screen; a start-up error is handled differently before and after start-up.
  • Copy fallback vs. the catalogs, the persisted-key match, appearance resolution, root and boundary rendering, and entry import order.
  • Import graph: a static walk of the recovery module asserts that nothing it loads reaches src/i18n. The helper is tests/fixtures/static-import-graph.ts.
  • i18n failure: a separate bun process with tests/fixtures/i18n-init-failure.preload.ts makes every src/i18n module throw while loading. It then loads the recovery the way src/main.tsx does (tests/fixtures/renderer-recovery-i18n-failure.ts) and asserts:
    • i18n and the translator registration really threw
    • both error and vite:preloadError listeners installed
    • a start-up error records the failure and targets #root
    • the screen renders with the English fallback

Rendered check (headless Chrome via bun run dev, temporary local throws, not committed)

  • Before (main + a throw in App render): a blank dark window, nothing to act on.
  • Render error, dark/en:
    • Centered title "Stave ran into a problem", the explanation, and two 36px buttons on --background: Reload window (primary, focused, focus ring visible) and Copy error details (outline).
    • The primary button resolved to --primary (dark oklch(0.71 0.15 252), light oklch(0.54 0.18 260)).
    • The copied text had /Users/<user>/… and token=<redacted> in place of the injected path and token, plus the cause chain and component stack.
  • Render error, light/ko: Korean title, body and buttons on the light background. The status line read "오류 정보를 복사했습니다".
  • 320x160 window: content is 248px tall in a 160px viewport. The screen scrolls, and the title stays reachable at the top.
  • Module-scope throw in App.tsx (start-up failure): the screen mounted without the app, in the saved language (ko) or theme (dark).
    • In dev, globals.css is injected by the modules that never ran, so the screen used the system-color fallbacks. Text stayed legible.
    • A production build links that stylesheet from index.html (checked in dist/index.html), so the role tokens are present even when no script ran.
  • Chunk path: the first dispatched vite:preloadError reloaded the page and set the session mark. The second showed "Stave needs to reload".

Notes

  • If the entry graph fails to link at all (a missing static module file), no renderer code runs and this cannot help. The main-process crash/unresponsive dialog is unchanged.
  • The persisted store key is duplicated as PERSISTED_APP_STORE_KEY. A test asserts it matches APP_STORE_KEY.
  • Errors are not forwarded to the main-process diagnostics log in this change.

🤖 Generated with Claude Code

A render error anywhere in the app, a module that throws while the
renderer starts, or a lazily loaded chunk that cannot be fetched used to
leave a blank window.

- The renderer entry imports the recovery module first. If a later module
  fails to evaluate, its start-up listener mounts the recovery screen on
  its own root; once start-up is over, an error boundary above every
  provider catches render errors.
- vite:preloadError (a stale chunk, usually right after an update) gets
  one automatic reload per window session, guarded in sessionStorage, and
  the screen after that, so a broken build cannot reload forever.
- The screen has a title, a one-line explanation, Reload window and Copy
  error details. It uses Stave's role color tokens with system-color
  fallbacks instead of ADS controls or the design provider, follows the
  saved theme mode and language when the app never started, scrolls in
  small windows, and is translated in English and Korean, with an English
  fallback only if the i18n runtime itself failed to load. No logs-folder
  action exists to offer, so there is no "More options".
- Copied details carry the app version, platform, user agent, message,
  stack, cause chain and component stack, with home-directory account
  names and credential-shaped values redacted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@astyfx
astyfx requested a review from a team as a code owner October 27, 2026 13:53
@astyfx
astyfx requested review from heath-s and schang124 and removed request for a team October 27, 2026 13:53
@upwind-code-us

upwind-code-us Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Upwind Upwind IaC Scan - ✅ Passed

0 newly introduced misconfigurations · 0 resolved · 0 total in this PR vs main

View full analysis in Upwind Console →

Scan completed in 51s

Scan history (3 scans)
Commit Scanned at New Resolved Net
bc41f92 2026-10-27 13:53 UTC 0 0 0
ecf2d73 2026-10-27 14:07 UTC 0 0 0
5f20b92 < 2026-10-27 23:57 UTC 0 0 0

Last scanned: 5f20b92 · 2026-10-27 23:57 UTC

The design-system gate requires product actions to compose the ADS Button.
The screen now publishes the ADS-to-Stave role mapping on its own root, so
the buttons follow the theme without StaveDesignProvider above them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@heath-s heath-s left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved

The recovery module imported the canonical ADS Button, which imports Loader,
which imports @/i18n. If i18n failed to initialize, the recovery module
failed with it before its listeners existed, and the window stayed blank.

- The recovery screen uses the ADS headless ButtonRoot with the ADS
  focus-ring and transition recipes and role-token colors. The canonical
  Button cannot be used here without pulling i18n in through Loader.
- The recovery logic no longer loads src/i18n/locale.ts; it keeps its own
  locale list, which a test holds equal to APP_LOCALES.
- Tests: a static import-graph walk asserts nothing the recovery module
  loads reaches src/i18n, and a separate bun process makes every i18n
  module throw at load, then checks that both listeners install, a start-up
  error records the failure and mounts on #root, and the screen renders with
  the English fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@astyfx
astyfx merged commit 7b0ef97 into main Oct 28, 2026
7 checks passed
@astyfx
astyfx deleted the feat/renderer-start-recovery branch October 28, 2026 01:33
astyfx added a commit that referenced this pull request Oct 28, 2026
Bring in #839, #840, #841, #842, #844 and #846.

- Placeholder: one resolver. Main's `resolvePromptPlaceholder` now also
  takes `approvalRedirect` and returns the decline-and-steer or
  decline-and-queue keys. The duplicate `resolvePromptInputPlaceholder` is
  removed, and its tests now go through `resolvePromptPlaceholder`.
- Approval keys: main's `useComposerApprovalShortcuts` now returns the
  Enter/Tab handler the empty editor uses. `use-approval-window-shortcuts.ts`
  is removed.
- The composer's redirect-mode derivation moves into
  `resolveComposerApprovalRedirectMode`, which keeps `ChatInputComposer.tsx`
  and `prompt-input.tsx` within main's lowered line limits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants