@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.
- package/README.md +933 -157
- package/dist/{chunk-KHXP4ZNT.js → chunk-2DFYUVHH.js} +12 -1
- package/dist/chunk-6XKIZF5S.js +271 -0
- package/dist/chunk-DPOQIK4J.js +87 -0
- package/dist/{chunk-66I6YML2.js → chunk-EDRTFPCN.js} +8 -1
- package/dist/chunk-FAFKLSME.js +214 -0
- package/dist/chunk-HUAI4DRW.js +113 -0
- package/dist/chunk-HVBPX75C.js +99 -0
- package/dist/{chunk-Z3Y4C3HZ.js → chunk-T5RWH2HR.js} +93 -6
- package/dist/chunk-UMNFZ7IW.js +170 -0
- package/dist/common/DefaultErrorFallback.d.ts +17 -0
- package/dist/common/checkVersion.d.ts +5 -0
- package/dist/common/constants.d.ts +3 -0
- package/dist/common/events/internal.d.ts +11 -2
- package/dist/common/events/types.d.ts +46 -1
- package/dist/common/fallbackHtml.generated.d.ts +2 -1
- package/dist/common/handleErrorWithSpaGuard.d.ts +13 -0
- package/dist/common/index.d.ts +1 -0
- package/dist/common/index.js +183 -357
- package/dist/common/listen/internal.d.ts +2 -1
- package/dist/common/logger.d.ts +33 -0
- package/dist/common/options.d.ts +79 -10
- package/dist/common/reload.d.ts +2 -0
- package/dist/common/retryImport.d.ts +43 -0
- package/dist/common/retryState.d.ts +2 -0
- package/dist/common/shouldIgnore.d.ts +6 -2
- package/dist/eslint/index.d.ts +19 -0
- package/dist/eslint/index.js +152 -0
- package/dist/eslint/rules/no-direct-error-boundary.d.ts +3 -0
- package/dist/eslint/rules/no-direct-lazy.d.ts +3 -0
- package/dist/fastify/index.js +32 -25
- package/dist/react/DebugSyncErrorTrigger.d.ts +13 -0
- package/dist/react/index.d.ts +5 -0
- package/dist/react/index.js +16 -24
- package/dist/react/lazyWithRetry.d.ts +34 -0
- package/dist/react/types.d.ts +42 -0
- package/dist/react/useSPAGuardChunkError.d.ts +2 -0
- package/dist/react/useSPAGuardEvents.d.ts +2 -0
- package/dist/react-error-boundary/index.d.ts +31 -1
- package/dist/react-error-boundary/index.js +90 -1
- package/dist/react-router/index.d.ts +25 -1
- package/dist/react-router/index.js +60 -3
- package/dist/runtime/debug/errorDispatchers.d.ts +38 -0
- package/dist/runtime/debug/index.d.ts +12 -0
- package/dist/runtime/debug/index.js +273 -0
- package/dist/runtime/index.d.ts +3 -0
- package/dist/runtime/index.js +12 -3
- package/dist/runtime/recommendedSetup.d.ts +12 -0
- package/dist/vite-plugin/index.js +18 -9
- package/dist-inline/index.js +1 -1
- package/dist-inline-trace/index.js +1 -1
- package/package.json +16 -4
- package/dist/chunk-FQCDQYOP.js +0 -49
- 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
|
|
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 (~
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
<div style="
|
|
53
|
-
<
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
<
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
|
424
|
+
### React Integration
|
|
425
|
+
|
|
426
|
+
spa-guard provides two React error boundary components with automatic chunk error recovery.
|
|
347
427
|
|
|
348
|
-
|
|
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 {
|
|
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: <
|
|
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.
|
|
379
|
-
5.
|
|
380
|
-
6.
|
|
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.
|
|
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.
|
|
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
|
|
532
|
-
|
|
533
|
-
Registers a POST endpoint to receive beacon data from clients.
|
|
984
|
+
#### `fastifySPAGuard: FastifyPluginAsync<FastifySPAGuardOptions>`
|
|
534
985
|
|
|
535
|
-
|
|
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?: (
|
|
546
|
-
|
|
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
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
1232
|
+
#### Events
|
|
647
1233
|
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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` ~
|
|
679
|
-
- **Trace:** `dist-inline-trace/index.js` ~
|
|
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
|
|
1407
|
+
### Disable Retry ID Query Parameter (PII Concerns)
|
|
685
1408
|
|
|
686
|
-
If query
|
|
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
|
|
691
|
-
reloadDelays: [1000, 2000], // Still retries, but without cache
|
|
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
|
-
**
|
|
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
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
<div class="spa-guard-
|
|
743
|
-
<
|
|
744
|
-
|
|
745
|
-
|
|
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
|
-
|
|
748
|
-
|
|
749
|
-
|
|
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
|
-
|
|
761
|
-
|
|
762
|
-
|
|
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
|
|
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
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
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
|
-
|
|
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 `
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
843
|
-
- **Enhanced Event Emitter Architecture** - Rich event system for SPA integration with React hooks
|
|
1604
|
+
#### `no-direct-lazy`
|
|
844
1605
|
|
|
845
|
-
|
|
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
|
|