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.

Resource failures produce resource-error beacons for code resources by default: scripts, stylesheets, module preloads, and code-bearing preloads or prefetches. Font, image, icon, and other non-code failures are excluded. Worker and document preload destinations count as code. When a link's kind is not recognized, its URL extension is used (.js, .mjs, .cjs, and .css are treated as code). Set resources.report to "all" to report every resource element or "none" to disable resource beacons.

Beacon deduplication and a 20-report per-page cap are enabled by default. dedupeWindowMs controls how long a report with the same stable identifying fields is suppressed; the default is five minutes. Serialized diagnostics are not part of the dedupe key because they can contain volatile event data such as timestamps. The retained dedupe keys are bounded. Set dedupe to false to disable deduplication. maxPerPageLoad accepts non-negative values; 0 or Infinity disables the cap, while a negative value uses the default safety cap of 20. When the cap is reached, one beacon-throttled notice is sent without a stale interim total. A final report with the total suppressed during the page load is attempted on pagehide, with visibilitychange as a fallback. Exhausted-retry reports bypass this cap.

Known browser noise​

Known browser-noise filtering is enabled by default. Set errors.ignoreKnownNoise to false to disable it. User-provided errors.ignore patterns are added to the built-in list rather than replacing it. The list is exported as KNOWN_BROWSER_NOISE. It contains only the ResizeObserver loop messages and non-error rejection noise. Cross-origin "Script error." messages are intentionally not in that default list; add the exported CROSS_ORIGIN_ERROR pattern to errors.ignore only when that signal is unwanted.

Retry source and resource status​

The exhausted-retry beacon retains the historical eventName: "chunk_error_max_reloads". Its errorType identifies the retry source, and the same value is included in serialized.

Resource failure beacons populate httpStatus from Resource Timing when the browser provides it. The browser-reported value can be 0 when an HTTP status is unavailable or opaque, so it should not be interpreted as a specific HTTP response. When the API or status field is unavailable, httpStatus is omitted; the context records a no-byte signal when that signal is available.

Before transport, all beacon strings are capped to the limits enforced by parseBeacon, and non-finite numeric fields are omitted. This prevents an otherwise valid report from being rejected as a whole because one client-derived field is too long or invalid.

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
  • stripForceRetryMagic(message) — remove the internal force-retry marker before rendering or persisting an error message
  • isCodeResource(target) — classify a script or link element for resources.report: "code"
  • resetBeaconThrottle() — clear dedupe/cap state and lifecycle listeners; intended for tests or an explicit host-managed page lifecycle
  • KNOWN_BROWSER_NOISE / CROSS_ORIGIN_ERROR — reusable error-message filter patterns

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
    • { status: "fallback" } — already in fallback mode; no retry scheduled
    • { status: "internal-error" } — retry setup failed and the orchestrator returned to idle
    • { 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 }

@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 — emits exhaustion and uses the debug transition to render fallback UI with orchestrator phase fallback
  • dispatchStaticAsset404 — appends a hashed <script> that 404s, exercising the Resource Timing API-based static asset detection path