8.4 KiB
SPA Failure Containment and Deployment Recovery
Status: accepted (amended 2026-09-03) Date: 2026-07-27 Area: frontend
Context
Kandev serves a Vite SPA from the backend binary. The HTML shell is not cached, while fingerprinted assets are immutable. A tab can therefore keep an older entry bundle alive across a backend self-update and request a lazy route chunk that the new binary no longer contains.
The SPA also has a single React root without a first-party application error boundary. Settings and Office are lazy routes rendered with a null Suspense fallback. An uncaught render or lazy-import error can empty the React root, and a pending import can look like the same blank failure.
An application document can also remain open across a backend restart. The document then has old boot data and an old settings interlock token. Waiting for the next protected mutation exposes an internal recovery condition too late.
Two render-determinism defects compound this behavior:
- the Settings SPA adapter creates new thenables during render and passes them to client-side page components; and
- some Zustand selectors return new empty arrays for unchanged missing data,
violating the stable-snapshot requirement of
useSyncExternalStore.
These are cross-cutting runtime and deployment contracts rather than a new product feature. ADRs 0012, 0021, and 0022 define self-update, the Go-served SPA, and embedded Vite assets; this decision defines how the frontend remains recoverable across failures at those boundaries.
Decision
Once the SPA entry loads, every route transition must retain a visible, recoverable surface.
Failure containment
- A dependency-minimal root error boundary outside
StateProvidercatches provider, authentication, plugin-host, shell, and route failures. It renders a full-page alert with a native hard-reload action. - A route error boundary inside
AppShellcontains route failures while preserving application chrome and navigation. It resets after an error only when the browser route changes. - Narrower plugin error boundaries remain in place.
- Lazy Settings and Office routes use a visible, accessible loading surface. Loading, failure, and missing content must not be represented by an empty application root.
- User-facing recovery copy is generic. Raw errors and component stacks remain diagnostic output and are not displayed to users.
Deployment-skew recovery
The frontend installs a vite:preloadError listener before React mounts.
- It stores
{ attemptedAt, href }underkandev.preloadRecoveryinsessionStorage. - When no recent marker exists, it synchronously writes and verifies the marker, prevents Vite's later throw, and hard-reloads the document.
- A marker is recent for 60 seconds and permits at most one automatic reload in that tab during the window. A repeated failure is not prevented; it reaches the route or root boundary and presents recovery UI.
- If storage is unavailable or the marker cannot be verified, automatic reload is skipped so an unguarded cross-document loop is impossible.
- The marker is not cleared at initial mount. Clearing it before the lazy route succeeds could recreate the loop; expiration provides bounded recovery.
A rejected React.lazy import is not retried in-process because the rejected
module promise is cached. Recovery uses navigation or a hard page reload.
Self-update handoff
Service self-update hard-reloads the document exactly once after
/system/info confirms the target backend version. The existing persisted
self-update marker is cleared before reload. Refreshing only update metadata is
insufficient because it leaves the old frontend module graph alive.
Desktop-native update behavior is unchanged.
Backend-generation recovery
Each application document receives the backend boot_id in its boot payload.
After every successful WebSocket connection, the application shell requests
/system/info without cache and compares the live ID with the document ID.
- A different ID proves that the backend process changed. The current document enters a one-way reload-required state.
- A disconnect, failed request, missing ID, or equal ID does not prove a restart. It does not enter reload-required state.
- Every authenticated application route presents the same persistent in-flow alert. The alert is not a toast and does not expire or allow dismissal.
- The user must select Reload page. Kandev does not reload automatically because the current route can contain unsaved work.
- A typed stale-settings-interlock response enters the same state when a protected mutation races with proactive detection.
- Intentional restart and self-update flows coordinate with this state. One flow owns the reload action at a time, so the UI does not show duplicate reload-required surfaces.
The reload keeps the current browser location. The new document receives the new process identity and leaves reload-required state.
Render determinism
- SPA route adapters pass synchronous parsed identifiers to client components. They must not create promises or other thenables during render. Transitional server-style wrappers may remain only where a non-SPA caller still requires them.
- External-store selectors must return referentially stable empty collection snapshots. Module-level typed sentinels are the default for keyed data that has not hydrated.
- Office's existing cached parameter promises remain a transitional exception; converting that adapter is separate work.
Consequences
- Route loading and failure remain distinguishable and visible on desktop and mobile.
- A stale lazy chunk normally self-recovers once; a persistent asset failure stops reloading and exposes an actionable fallback.
- Successful service updates replace the old HTML and JavaScript graph as soon as the new backend is authoritative.
- An open application document identifies a changed backend process after its WebSocket reconnects. It gives the user a clear recovery action before the next mutation.
- Temporary network failures do not claim that Kandev restarted.
- Settings route adapters and Zustand selectors have explicit identity contracts that are enforceable with focused tests.
- Error boundaries do not catch event-handler or arbitrary asynchronous failures. Those paths still require local error handling.
sessionStorage-blocked environments skip automatic preload recovery but still receive boundary-based recovery UI.
Alternatives Considered
Only add a root error boundary
Rejected. It prevents a permanently empty root but discards usable shell navigation for route-local failures and does not distinguish loading from failure.
Retry rejected lazy imports in the current document
Rejected. React.lazy caches the rejected import promise, so an in-process
retry is not reliable.
Reload on every preload error
Rejected. A missing or corrupt asset could create an infinite cross-document reload loop.
Reload automatically after every backend restart
Rejected. The open route can contain unsaved work. A persistent warning lets the user copy that work before a full reload.
Treat every WebSocket reconnect as a backend restart
Rejected. Temporary network failures also reconnect the WebSocket. A changed
boot_id is the required proof.
Poll process identity on a fixed timer
Rejected. A backend restart already breaks and reconnects the shared WebSocket. Checking identity after connection recovery gives prompt detection without continuous background requests.
Handle only the settings interlock rejection
Rejected. That response appears only after a protected settings mutation. It does not protect other routes from continued use with old boot and route data.
Retain multiple generations of embedded assets
Deferred. It increases packaging and asset-lifecycle complexity and does not address render errors, unstable selectors, or async client components.
Cache Settings parameter promises
Rejected as the primary fix. Stable promises can avoid identity churn for
use(params) consumers but do not make async client component wrappers valid.
Synchronous SPA props remove both suspension sources.
Redesign Settings bootstrap and all null page states
Deferred. Cancellation, stale-data retention, and page-specific loading states deserve separate work. They are not required to contain the diagnosed root, lazy-chunk, thenable, and selector failures.