@ovineko/spa-guard 0.0.1-alpha-2 → 0.0.1-alpha-4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +933 -157
  2. package/dist/{chunk-KHXP4ZNT.js → chunk-2DFYUVHH.js} +12 -1
  3. package/dist/chunk-6XKIZF5S.js +271 -0
  4. package/dist/chunk-DPOQIK4J.js +87 -0
  5. package/dist/{chunk-66I6YML2.js → chunk-EDRTFPCN.js} +8 -1
  6. package/dist/chunk-FAFKLSME.js +214 -0
  7. package/dist/chunk-HUAI4DRW.js +113 -0
  8. package/dist/chunk-HVBPX75C.js +99 -0
  9. package/dist/{chunk-Z3Y4C3HZ.js → chunk-T5RWH2HR.js} +93 -6
  10. package/dist/chunk-UMNFZ7IW.js +170 -0
  11. package/dist/common/DefaultErrorFallback.d.ts +17 -0
  12. package/dist/common/checkVersion.d.ts +5 -0
  13. package/dist/common/constants.d.ts +3 -0
  14. package/dist/common/events/internal.d.ts +11 -2
  15. package/dist/common/events/types.d.ts +46 -1
  16. package/dist/common/fallbackHtml.generated.d.ts +2 -1
  17. package/dist/common/handleErrorWithSpaGuard.d.ts +13 -0
  18. package/dist/common/index.d.ts +1 -0
  19. package/dist/common/index.js +183 -357
  20. package/dist/common/listen/internal.d.ts +2 -1
  21. package/dist/common/logger.d.ts +33 -0
  22. package/dist/common/options.d.ts +79 -10
  23. package/dist/common/reload.d.ts +2 -0
  24. package/dist/common/retryImport.d.ts +43 -0
  25. package/dist/common/retryState.d.ts +2 -0
  26. package/dist/common/shouldIgnore.d.ts +6 -2
  27. package/dist/eslint/index.d.ts +19 -0
  28. package/dist/eslint/index.js +152 -0
  29. package/dist/eslint/rules/no-direct-error-boundary.d.ts +3 -0
  30. package/dist/eslint/rules/no-direct-lazy.d.ts +3 -0
  31. package/dist/fastify/index.js +32 -25
  32. package/dist/react/DebugSyncErrorTrigger.d.ts +13 -0
  33. package/dist/react/index.d.ts +5 -0
  34. package/dist/react/index.js +16 -24
  35. package/dist/react/lazyWithRetry.d.ts +34 -0
  36. package/dist/react/types.d.ts +42 -0
  37. package/dist/react/useSPAGuardChunkError.d.ts +2 -0
  38. package/dist/react/useSPAGuardEvents.d.ts +2 -0
  39. package/dist/react-error-boundary/index.d.ts +31 -1
  40. package/dist/react-error-boundary/index.js +90 -1
  41. package/dist/react-router/index.d.ts +25 -1
  42. package/dist/react-router/index.js +60 -3
  43. package/dist/runtime/debug/errorDispatchers.d.ts +38 -0
  44. package/dist/runtime/debug/index.d.ts +12 -0
  45. package/dist/runtime/debug/index.js +273 -0
  46. package/dist/runtime/index.d.ts +3 -0
  47. package/dist/runtime/index.js +12 -3
  48. package/dist/runtime/recommendedSetup.d.ts +12 -0
  49. package/dist/vite-plugin/index.js +18 -9
  50. package/dist-inline/index.js +1 -1
  51. package/dist-inline-trace/index.js +1 -1
  52. package/package.json +16 -4
  53. package/dist/chunk-FQCDQYOP.js +0 -49
  54. package/dist/chunk-W65YKSMF.js +0 -6
package/README.md CHANGED
@@ -10,6 +10,8 @@ pnpm add @ovineko/spa-guard
10
10
 
11
11
  Peer dependencies vary by integration - see sections below for specific requirements.
12
12
 
13
+ > **Alpha software:** This package is in active development (`0.0.1-alpha`). The public API may change between versions without migration guides. This README always reflects the current state.
14
+
13
15
  ## Features
14
16
 
15
17
  - ✅ **Automatic chunk load error detection** - Handles `vite:preloadError`, dynamic imports, and chunk failures across Chrome, Firefox, and Safari
@@ -19,10 +21,10 @@ Peer dependencies vary by integration - see sections below for specific requirem
19
21
  - ✅ **Infinite loop protection** - Prevents rapid retry resets with configurable minimum time between resets
20
22
  - ✅ **Graceful fallback UI** - Shows user-friendly error screen after all retry attempts are exhausted
21
23
  - ✅ **Configurable injection target** - Inject fallback UI into any element via CSS selector (default: `body`)
22
- - ✅ **Error filtering** - Filter out specific errors from logging and reporting via `ignoredErrors` option
24
+ - ✅ **Error filtering** - Filter out specific errors via `errors.ignore`, or force retry/reload via `errors.forceRetry`
23
25
  - ✅ **Deep error serialization** - Captures detailed error information for server-side analysis
24
26
  - ✅ **Smart beacon reporting** - Sends error reports only after retry exhaustion to prevent spam
25
- - ✅ **Dual build system** - Production minified (~5.9 KB) and trace minified (~7.3 KB) builds for different environments
27
+ - ✅ **Dual build system** - Production minified (~8.3 KB) and trace minified (~13.3 KB) builds for different environments
26
28
  - ✅ **Global error listeners** - Captures `error`, `unhandledrejection`, and `securitypolicyviolation` events
27
29
  - ✅ **Vite plugin for inline script injection** - Runs before all chunks to catch early errors
28
30
  - ✅ **HTML minification** - Automatically minifies fallback HTML to reduce bundle size
@@ -30,6 +32,11 @@ Peer dependencies vary by integration - see sections below for specific requirem
30
32
  - ✅ **React Router v7 integration** - Works seamlessly with React Router error boundaries
31
33
  - ✅ **TypeScript support** - Full type definitions with all exports
32
34
  - ✅ **Framework-agnostic core** - Works with or without React
35
+ - ✅ **lazyWithRetry** - Drop-in React.lazy replacement with automatic module-level retry before page reload
36
+ - ✅ **Version checker** - Proactive new-deployment detection via HTML or JSON polling
37
+ - ✅ **Event hooks** - React hooks for subscribing to spa-guard events (`useSPAGuardEvents`, `useSPAGuardChunkError`)
38
+ - ✅ **Retry control** - Programmatic control over default retry behavior (`disableDefaultRetry`, `enableDefaultRetry`)
39
+ - ✅ **ESLint plugin** - Enforces usage of spa-guard wrappers instead of direct React imports
33
40
 
34
41
  ## Quick Start
35
42
 
@@ -46,22 +53,27 @@ export default defineConfig({
46
53
  spaGuardVitePlugin({
47
54
  // Production configuration
48
55
  reloadDelays: [1000, 2000, 5000], // 3 attempts with increasing delays
49
- fallback: {
50
- html: `
51
- <div style="display:flex;align-items:center;justify-content:center;height:100vh;font-family:sans-serif">
52
- <div style="text-align:center">
53
- <h1>Something went wrong</h1>
54
- <p>Please refresh the page to continue.</p>
55
- <button onclick="location.reload()">Refresh Page</button>
56
+ html: {
57
+ fallback: {
58
+ content: `
59
+ <div style="display:flex;align-items:center;justify-content:center;height:100vh;font-family:sans-serif">
60
+ <div style="text-align:center">
61
+ <h1>Something went wrong</h1>
62
+ <p>Please refresh the page to continue.</p>
63
+ <button onclick="location.reload()">Refresh Page</button>
64
+ </div>
56
65
  </div>
57
- </div>
58
- `,
59
- selector: "body", // CSS selector where to inject fallback UI (default: "body")
66
+ `,
67
+ selector: "body", // CSS selector where to inject fallback UI (default: "body")
68
+ },
60
69
  },
61
70
  reportBeacon: {
62
71
  endpoint: "/api/beacon",
63
72
  },
64
- ignoredErrors: [], // Filter out specific error messages from reporting
73
+ errors: {
74
+ ignore: [], // Filter out specific error messages from reporting
75
+ forceRetry: [], // Custom error messages that trigger retry/reload (like chunk errors)
76
+ },
65
77
  useRetryId: true, // Use query parameters for cache busting (default: true)
66
78
  }),
67
79
  react(),
@@ -76,7 +88,7 @@ export default defineConfig({
76
88
  export default defineConfig({
77
89
  plugins: [
78
90
  spaGuardVitePlugin({
79
- trace: true, // Enable verbose logging (10KB instead of 5KB)
91
+ trace: true, // Enable verbose logging (~13KB instead of ~8KB)
80
92
  reloadDelays: [1000, 2000],
81
93
  reportBeacon: { endpoint: "/api/beacon" },
82
94
  }),
@@ -85,6 +97,39 @@ export default defineConfig({
85
97
  });
86
98
  ```
87
99
 
100
+ ### Testing
101
+
102
+ Console logs are suppressed by default during tests to keep the output clean and readable. This makes it easier to identify which tests passed or failed.
103
+
104
+ To run tests with console output visible (useful for debugging):
105
+
106
+ ```bash
107
+ # Run tests with console logs enabled
108
+ DEBUG=true pnpm test
109
+
110
+ # Or use the convenience script
111
+ pnpm test:debug
112
+
113
+ # Watch mode with console logs
114
+ pnpm test:debug:watch
115
+ ```
116
+
117
+ Other test commands:
118
+
119
+ ```bash
120
+ # Run tests (clean output, no console logs)
121
+ pnpm test
122
+
123
+ # Watch mode (clean output)
124
+ pnpm test:watch
125
+
126
+ # Coverage report
127
+ pnpm test:coverage
128
+
129
+ # Interactive UI
130
+ pnpm test:ui
131
+ ```
132
+
88
133
  ### Fastify Server
89
134
 
90
135
  ```typescript
@@ -96,7 +141,7 @@ const app = Fastify();
96
141
 
97
142
  app.register(fastifySPAGuard, {
98
143
  path: "/api/beacon",
99
- onBeacon: async (beacon, request) => {
144
+ onBeacon: async (beacon, request, reply) => {
100
145
  // Log to Sentry, DataDog, or your monitoring service
101
146
  request.log.error(beacon, "Client error received");
102
147
 
@@ -207,18 +252,23 @@ spaGuardVitePlugin({
207
252
  minTimeBetweenResets: 5000, // Min time between retry resets in ms (default: 5000)
208
253
 
209
254
  // Fallback UI configuration
210
- fallback: {
211
- html: `
212
- <div style="...">
213
- <h1>Something went wrong</h1>
214
- <button onclick="location.reload()">Refresh</button>
215
- </div>
216
- `,
217
- selector: "body", // CSS selector for injection target (default: "body")
255
+ html: {
256
+ fallback: {
257
+ content: `
258
+ <div style="...">
259
+ <h1>Something went wrong</h1>
260
+ <button onclick="location.reload()">Refresh</button>
261
+ </div>
262
+ `,
263
+ selector: "body", // CSS selector for injection target (default: "body")
264
+ },
218
265
  },
219
266
 
220
- // Error filtering
221
- ignoredErrors: [], // Array of error message substrings to ignore
267
+ // Error filtering and retry
268
+ errors: {
269
+ ignore: [], // Array of error message substrings to ignore
270
+ forceRetry: [], // Array of error message substrings that trigger retry/reload
271
+ },
222
272
 
223
273
  // Beacon reporting
224
274
  reportBeacon: {
@@ -226,7 +276,7 @@ spaGuardVitePlugin({
226
276
  },
227
277
 
228
278
  // Build mode
229
- trace: false, // Set to true for verbose debug build (10KB vs 5KB)
279
+ trace: false, // Set to true for verbose debug build (~13KB vs ~8KB)
230
280
  });
231
281
  ```
232
282
 
@@ -247,16 +297,38 @@ interface Options {
247
297
  enableRetryReset?: boolean; // Auto-reset retry cycle when enough time passes (default: true)
248
298
  minTimeBetweenResets?: number; // Min time between retry resets to prevent loops (default: 5000)
249
299
 
250
- fallback?: {
251
- html?: string; // Custom error UI HTML (default: basic error screen)
252
- selector?: string; // CSS selector for injection target (default: "body")
300
+ version?: string; // App version (auto-generated UUID if not specified)
301
+
302
+ checkVersion?: {
303
+ mode?: "html" | "json"; // Detection mode (default: "html")
304
+ interval?: number; // Polling interval in ms (default: 300000)
305
+ endpoint?: string; // JSON endpoint URL (required for "json" mode)
306
+ onUpdate?: "reload" | "event"; // Behavior on version change (default: "reload")
307
+ };
308
+
309
+ errors?: {
310
+ ignore?: string[]; // Error message substrings to filter out (default: [])
311
+ forceRetry?: string[]; // Error message substrings that trigger retry/reload (default: [])
253
312
  };
254
313
 
255
- ignoredErrors?: string[]; // Error message substrings to filter out (default: [])
314
+ html?: {
315
+ fallback?: {
316
+ content?: string; // Custom error UI HTML (default: minimal error screen)
317
+ selector?: string; // CSS selector for injection target (default: "body")
318
+ };
319
+ loading?: {
320
+ content?: string; // Custom loading/retrying UI HTML (default: minimal loading screen)
321
+ };
322
+ };
256
323
 
257
324
  reportBeacon?: {
258
325
  endpoint?: string; // Server endpoint for beacon reports
259
326
  };
327
+
328
+ lazyRetry?: {
329
+ retryDelays?: number[]; // Delays in ms for module-level retries (default: [1000, 2000])
330
+ callReloadOnFailure?: boolean; // Trigger page reload after all retries fail (default: true)
331
+ };
260
332
  }
261
333
 
262
334
  interface VitePluginOptions extends Options {
@@ -272,19 +344,24 @@ interface VitePluginOptions extends Options {
272
344
  useRetryId: true,
273
345
  enableRetryReset: true,
274
346
  minTimeBetweenResets: 5000,
275
- fallback: {
276
- html: `
277
- <div style="display:flex;align-items:center;justify-content:center;height:100vh;font-family:sans-serif">
278
- <div style="text-align:center">
279
- <h1>Something went wrong</h1>
280
- <p>Please refresh the page to continue.</p>
281
- <button onclick="location.reload()">Refresh Page</button>
282
- </div>
283
- </div>
284
- `,
285
- selector: "body",
347
+ checkVersion: {
348
+ mode: "html",
349
+ interval: 300_000,
350
+ onUpdate: "reload",
351
+ },
352
+ errors: {
353
+ ignore: [],
354
+ forceRetry: [],
355
+ },
356
+ html: {
357
+ fallback: {
358
+ content: defaultErrorFallbackHtml, // minimal error screen (auto-generated)
359
+ selector: "body",
360
+ },
361
+ loading: {
362
+ content: defaultLoadingFallbackHtml, // minimal loading screen (auto-generated)
363
+ },
286
364
  },
287
- ignoredErrors: [],
288
365
  }
289
366
  ```
290
367
 
@@ -299,7 +376,7 @@ app.register(fastifySPAGuard, {
299
376
  path: "/api/beacon",
300
377
 
301
378
  // Custom beacon handler
302
- onBeacon: async (beacon, request) => {
379
+ onBeacon: async (beacon, request, reply) => {
303
380
  const error = new Error(beacon.errorMessage || "Unknown client error");
304
381
 
305
382
  // Log structured data
@@ -318,7 +395,7 @@ app.register(fastifySPAGuard, {
318
395
  },
319
396
 
320
397
  // Handle unknown/invalid beacon formats
321
- onUnknownBeacon: async (body, request) => {
398
+ onUnknownBeacon: async (body, request, reply) => {
322
399
  request.log.warn({ body }, "Received unknown beacon format");
323
400
  },
324
401
  });
@@ -331,6 +408,8 @@ interface BeaconSchema {
331
408
  errorMessage?: string; // Error message
332
409
  eventMessage?: string; // Event-specific message
333
410
  eventName?: string; // Event type (e.g., 'chunk_error_max_reloads', 'error', 'unhandledrejection')
411
+ retryAttempt?: number; // Current retry attempt at time of beacon
412
+ retryId?: string; // ID of the retry cycle
334
413
  serialized?: string; // Serialized error details (JSON string)
335
414
  }
336
415
  ```
@@ -340,22 +419,47 @@ interface BeaconSchema {
340
419
  - `chunk_error_max_reloads` - All reload attempts exhausted for chunk error
341
420
  - `error` - Non-chunk global error
342
421
  - `unhandledrejection` - Non-chunk promise rejection
343
- - `uncaughtException` - Uncaught exception
344
422
  - `securitypolicyviolation` - CSP violation
345
423
 
346
- ### React Router Integration
424
+ ### React Integration
425
+
426
+ spa-guard provides two React error boundary components with automatic chunk error recovery.
347
427
 
348
- spa-guard works seamlessly with React Router v7 error boundaries:
428
+ #### ErrorBoundary (Class-based)
429
+
430
+ Standard React error boundary with spa-guard integration:
431
+
432
+ ```tsx
433
+ import { ErrorBoundary } from "@ovineko/spa-guard/react-error-boundary";
434
+
435
+ function App() {
436
+ return (
437
+ <ErrorBoundary
438
+ autoRetryChunkErrors={true}
439
+ sendBeaconOnError={true}
440
+ onError={(error, errorInfo) => {
441
+ console.error("Error caught:", error, errorInfo);
442
+ }}
443
+ >
444
+ <YourApp />
445
+ </ErrorBoundary>
446
+ );
447
+ }
448
+ ```
449
+
450
+ #### ErrorBoundaryReactRouter (React Router)
451
+
452
+ For React Router v7 route-level error handling:
349
453
 
350
454
  ```tsx
351
455
  import { createBrowserRouter, RouterProvider } from "react-router";
352
- import { ErrorBoundaryReloadPage } from "@ovineko/react-error-boundary";
456
+ import { ErrorBoundaryReactRouter } from "@ovineko/spa-guard/react-router";
353
457
 
354
458
  const router = createBrowserRouter([
355
459
  {
356
460
  path: "/",
357
461
  element: <App />,
358
- errorElement: <ErrorBoundaryReloadPage />,
462
+ errorElement: <ErrorBoundaryReactRouter />,
359
463
  children: [
360
464
  {
361
465
  path: "users/:id",
@@ -370,21 +474,365 @@ function Root() {
370
474
  }
371
475
  ```
372
476
 
477
+ #### DebugSyncErrorTrigger
478
+
479
+ A React component that bridges the vanilla debug panel's "Sync Runtime Error" button with React Error Boundaries. It listens for a CustomEvent dispatched by the debug panel, stores the error in state, and throws it during render so that a parent Error Boundary catches it.
480
+
481
+ Place this component inside your ErrorBoundary:
482
+
483
+ ```tsx
484
+ import { ErrorBoundary } from "@ovineko/spa-guard/react-error-boundary";
485
+ import { DebugSyncErrorTrigger } from "@ovineko/spa-guard/react";
486
+
487
+ function App() {
488
+ return (
489
+ <ErrorBoundary>
490
+ <DebugSyncErrorTrigger />
491
+ <YourApp />
492
+ </ErrorBoundary>
493
+ );
494
+ }
495
+ ```
496
+
497
+ This component renders nothing under normal conditions. It only throws when triggered by the debug panel's "Sync Runtime Error" button.
498
+
499
+ **Props:**
500
+
501
+ ```typescript
502
+ interface ErrorBoundaryProps {
503
+ autoRetryChunkErrors?: boolean; // Auto-reload on chunk errors (default: true)
504
+ sendBeaconOnError?: boolean; // Send error reports to server (default: true)
505
+ fallback?: ((props: FallbackProps) => React.ReactElement) | React.ComponentType<FallbackProps>; // Custom fallback component or render function
506
+ fallbackRender?: (props: FallbackProps) => React.ReactElement; // Render prop alternative
507
+ onError?: (error: Error, errorInfo: React.ErrorInfo) => void; // Error callback
508
+ resetKeys?: Array<unknown>; // Keys that trigger error reset when changed
509
+ children: ReactNode;
510
+ }
511
+ ```
512
+
513
+ **Custom Fallback UI:**
514
+
515
+ ```tsx
516
+ import { ErrorBoundary, type FallbackProps } from "@ovineko/spa-guard/react-error-boundary";
517
+
518
+ const CustomFallback = ({
519
+ error,
520
+ errorInfo,
521
+ resetError,
522
+ isChunkError,
523
+ isRetrying,
524
+ spaGuardState,
525
+ }: FallbackProps) => (
526
+ <div>
527
+ <h1>{isChunkError ? "Failed to load" : "Something went wrong"}</h1>
528
+ <p>{error.message}</p>
529
+ {isRetrying ? <p>Retrying...</p> : <button onClick={resetError}>Try Again</button>}
530
+ </div>
531
+ );
532
+
533
+ function App() {
534
+ return (
535
+ <ErrorBoundary fallback={CustomFallback}>
536
+ <YourApp />
537
+ </ErrorBoundary>
538
+ );
539
+ }
540
+ ```
541
+
542
+ **Default Fallback Component:**
543
+
544
+ Both error boundaries use `DefaultErrorFallback` by default, which uses two minimal HTML templates:
545
+
546
+ - **Error state:** Heading, message paragraph, "Try again" button (non-chunk errors), "Reload page" button, error ID. No colors, no custom fonts, no animations - plain default browser styling.
547
+ - **Loading/retrying state:** Centered "Loading..." text with retry attempt counter. No spinner, no animation.
548
+
549
+ These templates are auto-generated as TypeScript string constants (`defaultErrorFallbackHtml` and `defaultLoadingFallbackHtml`) and can be imported for use in custom integrations.
550
+
373
551
  **Error flow:**
374
552
 
375
553
  1. Chunk load error occurs (e.g., after deployment)
376
554
  2. spa-guard inline script detects error
377
555
  3. Attempts automatic reload with query parameters (up to `reloadDelays.length` times)
378
- 4. If all reloads fail, sends beacon to server and shows fallback UI
379
- 5. React Router error boundary catches error (if no fallback UI configured)
380
- 6. `ErrorBoundaryReloadPage` displays fallback UI
556
+ 4. React error boundary catches error and shows loading spinner during retries
557
+ 5. If all reloads fail, sends beacon to server and shows error UI
558
+ 6. User can manually retry or reload the page
559
+
560
+ ### React Hooks
561
+
562
+ spa-guard provides React hooks to access retry state in your components.
563
+
564
+ #### useSpaGuardState()
565
+
566
+ Subscribe to spa-guard state changes:
567
+
568
+ ```tsx
569
+ import { useSpaGuardState } from "@ovineko/spa-guard/react";
570
+
571
+ function RetryIndicator() {
572
+ const state = useSpaGuardState();
573
+
574
+ if (!state.isWaiting) {
575
+ return null;
576
+ }
577
+
578
+ return <div className="retry-banner">Retrying... (Attempt {state.currentAttempt})</div>;
579
+ }
580
+ ```
581
+
582
+ **State properties:**
583
+
584
+ ```typescript
585
+ interface SpaGuardState {
586
+ currentAttempt: number; // Current retry attempt (0-based)
587
+ isFallbackShown: boolean; // Whether fallback UI is displayed
588
+ isWaiting: boolean; // Whether waiting for a retry
589
+ lastRetryResetTime?: number; // Timestamp of last retry reset (undefined if no reset has occurred)
590
+ lastResetRetryId?: string; // Retry ID of last reset cycle (undefined if no reset has occurred)
591
+ }
592
+ ```
593
+
594
+ **Example - Global retry indicator:**
595
+
596
+ ```tsx
597
+ import { useSpaGuardState } from "@ovineko/spa-guard/react";
598
+
599
+ function App() {
600
+ const spaGuardState = useSpaGuardState();
601
+
602
+ return (
603
+ <>
604
+ {spaGuardState.isWaiting && (
605
+ <div className="fixed top-0 left-0 right-0 bg-yellow-500 text-white p-4 text-center">
606
+ Loading updated version... (Attempt {spaGuardState.currentAttempt})
607
+ </div>
608
+ )}
609
+ <YourApp />
610
+ </>
611
+ );
612
+ }
613
+ ```
614
+
615
+ #### useSPAGuardEvents(callback)
616
+
617
+ Subscribe to all spa-guard events (chunk errors, retries, fallback UI, etc.):
618
+
619
+ ```tsx
620
+ import { useSPAGuardEvents } from "@ovineko/spa-guard/react";
621
+
622
+ function EventLogger() {
623
+ useSPAGuardEvents((event) => {
624
+ if (event.name === "chunk-error") {
625
+ console.log("Chunk error:", event.error, "retrying:", event.isRetrying);
626
+ }
627
+ if (event.name === "retry-attempt") {
628
+ console.log(`Retry ${event.attempt} (delay: ${event.delay}ms)`);
629
+ }
630
+ });
631
+
632
+ return null;
633
+ }
634
+ ```
635
+
636
+ The callback ref is kept up-to-date without resubscribing, so you can safely reference component state or props inside the callback.
637
+
638
+ #### useSPAGuardChunkError()
639
+
640
+ Convenience hook that tracks the latest chunk error event:
641
+
642
+ ```tsx
643
+ import { useSPAGuardChunkError } from "@ovineko/spa-guard/react";
644
+
645
+ function ChunkErrorBanner() {
646
+ const chunkError = useSPAGuardChunkError();
647
+
648
+ if (!chunkError) return null;
649
+
650
+ return (
651
+ <div className="error-banner">
652
+ A chunk error occurred. {chunkError.isRetrying ? "Retrying..." : "Please reload the page."}
653
+ </div>
654
+ );
655
+ }
656
+ ```
657
+
658
+ ### Debug Panel
659
+
660
+ spa-guard provides a framework-agnostic `createDebugger()` function for development and testing. It creates a vanilla JS debug panel that lets you simulate error scenarios and observe how spa-guard handles them in real time. Works with any SPA framework.
661
+
662
+ ```typescript
663
+ import { createDebugger } from "@ovineko/spa-guard/runtime/debug";
664
+
665
+ // Create the debug panel - returns a cleanup function
666
+ const destroy = createDebugger();
667
+
668
+ // Later: remove the panel and clean up all subscriptions
669
+ destroy();
670
+ ```
671
+
672
+ **React usage:**
673
+
674
+ ```tsx
675
+ import { useEffect } from "react";
676
+ import { createDebugger } from "@ovineko/spa-guard/runtime/debug";
677
+
678
+ function App() {
679
+ useEffect(() => createDebugger(), []);
680
+
681
+ return <YourApp />;
682
+ }
683
+ ```
684
+
685
+ Since `createDebugger()` returns a cleanup function directly, the arrow shorthand implicitly returns it as the `useEffect` cleanup. No wrapper needed.
686
+
687
+ **Options:**
688
+
689
+ ```typescript
690
+ function createDebugger(options?: {
691
+ position?: "bottom-left" | "bottom-right" | "top-left" | "top-right"; // default: "bottom-right"
692
+ defaultOpen?: boolean; // default: true
693
+ }): () => void;
694
+ ```
695
+
696
+ **Features:**
697
+
698
+ - Framework-agnostic vanilla JS (no React dependency)
699
+ - Fixed-position overlay panel with toggle open/close
700
+ - Error scenario buttons: ChunkLoadError, Network Timeout, Sync Runtime Error, Async Runtime Error, Finally Error
701
+ - Button visual states (default, loading, triggered)
702
+ - Live spa-guard state display (attempt, isWaiting, isFallbackShown)
703
+ - Scrollable event history with timestamps
704
+ - Clear history button
705
+ - Single-instance deduplication (second call returns existing cleanup function with a warning)
706
+ - Errors use fire-and-forget dispatch (reach spa-guard's window event listeners instead of being caught)
707
+
708
+ ### Version Checker
709
+
710
+ spa-guard can proactively detect new deployments by periodically polling for version changes. This helps notify users before chunk errors occur.
711
+
712
+ The version is automatically generated by the Vite plugin using `crypto.randomUUID()` if not explicitly provided. Each build gets a unique version, so version checking works with zero configuration.
713
+
714
+ #### Setup
715
+
716
+ The simplest way to enable version checking is with `recommendedSetup()`:
717
+
718
+ ```tsx
719
+ import { useEffect } from "react";
720
+ import { recommendedSetup } from "@ovineko/spa-guard/runtime";
721
+
722
+ function App() {
723
+ useEffect(() => {
724
+ const cleanup = recommendedSetup();
725
+ return cleanup;
726
+ }, []);
727
+
728
+ return <YourApp />;
729
+ }
730
+ ```
731
+
732
+ For more control, use `startVersionCheck()` directly:
733
+
734
+ ```tsx
735
+ import { useEffect } from "react";
736
+ import { startVersionCheck } from "@ovineko/spa-guard/runtime";
737
+
738
+ function App() {
739
+ useEffect(() => {
740
+ startVersionCheck();
741
+ }, []);
742
+
743
+ useEffect(() => {
744
+ const handleVersionChange = (event: CustomEvent) => {
745
+ const { oldVersion, latestVersion } = event.detail;
746
+ console.log(`New version detected: ${oldVersion} → ${latestVersion}`);
747
+ // Show a toast, banner, etc.
748
+ };
749
+
750
+ window.addEventListener("spa-guard:version-change", handleVersionChange as EventListener);
751
+ return () => {
752
+ window.removeEventListener("spa-guard:version-change", handleVersionChange as EventListener);
753
+ };
754
+ }, []);
755
+
756
+ return <YourApp />;
757
+ }
758
+ ```
759
+
760
+ #### Configuration
761
+
762
+ Configure version checking via the Vite plugin options:
763
+
764
+ ```typescript
765
+ spaGuardVitePlugin({
766
+ // version is auto-generated if not specified
767
+ checkVersion: {
768
+ mode: "html", // "html" (default) or "json"
769
+ interval: 300_000, // polling interval in ms (default: 5min)
770
+ endpoint: "/api/version", // required for "json" mode
771
+ onUpdate: "reload", // "reload" (default) or "event"
772
+ },
773
+ });
774
+ ```
775
+
776
+ **Two modes:**
777
+
778
+ - **HTML mode** (default): Re-fetches the current page and parses the version from the injected `__SPA_GUARD_OPTIONS__`. No extra server endpoint needed.
779
+ - **JSON mode**: Fetches a dedicated JSON endpoint that returns `{ "version": "1.2.3" }`. Lower bandwidth, but requires a server endpoint.
780
+
781
+ **Auto-reload on version change:**
782
+
783
+ By default (`onUpdate: "reload"`), when a new version is detected, spa-guard dispatches a `spa-guard:version-change` CustomEvent and then automatically calls `location.reload()`. Set `onUpdate: "event"` to only dispatch the event without reloading, allowing your app to handle the update notification (e.g., show a banner prompting the user to refresh).
784
+
785
+ ```typescript
786
+ spaGuardVitePlugin({
787
+ checkVersion: {
788
+ onUpdate: "event", // Don't auto-reload, just dispatch the event
789
+ },
790
+ });
791
+ ```
792
+
793
+ #### API
794
+
795
+ - `recommendedSetup(overrides?)` - Enable recommended runtime features (version checking, etc.) with sensible defaults. Returns a cleanup function.
796
+ - `startVersionCheck()` - Start periodic version polling. No-op if no version is configured or already running.
797
+ - `stopVersionCheck()` - Stop version polling and clear the interval.
798
+
799
+ #### Tab Visibility and Focus
800
+
801
+ Version polling automatically pauses when the browser tab is hidden (Page Visibility API) or the window loses focus, and resumes when the tab becomes visible or the window regains focus. When resuming:
802
+
803
+ - If enough time has passed (>= polling interval), a check runs immediately and polling resumes
804
+ - If less time has passed, polling resumes after the remaining interval
805
+
806
+ Additionally, if the tab is hidden or the window is unfocused when `startVersionCheck()` is called, polling is deferred until the tab/window becomes active. Concurrent version checks are deduplicated - overlapping visibility and focus events won't trigger multiple fetches.
807
+
808
+ This is handled transparently - no configuration needed.
809
+
810
+ ### Retry Control
811
+
812
+ spa-guard allows your SPA to take over error handling from the inline script by disabling the default retry behavior:
813
+
814
+ ```typescript
815
+ import { disableDefaultRetry, enableDefaultRetry, isDefaultRetryEnabled } from "@ovineko/spa-guard";
816
+
817
+ // Disable the inline script's automatic page reload on chunk errors
818
+ disableDefaultRetry();
819
+
820
+ // Check current state
821
+ console.log(isDefaultRetryEnabled()); // false
822
+
823
+ // Re-enable if needed
824
+ enableDefaultRetry();
825
+ ```
826
+
827
+ When default retry is disabled, chunk errors will still emit events (via `subscribe` or `useSPAGuardEvents`), but the inline script will not trigger automatic page reloads. Your SPA can then implement custom error UI and retry logic.
381
828
 
382
829
  ### Core API (Framework-agnostic)
383
830
 
384
831
  The core module provides low-level APIs for custom integrations:
385
832
 
386
833
  ```typescript
387
- import { events, listen, options } from "@ovineko/spa-guard";
834
+ import { events, listen, options, disableDefaultRetry } from "@ovineko/spa-guard";
835
+ import { startVersionCheck } from "@ovineko/spa-guard/runtime";
388
836
 
389
837
  // Subscribe to spa-guard events
390
838
  events.subscribe((event) => {
@@ -392,7 +840,7 @@ events.subscribe((event) => {
392
840
  });
393
841
 
394
842
  // Emit custom event
395
- events.emit({ type: "custom", data: "..." });
843
+ events.emitEvent({ name: "chunk-error", error: new Error("test"), isRetrying: false });
396
844
 
397
845
  // Initialize error listeners (automatically called by inline script)
398
846
  listen();
@@ -400,12 +848,18 @@ listen();
400
848
  // Get merged options
401
849
  const opts = options.getOptions();
402
850
  console.log("Reload delays:", opts.reloadDelays);
851
+
852
+ // Start version checking
853
+ startVersionCheck();
854
+
855
+ // Take over retry behavior
856
+ disableDefaultRetry();
403
857
  ```
404
858
 
405
859
  **Event system:**
406
860
 
407
861
  - `events.subscribe(listener)` - Subscribe to all spa-guard events
408
- - `events.emit(event)` - Emit event to all subscribers
862
+ - `events.emitEvent(event)` - Emit event to all subscribers
409
863
  - Uses Symbol-based storage for isolation
410
864
  - Safe for server-side rendering (checks for `globalThis.window` availability)
411
865
 
@@ -448,7 +902,6 @@ const patterns = [
448
902
  /Loading chunk \d+ failed/i, // Webpack
449
903
  /Loading CSS chunk \d+ failed/i, // Webpack CSS
450
904
  /ChunkLoadError/i, // Generic
451
- /Failed to fetch/i, // Network failures
452
905
  ];
453
906
  ```
454
907
 
@@ -528,25 +981,38 @@ export default defineConfig({
528
981
 
529
982
  ### Fastify Plugin
530
983
 
531
- #### `fastifySPAGuard(fastify, options: FastifySPAGuardOptions): Promise<void>`
532
-
533
- Registers a POST endpoint to receive beacon data from clients.
984
+ #### `fastifySPAGuard: FastifyPluginAsync<FastifySPAGuardOptions>`
534
985
 
535
- **Parameters:**
536
-
537
- - `fastify` - Fastify instance
538
- - `options: FastifySPAGuardOptions` - Configuration object
986
+ Fastify plugin that registers a POST endpoint to receive beacon data from clients. Use with `app.register()`.
539
987
 
540
988
  **FastifySPAGuardOptions:**
541
989
 
542
990
  ```typescript
543
991
  interface FastifySPAGuardOptions {
544
992
  path: string; // Route path (e.g., "/api/beacon")
545
- onBeacon?: (beacon: BeaconSchema, request: any) => Promise<void> | void;
546
- onUnknownBeacon?: (body: unknown, request: any) => Promise<void> | void;
993
+ onBeacon?: (
994
+ beacon: BeaconSchema,
995
+ request: FastifyRequest,
996
+ reply: FastifyReply,
997
+ ) => BeaconHandlerResult | Promise<BeaconHandlerResult | void> | void;
998
+ onUnknownBeacon?: (
999
+ body: unknown,
1000
+ request: FastifyRequest,
1001
+ reply: FastifyReply,
1002
+ ) => BeaconHandlerResult | Promise<BeaconHandlerResult | void> | void;
547
1003
  }
548
1004
  ```
549
1005
 
1006
+ **BeaconHandlerResult:**
1007
+
1008
+ ```typescript
1009
+ interface BeaconHandlerResult {
1010
+ skipDefaultLog?: boolean; // If true, skips default logging behavior
1011
+ }
1012
+ ```
1013
+
1014
+ Return `{ skipDefaultLog: true }` from `onBeacon` or `onUnknownBeacon` to suppress the default Fastify request log entry (useful when you handle logging yourself).
1015
+
550
1016
  ### Types
551
1017
 
552
1018
  #### `Options`
@@ -558,16 +1024,38 @@ interface Options {
558
1024
  enableRetryReset?: boolean; // Auto-reset retry cycle when enough time passes (default: true)
559
1025
  minTimeBetweenResets?: number; // Min time between retry resets in ms (default: 5000)
560
1026
 
561
- fallback?: {
562
- html?: string; // Custom error UI HTML
563
- selector?: string; // CSS selector for injection target (default: "body")
1027
+ version?: string; // App version (auto-generated UUID if not specified)
1028
+
1029
+ checkVersion?: {
1030
+ mode?: "html" | "json"; // Detection mode (default: "html")
1031
+ interval?: number; // Polling interval in ms (default: 300000)
1032
+ endpoint?: string; // JSON endpoint URL (required for "json" mode)
1033
+ onUpdate?: "reload" | "event"; // Behavior on version change (default: "reload")
1034
+ };
1035
+
1036
+ errors?: {
1037
+ ignore?: string[]; // Error message substrings to filter out (default: [])
1038
+ forceRetry?: string[]; // Error message substrings that trigger retry/reload (default: [])
564
1039
  };
565
1040
 
566
- ignoredErrors?: string[]; // Error message substrings to filter out (default: [])
1041
+ html?: {
1042
+ fallback?: {
1043
+ content?: string; // Custom error UI HTML
1044
+ selector?: string; // CSS selector for injection target (default: "body")
1045
+ };
1046
+ loading?: {
1047
+ content?: string; // Custom loading/retrying UI HTML
1048
+ };
1049
+ };
567
1050
 
568
1051
  reportBeacon?: {
569
1052
  endpoint?: string; // Error reporting endpoint
570
1053
  };
1054
+
1055
+ lazyRetry?: {
1056
+ retryDelays?: number[]; // Delays in ms for lazy import retries (default: [1000, 2000])
1057
+ callReloadOnFailure?: boolean; // Trigger page reload after all retries fail (default: true)
1058
+ };
571
1059
  }
572
1060
  ```
573
1061
 
@@ -586,6 +1074,8 @@ interface BeaconSchema {
586
1074
  errorMessage?: string;
587
1075
  eventMessage?: string;
588
1076
  eventName?: string;
1077
+ retryAttempt?: number; // Current retry attempt at time of beacon
1078
+ retryId?: string; // ID of the retry cycle
589
1079
  serialized?: string; // JSON stringified error details
590
1080
  }
591
1081
  ```
@@ -595,10 +1085,13 @@ interface BeaconSchema {
595
1085
  From `@ovineko/spa-guard`:
596
1086
 
597
1087
  - `events.subscribe(listener)` - Subscribe to spa-guard events
598
- - `events.emit(event)` - Emit event to subscribers
1088
+ - `events.emitEvent(event)` - Emit event to subscribers
599
1089
  - `listen()` - Initialize error listeners
600
1090
  - `options.getOptions()` - Get merged options from globalThis.window
601
1091
  - `options.optionsWindowKey` - Window storage key constant
1092
+ - `disableDefaultRetry()` - Disable inline script's automatic retry
1093
+ - `enableDefaultRetry()` - Re-enable automatic retry
1094
+ - `isDefaultRetryEnabled()` - Check if default retry is enabled
602
1095
 
603
1096
  ### Schema Exports
604
1097
 
@@ -615,9 +1108,13 @@ From `@ovineko/spa-guard/schema/parse`:
615
1108
 
616
1109
  From `@ovineko/spa-guard/runtime`:
617
1110
 
1111
+ - `recommendedSetup(overrides?)` - Enable recommended runtime features with sensible defaults, returns cleanup function
618
1112
  - `getState()` - Get current spa-guard state (currentAttempt, isFallbackShown, isWaiting, lastRetryResetTime, lastResetRetryId)
619
1113
  - `subscribeToState(callback)` - Subscribe to state changes, returns unsubscribe function
1114
+ - `startVersionCheck()` - Start periodic version polling
1115
+ - `stopVersionCheck()` - Stop version polling
620
1116
  - `SpaGuardState` - TypeScript type for state object
1117
+ - `RecommendedSetupOptions` - TypeScript type for recommendedSetup overrides
621
1118
 
622
1119
  **Example:**
623
1120
 
@@ -641,27 +1138,250 @@ const unsubscribe = subscribeToState((state) => {
641
1138
  unsubscribe();
642
1139
  ```
643
1140
 
644
- ## Module Exports
1141
+ ### lazyWithRetry
1142
+
1143
+ `lazyWithRetry` is a drop-in replacement for `React.lazy` that adds automatic retry logic for dynamic imports. Instead of immediately failing on a chunk load error, it retries the import with configurable delays and only falls back to a full page reload via `attemptReload()` if all retries are exhausted.
1144
+
1145
+ #### Basic Usage
1146
+
1147
+ ```tsx
1148
+ import { lazyWithRetry } from "@ovineko/spa-guard/react";
1149
+ import { Suspense } from "react";
1150
+ import { ErrorBoundary } from "@ovineko/spa-guard/react-error-boundary";
1151
+
1152
+ // Uses global options from window.__SPA_GUARD_OPTIONS__.lazyRetry
1153
+ const LazyHome = lazyWithRetry(() => import("./pages/Home"));
1154
+
1155
+ function App() {
1156
+ return (
1157
+ <ErrorBoundary>
1158
+ <Suspense fallback={<div>Loading...</div>}>
1159
+ <LazyHome />
1160
+ </Suspense>
1161
+ </ErrorBoundary>
1162
+ );
1163
+ }
1164
+ ```
1165
+
1166
+ #### Global Configuration
1167
+
1168
+ Configure default retry behavior via `window.__SPA_GUARD_OPTIONS__`:
1169
+
1170
+ ```typescript
1171
+ window.__SPA_GUARD_OPTIONS__ = {
1172
+ lazyRetry: {
1173
+ retryDelays: [1000, 2000], // 2 retry attempts: 1s then 2s (default)
1174
+ callReloadOnFailure: true, // Fall back to page reload after all retries (default)
1175
+ },
1176
+ };
1177
+ ```
1178
+
1179
+ These global options are used by all `lazyWithRetry` calls unless overridden per-import.
1180
+
1181
+ #### Per-Import Override
1182
+
1183
+ Override global options for individual components:
1184
+
1185
+ ```tsx
1186
+ // More retries for a critical checkout flow
1187
+ const LazyCheckout = lazyWithRetry(
1188
+ () => import("./pages/Checkout"),
1189
+ { retryDelays: [500, 1000, 2000, 4000] }, // 4 attempts
1190
+ );
1191
+
1192
+ // Disable page reload for a non-critical widget
1193
+ const LazyWidget = lazyWithRetry(() => import("./widgets/Optional"), {
1194
+ retryDelays: [1000], // 1 retry attempt
1195
+ callReloadOnFailure: false, // just throw to error boundary, no reload
1196
+ });
1197
+ ```
1198
+
1199
+ Per-import options always take priority over global options.
1200
+
1201
+ #### Cancelling Retries with AbortSignal
1202
+
1203
+ Use an `AbortSignal` to cancel in-progress retries and prevent memory leaks (e.g., when a component unmounts before the import resolves):
1204
+
1205
+ ```tsx
1206
+ const controller = new AbortController();
1207
+
1208
+ const LazyPage = lazyWithRetry(() => import("./pages/Page"), { signal: controller.signal });
1209
+
1210
+ // Cancel retries when no longer needed
1211
+ controller.abort();
1212
+ ```
1213
+
1214
+ Aborting clears any pending `setTimeout` timers immediately and rejects the import promise with an `AbortError`.
1215
+
1216
+ #### Integration with attemptReload
1217
+
1218
+ `lazyWithRetry` integrates with spa-guard's existing page reload logic:
1219
+
1220
+ 1. Component renders inside `<Suspense>`
1221
+ 2. `React.lazy` calls the import function through `retryImport`
1222
+ 3. On import failure, `retryImport` checks if it is a chunk load error
1223
+ 4. If yes: retries with delays from `retryDelays` array, emitting `lazy-retry-attempt` events
1224
+ 5. If all retries fail: emits `lazy-retry-exhausted` event, then calls `attemptReload()` (if `callReloadOnFailure: true`) before throwing to the error boundary
1225
+ 6. `attemptReload()` adds `?spaGuardRetryId=...&spaGuardRetryAttempt=N` to the URL and reloads the page
1226
+
1227
+ This means `lazyWithRetry` provides a two-level retry strategy:
1228
+
1229
+ - Level 1 (new): Retry the individual module import with delays — no page disruption
1230
+ - Level 2 (existing): If all module retries fail, trigger the full page reload cycle
645
1231
 
646
- spa-guard provides 9 export entry points:
1232
+ #### Events
647
1233
 
648
- | Export | Description | Peer Dependencies |
649
- | ------------------------ | -------------------------------------------- | -------------------------------------- |
650
- | `.` | Core functionality (events, listen, options) | None |
651
- | `./schema` | BeaconSchema type definitions | `typebox@^1` |
652
- | `./schema/parse` | Beacon parsing utilities | `typebox@^1` |
653
- | `./runtime` | Runtime state management and subscriptions | None |
654
- | `./react` | React integration (placeholder) | `react@^19` |
655
- | `./react-router` | React Router integration | `react@^19`, `react-router@^7` |
656
- | `./fastify` | Fastify server plugin | None |
657
- | `./vite-plugin` | Vite build plugin | `vite@^7 \|\| ^8` |
658
- | `./react-error-boundary` | Error boundary re-export | `react@^19`, `react-error-boundary@^6` |
1234
+ Subscribe to lazy retry events via the event system:
1235
+
1236
+ ```typescript
1237
+ import { events } from "@ovineko/spa-guard";
1238
+
1239
+ events.subscribe((event) => {
1240
+ if (event.name === "lazy-retry-attempt") {
1241
+ console.log(`Retry ${event.attempt}/${event.totalAttempts} after ${event.delay}ms`);
1242
+ }
1243
+ if (event.name === "lazy-retry-success") {
1244
+ console.log(`Succeeded on attempt ${event.attempt}`);
1245
+ }
1246
+ if (event.name === "lazy-retry-exhausted") {
1247
+ console.log(`All retries exhausted. Will reload: ${event.willReload}`);
1248
+ }
1249
+ });
1250
+ ```
1251
+
1252
+ **Event payload types:**
1253
+
1254
+ ```typescript
1255
+ type LazyRetryAttempt = {
1256
+ name: "lazy-retry-attempt";
1257
+ attempt: number; // current attempt number (1-based)
1258
+ delay: number; // delay in ms before this attempt
1259
+ totalAttempts: number; // total number of retry attempts
1260
+ };
1261
+
1262
+ type LazyRetrySuccess = {
1263
+ name: "lazy-retry-success";
1264
+ attempt: number; // 1-based retry number on which import succeeded (1 = first retry)
1265
+ };
1266
+
1267
+ type LazyRetryExhausted = {
1268
+ name: "lazy-retry-exhausted";
1269
+ totalAttempts: number; // number of attempts made
1270
+ willReload: boolean; // whether attemptReload() will be called
1271
+ };
1272
+ ```
1273
+
1274
+ #### API Reference
1275
+
1276
+ ##### `lazyWithRetry<T>(importFn, options?)`
1277
+
1278
+ Creates a lazy React component with retry logic.
1279
+
1280
+ ```typescript
1281
+ import { lazyWithRetry } from "@ovineko/spa-guard/react";
1282
+ import type { LazyRetryOptions } from "@ovineko/spa-guard/react";
1283
+ ```
1284
+
1285
+ **Parameters:**
1286
+
1287
+ - `importFn: () => Promise<{ default: T }>` - Dynamic import function
1288
+ - `options?: LazyRetryOptions` - Per-import options (override global)
1289
+
1290
+ **Returns:** `LazyExoticComponent<T>` - Same type as `React.lazy()`
1291
+
1292
+ ##### `LazyRetryOptions`
1293
+
1294
+ ```typescript
1295
+ interface LazyRetryOptions {
1296
+ /**
1297
+ * Array of delays in milliseconds for retry attempts.
1298
+ * Each element = one retry attempt with that delay.
1299
+ * Overrides window.__SPA_GUARD_OPTIONS__.lazyRetry.retryDelays.
1300
+ * @default [1000, 2000]
1301
+ */
1302
+ retryDelays?: number[];
1303
+
1304
+ /**
1305
+ * Call attemptReload() after all retries are exhausted.
1306
+ * Overrides window.__SPA_GUARD_OPTIONS__.lazyRetry.callReloadOnFailure.
1307
+ * @default true
1308
+ */
1309
+ callReloadOnFailure?: boolean;
1310
+
1311
+ /**
1312
+ * AbortSignal to cancel in-progress retries and clear pending timers.
1313
+ */
1314
+ signal?: AbortSignal;
1315
+ }
1316
+ ```
1317
+
1318
+ ##### Global `lazyRetry` Options
1319
+
1320
+ ```typescript
1321
+ interface Options {
1322
+ // ... existing options ...
1323
+
1324
+ lazyRetry?: {
1325
+ /**
1326
+ * Array of retry delays in ms for dynamic imports.
1327
+ * @default [1000, 2000]
1328
+ */
1329
+ retryDelays?: number[];
1330
+
1331
+ /**
1332
+ * Call attemptReload() after all lazy import retries fail.
1333
+ * @default true
1334
+ */
1335
+ callReloadOnFailure?: boolean;
1336
+ };
1337
+ }
1338
+ ```
1339
+
1340
+ ## Module Exports
1341
+
1342
+ spa-guard provides 11 export entry points:
1343
+
1344
+ | Export | Description | Peer Dependencies |
1345
+ | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
1346
+ | `.` | Core functionality (events, listen, options, retry control) | None |
1347
+ | `./schema` | BeaconSchema type definitions | `typebox@^1` |
1348
+ | `./schema/parse` | Beacon parsing utilities | `typebox@^1` |
1349
+ | `./runtime` | Runtime state management and subscriptions | None |
1350
+ | `./react` | React hooks and components (useSpaGuardState, useSPAGuardEvents, useSPAGuardChunkError, lazyWithRetry, DebugSyncErrorTrigger) | `react@^19` |
1351
+ | `./runtime/debug` | Debug panel factory (`createDebugger`) - framework-agnostic vanilla JS | None |
1352
+ | `./react-router` | React Router error boundary (ErrorBoundaryReactRouter) | `react@^19`, `react-router@^7` |
1353
+ | `./fastify` | Fastify server plugin | `fastify@^5 \|\| ^4`, `fastify-plugin@^5 \|\| ^4` |
1354
+ | `./vite-plugin` | Vite build plugin | `vite@^8 \|\| ^7` |
1355
+ | `./react-error-boundary` | React error boundary component (ErrorBoundary) | `react@^19` |
1356
+ | `./eslint` | ESLint plugin with `configs.recommended` preset (`no-direct-error-boundary`, `no-direct-lazy`) | `eslint@^9 \|\| ^10` (optional) |
659
1357
 
660
1358
  **Import examples:**
661
1359
 
662
1360
  ```typescript
663
1361
  // Core
664
- import { events, listen } from "@ovineko/spa-guard";
1362
+ import { events, listen, disableDefaultRetry } from "@ovineko/spa-guard";
1363
+
1364
+ // Runtime state + version check
1365
+ import { getState, subscribeToState, startVersionCheck } from "@ovineko/spa-guard/runtime";
1366
+
1367
+ // React hooks and components
1368
+ import {
1369
+ useSpaGuardState,
1370
+ useSPAGuardEvents,
1371
+ useSPAGuardChunkError,
1372
+ DebugSyncErrorTrigger,
1373
+ } from "@ovineko/spa-guard/react";
1374
+
1375
+ // Lazy imports with retry
1376
+ import { lazyWithRetry } from "@ovineko/spa-guard/react";
1377
+ import type { LazyRetryOptions } from "@ovineko/spa-guard/react";
1378
+
1379
+ // Debug panel (framework-agnostic)
1380
+ import { createDebugger } from "@ovineko/spa-guard/runtime/debug";
1381
+
1382
+ // React error boundaries
1383
+ import { ErrorBoundary } from "@ovineko/spa-guard/react-error-boundary";
1384
+ import { ErrorBoundaryReactRouter } from "@ovineko/spa-guard/react-router";
665
1385
 
666
1386
  // Schema
667
1387
  import type { BeaconSchema } from "@ovineko/spa-guard/schema";
@@ -671,28 +1391,40 @@ import { spaGuardVitePlugin } from "@ovineko/spa-guard/vite-plugin";
671
1391
 
672
1392
  // Fastify
673
1393
  import { fastifySPAGuard } from "@ovineko/spa-guard/fastify";
1394
+
1395
+ // ESLint plugin
1396
+ import spaGuardEslint from "@ovineko/spa-guard/eslint";
674
1397
  ```
675
1398
 
676
1399
  ## Build Sizes
677
1400
 
678
- - **Production:** `dist-inline/index.js` ~5.9 KB minified (Terser)
679
- - **Trace:** `dist-inline-trace/index.js` ~7.3 KB minified (Terser)
1401
+ - **Production:** `dist-inline/index.js` ~8.3 KB minified (Terser)
1402
+ - **Trace:** `dist-inline-trace/index.js` ~13.3 KB minified (Terser)
680
1403
  - **Main library:** `dist/` varies by export
681
1404
 
682
1405
  ## Advanced Usage
683
1406
 
684
- ### Disable Query Parameters (PII Concerns)
1407
+ ### Disable Retry ID Query Parameter (PII Concerns)
685
1408
 
686
- If query parameters are problematic for your use case:
1409
+ If the `spaGuardRetryId` query parameter is problematic for your use case (e.g., URL logging policies):
687
1410
 
688
1411
  ```typescript
689
1412
  spaGuardVitePlugin({
690
- useRetryId: false, // Disable query parameters
691
- reloadDelays: [1000, 2000], // Still retries, but without cache busting
1413
+ useRetryId: false, // Disable spaGuardRetryId in URL (default: true)
1414
+ reloadDelays: [1000, 2000], // Still retries, but without cache-busting UUID
692
1415
  });
693
1416
  ```
694
1417
 
695
- **Note:** Without `useRetryId`, retries use `globalThis.window.location.reload()` which may not bypass aggressive HTML cache.
1418
+ **Behavior with `useRetryId: false`:**
1419
+
1420
+ - The `spaGuardRetryAttempt` param is still written to the URL to track retry progress across reloads
1421
+ - Only the `spaGuardRetryId` UUID param is omitted
1422
+ - Retries still work correctly (attempt counter persists in URL)
1423
+ - The attempt param is cleaned up from the URL after retry exhaustion
1424
+ - The "Error ID" line in the default fallback UI is automatically hidden (no retry ID to display)
1425
+ - `enableRetryReset` (smart retry cycle reset) has no effect — retry cycles cannot auto-reset without a persisted retry ID
1426
+
1427
+ **Note:** Without `useRetryId`, the retry URL won't include a unique UUID, so aggressive HTML cache may not be bypassed. However, retry counting still works correctly.
696
1428
 
697
1429
  ### Custom Fallback UI
698
1430
 
@@ -700,53 +1432,55 @@ Provide fully custom HTML for error screen:
700
1432
 
701
1433
  ```typescript
702
1434
  spaGuardVitePlugin({
703
- fallback: {
704
- html: `
705
- <style>
706
- .spa-guard-error {
707
- font-family: system-ui, -apple-system, sans-serif;
708
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
709
- color: white;
710
- display: flex;
711
- align-items: center;
712
- justify-content: center;
713
- height: 100vh;
714
- margin: 0;
715
- padding: 20px;
716
- box-sizing: border-box;
717
- }
718
- .spa-guard-container {
719
- max-width: 500px;
720
- text-align: center;
721
- background: rgba(255, 255, 255, 0.1);
722
- backdrop-filter: blur(10px);
723
- padding: 40px;
724
- border-radius: 20px;
725
- }
726
- .spa-guard-button {
727
- background: white;
728
- color: #667eea;
729
- border: none;
730
- padding: 12px 24px;
731
- font-size: 16px;
732
- font-weight: 600;
733
- border-radius: 8px;
734
- cursor: pointer;
735
- margin-top: 20px;
736
- }
737
- .spa-guard-button:hover {
738
- transform: scale(1.05);
739
- }
740
- </style>
741
- <div class="spa-guard-error">
742
- <div class="spa-guard-container">
743
- <h1>⚠️ Application Error</h1>
744
- <p>We're experiencing technical difficulties. Please try reloading the page.</p>
745
- <button class="spa-guard-button" onclick="location.reload()">Reload Application</button>
1435
+ html: {
1436
+ fallback: {
1437
+ content: `
1438
+ <style>
1439
+ .spa-guard-error {
1440
+ font-family: system-ui, -apple-system, sans-serif;
1441
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
1442
+ color: white;
1443
+ display: flex;
1444
+ align-items: center;
1445
+ justify-content: center;
1446
+ height: 100vh;
1447
+ margin: 0;
1448
+ padding: 20px;
1449
+ box-sizing: border-box;
1450
+ }
1451
+ .spa-guard-container {
1452
+ max-width: 500px;
1453
+ text-align: center;
1454
+ background: rgba(255, 255, 255, 0.1);
1455
+ backdrop-filter: blur(10px);
1456
+ padding: 40px;
1457
+ border-radius: 20px;
1458
+ }
1459
+ .spa-guard-button {
1460
+ background: white;
1461
+ color: #667eea;
1462
+ border: none;
1463
+ padding: 12px 24px;
1464
+ font-size: 16px;
1465
+ font-weight: 600;
1466
+ border-radius: 8px;
1467
+ cursor: pointer;
1468
+ margin-top: 20px;
1469
+ }
1470
+ .spa-guard-button:hover {
1471
+ transform: scale(1.05);
1472
+ }
1473
+ </style>
1474
+ <div class="spa-guard-error">
1475
+ <div class="spa-guard-container">
1476
+ <h1>⚠️ Application Error</h1>
1477
+ <p>We're experiencing technical difficulties. Please try reloading the page.</p>
1478
+ <button class="spa-guard-button" onclick="location.reload()">Reload Application</button>
1479
+ </div>
746
1480
  </div>
747
- </div>
748
- `,
749
- selector: "body", // Inject into document.body
1481
+ `,
1482
+ selector: "body", // Inject into document.body
1483
+ },
750
1484
  },
751
1485
  });
752
1486
  ```
@@ -757,36 +1491,41 @@ Inject fallback UI into a specific element instead of `<body>`:
757
1491
 
758
1492
  ```typescript
759
1493
  spaGuardVitePlugin({
760
- fallback: {
761
- html: `<div>Error occurred. <button onclick="location.reload()">Retry</button></div>`,
762
- selector: "#app", // Inject into <div id="app">
1494
+ html: {
1495
+ fallback: {
1496
+ content: `<div>Error occurred. <button onclick="location.reload()">Retry</button></div>`,
1497
+ selector: "#app", // Inject into <div id="app">
1498
+ },
763
1499
  },
764
1500
  });
765
1501
  ```
766
1502
 
767
- ### Error Filtering
1503
+ ### Error Filtering and Force Retry
768
1504
 
769
- Filter out specific errors from being logged or reported:
1505
+ Filter out specific errors from reporting, or force certain errors to trigger the retry/reload process:
770
1506
 
771
1507
  ```typescript
772
1508
  spaGuardVitePlugin({
773
- ignoredErrors: [
774
- "ResizeObserver loop", // Ignore benign ResizeObserver errors
775
- "Non-Error promise rejection", // Ignore specific promise rejections
776
- "Script error", // Ignore generic script errors from third-party scripts
777
- ],
1509
+ errors: {
1510
+ ignore: [
1511
+ "ResizeObserver loop", // Ignore benign ResizeObserver errors
1512
+ "Non-Error promise rejection", // Ignore specific promise rejections
1513
+ "Script error", // Ignore generic script errors from third-party scripts
1514
+ ],
1515
+ forceRetry: [
1516
+ "STALE_DEPLOYMENT", // Custom errors that should trigger retry like chunk errors
1517
+ "MODULE_NOT_FOUND", // Framework-specific stale module errors
1518
+ ],
1519
+ },
778
1520
  reportBeacon: {
779
1521
  endpoint: "/api/beacon",
780
1522
  },
781
1523
  });
782
1524
  ```
783
1525
 
784
- **How it works:**
1526
+ **`errors.ignore`** - Errors containing any of these substrings will not be logged to console and beacons will not be sent. Case-sensitive substring matching.
785
1527
 
786
- - Errors containing any of the `ignoredErrors` substrings will not be logged to console
787
- - Beacons for filtered errors will not be sent to the server
788
- - Useful for filtering out known benign errors or third-party script noise
789
- - Case-sensitive substring matching
1528
+ **`errors.forceRetry`** - Errors containing any of these substrings will trigger the same retry/reload process as chunk load errors (calls `attemptReload()`). Useful for custom error patterns that indicate a stale deployment. Case-sensitive substring matching.
790
1529
 
791
1530
  ### Custom Retry Strategy
792
1531
 
@@ -835,14 +1574,51 @@ const handleBeacon = (beacon: BeaconSchema) => {
835
1574
  - Fastify plugin types for type-safe integration
836
1575
  - Options interface with optional fields
837
1576
 
838
- ## Future Enhancements
1577
+ ## ESLint Plugin
1578
+
1579
+ spa-guard includes an ESLint plugin (`@ovineko/spa-guard/eslint`) that enforces usage of spa-guard wrappers instead of direct React imports. This ensures all error boundaries and lazy loading are properly integrated with spa-guard's retry logic.
1580
+
1581
+ ### Setup
1582
+
1583
+ ```javascript
1584
+ // eslint.config.js (flat config)
1585
+ import spaGuardEslint from "@ovineko/spa-guard/eslint";
1586
+
1587
+ export default [spaGuardEslint.configs.recommended];
1588
+ ```
1589
+
1590
+ ### Rules
1591
+
1592
+ #### `no-direct-error-boundary`
1593
+
1594
+ Disallows importing `ErrorBoundary` from `react-error-boundary`. Auto-fixes to import from `@ovineko/spa-guard/react-error-boundary` instead.
839
1595
 
840
- Detailed specifications for planned features are documented in [TODO.md](TODO.md):
1596
+ ```typescript
1597
+ // Bad
1598
+ import { ErrorBoundary } from "react-error-boundary";
1599
+
1600
+ // Good (auto-fixed)
1601
+ import { ErrorBoundary } from "@ovineko/spa-guard/react-error-boundary";
1602
+ ```
841
1603
 
842
- - **Version Checker Module** - Detect new deployments via HTML or JSON polling
843
- - **Enhanced Event Emitter Architecture** - Rich event system for SPA integration with React hooks
1604
+ #### `no-direct-lazy`
844
1605
 
845
- These features are designed to extend spa-guard's capabilities while maintaining the minimal inline script footprint.
1606
+ Disallows importing `lazy` from `react`. Auto-fixes to import `lazyWithRetry` from `@ovineko/spa-guard/react` instead. Handles split-import cases where other specifiers (e.g., `Suspense`) remain on the original `react` import.
1607
+
1608
+ ```typescript
1609
+ // Bad
1610
+ import { lazy } from "react";
1611
+
1612
+ // Good (auto-fixed)
1613
+ import { lazyWithRetry } from "@ovineko/spa-guard/react";
1614
+
1615
+ // Bad (split import)
1616
+ import { lazy, Suspense } from "react";
1617
+
1618
+ // Good (auto-fixed)
1619
+ import { Suspense } from "react";
1620
+ import { lazyWithRetry } from "@ovineko/spa-guard/react";
1621
+ ```
846
1622
 
847
1623
  ## License
848
1624