Skip to main content

@ovineko/spa-guard

Core runtime for spa-guard — chunk load error handling, version checking, spinner, i18n, and event schema for SPAs.

Install

pnpm add @ovineko/spa-guard

Usage

Call recommendedSetup once when your app boots. It dismisses the loading spinner, starts version checking, and returns a cleanup function.

import { recommendedSetup } from "@ovineko/spa-guard/runtime";

const cleanup = recommendedSetup();
// optionally call cleanup() to stop background checks

recommendedSetup is idempotent. Repeated calls return the same cleanup function and do not re-run setup side effects.

By default, recommendedSetup uses healthyBoot: "auto":

  • if retry params exist in the URL, it waits a dynamic grace period and calls markRetryHealthyBoot() only when the orchestrator phase is still idle.
  • if no retry params exist, it does nothing.

Auto grace period formula:

  • max(5000, max(reloadDelays)+1000, sum(lazyRetry.retryDelays)+1000)

If you want strict control, switch to manual healthy-boot mode and call markRetryHealthyBoot() yourself.

import { markRetryHealthyBoot } from "@ovineko/spa-guard";
import { recommendedSetup } from "@ovineko/spa-guard/runtime";

recommendedSetup();

await Promise.all([import("./routes/Home"), import("./routes/Checkout")]);

markRetryHealthyBoot();

Disable auto healthy boot:

recommendedSetup({ healthyBoot: "manual" });

Disable version checking:

const cleanup = recommendedSetup({ versionCheck: false });

Retry behavior and event flow

spa-guard uses a single retry orchestrator (retryOrchestrator.ts) as the sole owner of retry lifecycle. All reload scheduling, deduplication, and fallback transitions run through triggerRetry().

Retry state machine

The orchestrator maintains an explicit phase:

  • idle — no retry in progress
  • scheduled — a reload has been scheduled (timer running); concurrent triggers are deduplicated
  • fallback — retries exhausted, fallback UI is shown; further triggers are ignored

Retry progression

When a recoverable error occurs (chunk load error, unhandled rejection, vite:preloadError, or static asset 404):

  1. Event listener classifies the event and calls triggerRetry({ source, error }).
  2. Orchestrator checks phase — if already scheduled or fallback, returns immediately without scheduling another reload.
  3. On first trigger: reads RETRY_ATTEMPT_PARAM from URL to restore attempt count after a reload.
  4. If attempts remain: increments attempt, calls showLoadingUI(nextAttempt) to render the loading UI immediately (before the timer fires), sets a timer for reloadDelays[currentAttempt], encodes retryId and attempt count into the reload URL, then navigates. Requires options.html.loading.content to be configured; if absent, showLoadingUI returns silently and the retry still proceeds.
  5. If attempts are exhausted: transitions to fallback, calls setFallbackMode(), sends a beacon, and renders fallback UI.

URL params

The orchestrator serializes state into URL params for cross-reload continuity:

  • RETRY_ATTEMPT_PARAM — current attempt count (strict non-negative integer; malformed values are ignored)
  • RETRY_ID_PARAM — unique ID for this retry session
  • CACHE_BUST_PARAM — timestamp for cache busting (set when cacheBust: true is passed, e.g. for static asset errors)

Loading UI during retry delay

When options.html.loading.content is configured, showLoadingUI(attempt) is called before the reload timer fires. It injects the loading HTML into the target element (selector from options.html.fallback.selector, defaulting to body), reveals the data-spa-guard-section="retrying" element, fills attempt numbers into [data-spa-guard-content="attempt"] elements, applies i18n via applyI18n/getI18n, and optionally hides or replaces the spinner based on options.html.spinner. If loading content is not configured or the target element is not found, showLoadingUI returns silently — the retry still proceeds normally.

Default fallback/loading templates are theme-aware: they set color-scheme: light dark and apply neutral dark colors automatically via @media (prefers-color-scheme: dark).

Unhandledrejection serialization

serializeError handles PromiseRejectionEvent with strict redaction guardrails. For HTTP-like errors it extracts only safe metadata: status, statusText, url, method, response.type, and X-Request-ID from response headers when present. Response body, request/response payload, and the full headers object are never included in serialized output. For request wrappers (reason.request / reason.config) only method, url, and baseURL are extracted. Deep object traversal is bounded by MAX_DEPTH=4, MAX_KEYS=20, and MAX_STRING_LEN=500 to prevent oversized beacons. Circular references are handled via a WeakSet visited tracker. The output also includes isTrusted, timeStamp from the event, and runtime context (pageUrl, constructorName). This HTTP-like extraction also applies to Error subclasses that carry a response field (for example ResponseError), not only to plain objects.

Retry reset

If enough time has passed since the last reload (configurable via minTimeBetweenResets, default 5000 ms), the orchestrator resets the attempt counter and starts a fresh retry cycle instead of continuing to fallback. This prevents stale URL params from triggering fallback on a clean page load.

Healthy boot

After a successful app boot following a retry reload, markRetryHealthyBoot() clears retry URL params, cancels any pending timer, and resets orchestrator state.

Default behavior in recommendedSetup: auto healthy-boot after a grace period (healthyBoot: "auto"). Manual override: set healthyBoot: "manual" and call markRetryHealthyBoot() yourself once critical boot is confirmed.

Retry on unhandled rejections

By default, regular unhandledrejection events only send a beacon (handleUnhandledRejections.retry: false). To also trigger triggerRetry() on non-chunk rejections, enable it explicitly — but only if you're confident these rejections indicate stale chunks:

window.__SPA_GUARD_OPTIONS__ = {
handleUnhandledRejections: {
retry: true,
},
};

Note: enabling retry for generic rejections can cause reload loops if your app has non-chunk unhandled rejections unrelated to stale assets.

Static asset burst coalescing

Multiple 404'd asset errors within a short window (default 500 ms) are coalesced into a single triggerRetry({ cacheBust: true, source: "static-asset-error" }). The orchestrator's deduplication ensures only one reload is scheduled.

Retry ownership rule

Only retryOrchestrator.ts may schedule reloads, advance retry state, or transition to fallback. Listeners, renderers, and other modules must not schedule their own retry timers or set fallback state directly.

API

@ovineko/spa-guard (common)

  • listen — subscribe to spa-guard events
  • events — event type constants
  • options — runtime options helpers
  • disableDefaultRetry / enableDefaultRetry / isDefaultRetryEnabled — control default retry behaviour
  • isInFallbackMode — returns true when fallback UI is active (retries exhausted)
  • resetFallbackMode — clears the fallback flag; use in tests or programmatic recovery flows
  • BeaconError — error class for beacon failures
  • ForceRetryError — error class to force a retry

Retry orchestrator (single owner of retry lifecycle):

  • triggerRetry(input?) — trigger a retry from any source; returns TriggerResult:
    • { status: "accepted" } — reload scheduled
    • { status: "deduped", reason: string } — ignored because another retry is already scheduled or an internal error occurred
    • { status: "fallback" } — already in fallback mode; no retry scheduled
    • { status: "retry-disabled" } — retry is disabled via disableDefaultRetry()
  • markRetryHealthyBoot() — call after a successful boot following a retry reload; clears URL params, cancels timers, resets orchestrator state and fallback flag
  • getRetrySnapshot() — returns current orchestrator state: { phase, attempt, retryId, lastSource, lastTriggerTime }
  • resetRetryOrchestratorForTests() — resets all orchestrator state including fallback flag; use in test teardown

@ovineko/spa-guard/runtime

  • recommendedSetup(options?) — enable recommended runtime features; idempotent and returns cleanup function
  • RecommendedSetupOptions{ versionCheck?: boolean; healthyBoot?: "auto" | "manual" | "off" | false | { mode?: "auto"; graceMs?: number } }
    • healthyBoot.graceMs acts as a lower bound override for auto mode (max(computedGraceMs, graceMs))
  • startVersionCheck / stopVersionCheck — manual version check control
  • getState / subscribeToState — runtime state access
  • SpaGuardState — state type
  • showSpinner / dismissSpinner / getSpinnerHtml — spinner helpers
  • setTranslations — override i18n strings
  • ForceRetryError

@ovineko/spa-guard/schema

Schemas for spa-guard configuration.

@ovineko/spa-guard/i18n

Built-in translation strings.

@ovineko/spa-guard/runtime/debug

Debug helpers for testing error scenarios. Use createDebugger() to mount an in-page panel with buttons for each scenario.

Available error scenarios:

  • dispatchChunkLoadError — unhandled ChunkLoadError rejection
  • dispatchNetworkTimeout — unhandled network timeout after a delay
  • dispatchSyncRuntimeError — sync error thrown during React render
  • dispatchAsyncRuntimeError — uncaught error via setTimeout
  • dispatchFinallyError — unhandled rejection from Promise.finally
  • dispatchForceRetryError — ForceRetryError to exercise the force-retry path
  • dispatchUnhandledRejection — generic unhandled promise rejection
  • dispatchRetryExhausted — renders fallback UI directly (bypasses orchestrator; orchestrator phase remains idle)
  • dispatchStaticAsset404 — appends a hashed <script> that 404s, exercising the Resource Timing API-based static asset detection path