@ovineko/spa-guard-react 0.0.2-alpha-1 → 0.0.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 CHANGED
@@ -7,78 +7,47 @@ React hooks, components, and error boundaries for [spa-guard](../spa-guard/READM
7
7
 
8
8
  ## Install
9
9
 
10
- ```sh
11
- npm install @ovineko/spa-guard-react @ovineko/spa-guard react
12
- ```
10
+ **pnpm** (recommended):
13
11
 
14
- ## Usage
15
-
16
- ### lazyWithRetry
12
+ ```bash
13
+ pnpm add @ovineko/spa-guard-react @ovineko/spa-guard react
14
+ ```
17
15
 
18
- Wrap `React.lazy` to automatically retry chunk load failures with configurable delays.
16
+ **npm**:
19
17
 
20
- ```tsx
21
- import { Suspense } from "react";
22
- import { lazyWithRetry } from "@ovineko/spa-guard-react";
18
+ ```bash
19
+ npm install @ovineko/spa-guard-react @ovineko/spa-guard react
20
+ ```
23
21
 
24
- const LazyHome = lazyWithRetry(() => import("./pages/Home"));
22
+ **yarn**:
25
23
 
26
- // Override retry delays for a specific route
27
- const LazyCheckout = lazyWithRetry(() => import("./pages/Checkout"), {
28
- retryDelays: [500, 1000, 2000, 4000],
29
- });
30
-
31
- export function App() {
32
- return (
33
- <Suspense fallback={<div>Loading...</div>}>
34
- <LazyHome />
35
- </Suspense>
36
- );
37
- }
24
+ ```bash
25
+ yarn add @ovineko/spa-guard-react @ovineko/spa-guard react
38
26
  ```
39
27
 
40
- ### ErrorBoundary
41
-
42
- Catches errors in child components and integrates with spa-guard retry state.
28
+ **bun**:
43
29
 
44
- ```tsx
45
- import { ErrorBoundary } from "@ovineko/spa-guard-react/error-boundary";
46
-
47
- export function App() {
48
- return (
49
- <ErrorBoundary>
50
- <MyApp />
51
- </ErrorBoundary>
52
- );
53
- }
30
+ ```bash
31
+ bun add @ovineko/spa-guard-react @ovineko/spa-guard react
54
32
  ```
55
33
 
56
- ## API
34
+ **deno**:
57
35
 
58
- From `@ovineko/spa-guard-react`:
36
+ ```bash
37
+ deno add npm:@ovineko/spa-guard-react npm:@ovineko/spa-guard npm:react
38
+ ```
59
39
 
60
- - `lazyWithRetry(importFn, options?)` — lazy component with automatic retry
61
- - `useSpaGuardState()` — reactive hook for current spa-guard state
62
- - `useSPAGuardChunkError()` — hook to detect chunk load errors
63
- - `useSPAGuardEvents()` — hook to subscribe to spa-guard events
64
- - `DefaultErrorFallback` — default fallback UI component
65
- - `Spinner` — loading spinner component
66
- - `DebugSyncErrorTrigger` — trigger sync errors for testing
67
- - `ForceRetryError` — error class to force a retry
68
- - `LazyRetryOptions` — options type for `lazyWithRetry`
69
- - `SpaGuardState` — spa-guard state type
40
+ ## Usage
70
41
 
71
- From `@ovineko/spa-guard-react/error-boundary`:
42
+ ```tsx
43
+ import { lazyWithRetry } from "@ovineko/spa-guard-react";
72
44
 
73
- - `ErrorBoundary` error boundary with spa-guard integration
74
- - `ErrorBoundaryProps` — props for `ErrorBoundary`
75
- - `FallbackProps` — props passed to fallback component
45
+ const LazyHome = lazyWithRetry(() => import("./pages/Home"));
46
+ ```
76
47
 
77
- ## Related Packages
48
+ ## Documentation
78
49
 
79
- - [@ovineko/spa-guard](../spa-guard/README.md) — core package
80
- - [@ovineko/spa-guard-react-router](../react-router/README.md) — React Router integration
81
- - [@ovineko/spa-guard-vite](../vite/README.md) — Vite plugin
50
+ Full documentation: [ovineko.com/docs/spa-guard/react](https://ovineko.com/docs/spa-guard/react)
82
51
 
83
52
  ## License
84
53
 
@@ -0,0 +1,133 @@
1
+ import { ComponentProps, ComponentType, LazyExoticComponent } from "react";
2
+ import { SPAGuardEvent, SPAGuardEventChunkError } from "@ovineko/spa-guard/_internal";
3
+ import * as _$_ovineko_spa_guard_runtime0 from "@ovineko/spa-guard/runtime";
4
+ import { SpaGuardState, SpaGuardState as SpaGuardState$1 } from "@ovineko/spa-guard/runtime";
5
+ import { ForceRetryError } from "@ovineko/spa-guard";
6
+
7
+ //#region src/DefaultErrorFallback.d.ts
8
+ interface DefaultErrorFallbackProps {
9
+ error: unknown;
10
+ isChunkError: boolean;
11
+ isRetrying: boolean;
12
+ onReset?: () => void;
13
+ spaGuardState: SpaGuardState;
14
+ }
15
+ /**
16
+ * Default fallback UI component for error boundaries.
17
+ *
18
+ * Uses two separate HTML templates: one for loading/retrying state
19
+ * and one for error state. Renders via dangerouslySetInnerHTML with
20
+ * virtual container + data attribute patching for dynamic content.
21
+ */
22
+ declare const DefaultErrorFallback: React.FC<DefaultErrorFallbackProps>;
23
+ //#endregion
24
+ //#region src/react/DebugSyncErrorTrigger.d.ts
25
+ /**
26
+ * Renders nothing normally. When a CustomEvent of type debugSyncErrorEventType
27
+ * is dispatched on window, this component stores the error in state and throws
28
+ * it during the next render, allowing a parent React Error Boundary to catch it.
29
+ *
30
+ * Place this component inside your ErrorBoundary:
31
+ *
32
+ * <ErrorBoundary fallback={<CrashPage />}>
33
+ * <DebugSyncErrorTrigger />
34
+ * <App />
35
+ * </ErrorBoundary>
36
+ */
37
+ declare function DebugSyncErrorTrigger(): null;
38
+ //#endregion
39
+ //#region src/react/types.d.ts
40
+ /**
41
+ * Per-import options for lazyWithRetry that override global lazyRetry options.
42
+ *
43
+ * @example
44
+ * // Override retry delays for a critical component
45
+ * const LazyCheckout = lazyWithRetry(
46
+ * () => import('./pages/Checkout'),
47
+ * { retryDelays: [500, 1000, 2000, 4000] } satisfies LazyRetryOptions
48
+ * );
49
+ */
50
+ interface LazyRetryOptions {
51
+ /**
52
+ * If true, triggers a full page reload via triggerRetry() after all retry attempts are exhausted.
53
+ * If false, only throws the error to the error boundary without reload.
54
+ * Overrides the global `window.__SPA_GUARD_OPTIONS__.lazyRetry.callReloadOnFailure`.
55
+ *
56
+ * @default true (inherited from global options)
57
+ */
58
+ callReloadOnFailure?: boolean;
59
+ /**
60
+ * Array of delays in milliseconds for retry attempts.
61
+ * Each element represents one retry attempt with the given delay.
62
+ * The number of elements determines the number of retry attempts.
63
+ * Overrides the global `window.__SPA_GUARD_OPTIONS__.lazyRetry.retryDelays`.
64
+ *
65
+ * @default [1000, 2000] (inherited from global options)
66
+ * @example [500, 1500, 3000] // 3 attempts: 500ms, 1.5s, 3s
67
+ */
68
+ retryDelays?: number[];
69
+ /**
70
+ * AbortSignal to cancel pending retry delays between import attempts.
71
+ * When the signal fires, any pending setTimeout between retries is cleared
72
+ * and the import promise rejects with an AbortError.
73
+ *
74
+ * Note: cancels only the wait periods between retry attempts, not an in-flight
75
+ * dynamic import (JavaScript does not support cancelling in-flight module fetches).
76
+ * Most useful when calling `retryImport` directly rather than through `lazyWithRetry`,
77
+ * since `lazyWithRetry` captures the signal at module scope (not per component instance).
78
+ */
79
+ signal?: AbortSignal;
80
+ }
81
+ //#endregion
82
+ //#region src/react/lazyWithRetry.d.ts
83
+ /**
84
+ * Creates a lazy-loaded React component with automatic retry on chunk load failures.
85
+ *
86
+ * On import failure, retries with configurable delays before falling back to
87
+ * `triggerRetry()` for a full page reload.
88
+ *
89
+ * @param importFn - Function that performs the dynamic import
90
+ * @param options - Per-import options that override global lazyRetry options
91
+ * @returns A lazy React component with retry logic
92
+ *
93
+ * @example
94
+ * // Basic usage with global options
95
+ * const LazyHome = lazyWithRetry(() => import('./pages/Home'));
96
+ *
97
+ * @example
98
+ * // Override retry delays for a critical component
99
+ * const LazyCheckout = lazyWithRetry(
100
+ * () => import('./pages/Checkout'),
101
+ * { retryDelays: [500, 1000, 2000, 4000] }
102
+ * );
103
+ *
104
+ * @example
105
+ * // Disable page reload for a non-critical component
106
+ * const LazyWidget = lazyWithRetry(
107
+ * () => import('./widgets/Optional'),
108
+ * { retryDelays: [1000], callReloadOnFailure: false }
109
+ * );
110
+ */
111
+ declare const lazyWithRetry: <T extends ComponentType<any>>(importFn: () => Promise<{
112
+ default: T;
113
+ }>, options?: LazyRetryOptions) => LazyExoticComponent<T>;
114
+ //#endregion
115
+ //#region src/react/Spinner.d.ts
116
+ type SpinnerProps = Omit<ComponentProps<"div">, "children" | "dangerouslySetInnerHTML">;
117
+ /**
118
+ * Renders the spa-guard spinner inside a div.
119
+ * Returns null if spinner is disabled or no content available.
120
+ * All div props forwarded to wrapper element.
121
+ */
122
+ declare function Spinner(props: SpinnerProps): null | React.ReactElement;
123
+ //#endregion
124
+ //#region src/react/useSPAGuardChunkError.d.ts
125
+ declare const useSPAGuardChunkError: () => null | SPAGuardEventChunkError;
126
+ //#endregion
127
+ //#region src/react/useSPAGuardEvents.d.ts
128
+ declare const useSPAGuardEvents: (callback: (event: SPAGuardEvent) => void) => void;
129
+ //#endregion
130
+ //#region src/react/index.d.ts
131
+ declare const useSpaGuardState: () => _$_ovineko_spa_guard_runtime0.SpaGuardState;
132
+ //#endregion
133
+ export { useSPAGuardChunkError as a, LazyRetryOptions as c, useSPAGuardEvents as i, DebugSyncErrorTrigger as l, SpaGuardState$1 as n, Spinner as o, useSpaGuardState as r, lazyWithRetry as s, ForceRetryError as t, DefaultErrorFallback as u };
@@ -1,10 +1,2 @@
1
- export { DefaultErrorFallback } from "../DefaultErrorFallback";
2
- export { DebugSyncErrorTrigger } from "./DebugSyncErrorTrigger";
3
- export { lazyWithRetry } from "./lazyWithRetry";
4
- export { Spinner } from "./Spinner";
5
- export type { LazyRetryOptions } from "./types";
6
- export { useSPAGuardChunkError } from "./useSPAGuardChunkError";
7
- export { useSPAGuardEvents } from "./useSPAGuardEvents";
8
- export { ForceRetryError } from "@ovineko/spa-guard";
9
- export type { SpaGuardState } from "@ovineko/spa-guard/runtime";
10
- export declare const useSpaGuardState: () => import("@ovineko/spa-guard/runtime").SpaGuardState;
1
+ import { a as useSPAGuardChunkError, c as LazyRetryOptions, i as useSPAGuardEvents, l as DebugSyncErrorTrigger, n as SpaGuardState, o as Spinner, r as useSpaGuardState, s as lazyWithRetry, t as ForceRetryError, u as DefaultErrorFallback } from "../index-Xle6BwDr.mjs";
2
+ export { DebugSyncErrorTrigger, DefaultErrorFallback, ForceRetryError, LazyRetryOptions, SpaGuardState, Spinner, lazyWithRetry, useSPAGuardChunkError, useSPAGuardEvents, useSpaGuardState };
@@ -1,20 +1,2 @@
1
- import {
2
- DebugSyncErrorTrigger,
3
- DefaultErrorFallback,
4
- ForceRetryError,
5
- Spinner,
6
- lazyWithRetry,
7
- useSPAGuardChunkError,
8
- useSPAGuardEvents,
9
- useSpaGuardState
10
- } from "../chunk-6VYHMTKS.js";
11
- export {
12
- DebugSyncErrorTrigger,
13
- DefaultErrorFallback,
14
- ForceRetryError,
15
- Spinner,
16
- lazyWithRetry,
17
- useSPAGuardChunkError,
18
- useSPAGuardEvents,
19
- useSpaGuardState
20
- };
1
+ import { a as Spinner, c as DefaultErrorFallback, i as useSPAGuardEvents, n as useSpaGuardState, o as lazyWithRetry, r as useSPAGuardChunkError, s as DebugSyncErrorTrigger, t as ForceRetryError } from "../react-DuyDGIyU.mjs";
2
+ export { DebugSyncErrorTrigger, DefaultErrorFallback, ForceRetryError, Spinner, lazyWithRetry, useSPAGuardChunkError, useSPAGuardEvents, useSpaGuardState };
@@ -0,0 +1,218 @@
1
+ import { lazy, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
2
+ import { applyI18n, debugSyncErrorEventType, defaultErrorFallbackHtml, defaultLoadingFallbackHtml, getI18n, getOptions, retryImport, subscribe } from "@ovineko/spa-guard/_internal";
3
+ import { jsx } from "react/jsx-runtime";
4
+ import { getState, subscribeToState } from "@ovineko/spa-guard/runtime";
5
+ import { ForceRetryError } from "@ovineko/spa-guard";
6
+ //#region src/DefaultErrorFallback.tsx
7
+ const escapeHtml = (str) => str.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;");
8
+ const reloadHandler = () => location.reload();
9
+ /**
10
+ * Build final HTML by parsing the template in a virtual container
11
+ * and patching content via data attributes. This is robust against
12
+ * template changes (minification, styling, element order).
13
+ */
14
+ function buildHtml(template, patches) {
15
+ const container = document.createElement("div");
16
+ container.innerHTML = template;
17
+ if (patches.spinnerHtml) {
18
+ const spinnerEl = container.querySelector("[data-spa-guard-spinner]");
19
+ if (spinnerEl) spinnerEl.innerHTML = patches.spinnerHtml;
20
+ }
21
+ if (patches.content) for (const [key, value] of Object.entries(patches.content)) {
22
+ const el = container.querySelector(`[data-spa-guard-content="${key}"]`);
23
+ if (el) el.innerHTML = value;
24
+ }
25
+ if (patches.sections) for (const [key, visible] of Object.entries(patches.sections)) {
26
+ const el = container.querySelector(`[data-spa-guard-section="${key}"]`);
27
+ if (el) el.style.display = visible ? "block" : "none";
28
+ }
29
+ if (patches.actions) for (const [key, visible] of Object.entries(patches.actions)) {
30
+ const el = container.querySelector(`[data-spa-guard-action="${key}"]`);
31
+ if (el) el.style.display = visible ? "inline-block" : "none";
32
+ }
33
+ const t = getI18n();
34
+ if (t) applyI18n(container, t);
35
+ return container.innerHTML;
36
+ }
37
+ /**
38
+ * Default fallback UI component for error boundaries.
39
+ *
40
+ * Uses two separate HTML templates: one for loading/retrying state
41
+ * and one for error state. Renders via dangerouslySetInnerHTML with
42
+ * virtual container + data attribute patching for dynamic content.
43
+ */
44
+ const DefaultErrorFallback = ({ error, isChunkError: isChunk, isRetrying, onReset, spaGuardState }) => {
45
+ const containerRef = useRef(null);
46
+ const html = useMemo(() => {
47
+ const opts = getOptions();
48
+ if (isRetrying) {
49
+ const loadingTemplate = opts.html?.loading?.content ?? defaultLoadingFallbackHtml;
50
+ const spinnerContent = opts.html?.spinner?.content;
51
+ return buildHtml(loadingTemplate, {
52
+ content: { attempt: String(spaGuardState.currentAttempt) },
53
+ sections: { retrying: true },
54
+ ...spinnerContent !== void 0 && { spinnerHtml: spinnerContent }
55
+ });
56
+ }
57
+ const heading = isChunk ? "Failed to load module" : "Something went wrong";
58
+ const message = error instanceof Error ? error.message : String(error);
59
+ return buildHtml(opts.html?.fallback?.content ?? defaultErrorFallbackHtml, {
60
+ actions: { "try-again": Boolean(onReset) },
61
+ content: {
62
+ heading: escapeHtml(heading),
63
+ message: escapeHtml(message)
64
+ }
65
+ });
66
+ }, [
67
+ isRetrying,
68
+ isChunk,
69
+ error,
70
+ onReset,
71
+ spaGuardState.currentAttempt
72
+ ]);
73
+ useLayoutEffect(() => {
74
+ const el = containerRef.current;
75
+ if (!el) return;
76
+ const reloadBtn = el.querySelector("[data-spa-guard-action=\"reload\"]");
77
+ reloadBtn?.addEventListener("click", reloadHandler);
78
+ const tryAgainHandler = onReset ? () => onReset() : null;
79
+ const tryAgainBtn = onReset ? el.querySelector("[data-spa-guard-action=\"try-again\"]") : null;
80
+ if (tryAgainHandler && tryAgainBtn) tryAgainBtn.addEventListener("click", tryAgainHandler);
81
+ return () => {
82
+ reloadBtn?.removeEventListener("click", reloadHandler);
83
+ if (tryAgainHandler && tryAgainBtn) tryAgainBtn.removeEventListener("click", tryAgainHandler);
84
+ };
85
+ }, [onReset, html]);
86
+ return /* @__PURE__ */ jsx("div", {
87
+ dangerouslySetInnerHTML: useMemo(() => ({ __html: html }), [html]),
88
+ ref: containerRef
89
+ });
90
+ };
91
+ //#endregion
92
+ //#region src/react/DebugSyncErrorTrigger.tsx
93
+ /**
94
+ * Renders nothing normally. When a CustomEvent of type debugSyncErrorEventType
95
+ * is dispatched on window, this component stores the error in state and throws
96
+ * it during the next render, allowing a parent React Error Boundary to catch it.
97
+ *
98
+ * Place this component inside your ErrorBoundary:
99
+ *
100
+ * <ErrorBoundary fallback={<CrashPage />}>
101
+ * <DebugSyncErrorTrigger />
102
+ * <App />
103
+ * </ErrorBoundary>
104
+ */
105
+ function DebugSyncErrorTrigger() {
106
+ const [error, setError] = useState(null);
107
+ useEffect(() => {
108
+ const handler = (e) => {
109
+ const detail = e.detail;
110
+ if (detail?.error instanceof Error) setError(detail.error);
111
+ };
112
+ globalThis.addEventListener(debugSyncErrorEventType, handler);
113
+ return () => {
114
+ globalThis.removeEventListener(debugSyncErrorEventType, handler);
115
+ };
116
+ }, []);
117
+ if (error) throw error;
118
+ return null;
119
+ }
120
+ //#endregion
121
+ //#region src/react/lazyWithRetry.tsx
122
+ /**
123
+ * Creates a lazy-loaded React component with automatic retry on chunk load failures.
124
+ *
125
+ * On import failure, retries with configurable delays before falling back to
126
+ * `triggerRetry()` for a full page reload.
127
+ *
128
+ * @param importFn - Function that performs the dynamic import
129
+ * @param options - Per-import options that override global lazyRetry options
130
+ * @returns A lazy React component with retry logic
131
+ *
132
+ * @example
133
+ * // Basic usage with global options
134
+ * const LazyHome = lazyWithRetry(() => import('./pages/Home'));
135
+ *
136
+ * @example
137
+ * // Override retry delays for a critical component
138
+ * const LazyCheckout = lazyWithRetry(
139
+ * () => import('./pages/Checkout'),
140
+ * { retryDelays: [500, 1000, 2000, 4000] }
141
+ * );
142
+ *
143
+ * @example
144
+ * // Disable page reload for a non-critical component
145
+ * const LazyWidget = lazyWithRetry(
146
+ * () => import('./widgets/Optional'),
147
+ * { retryDelays: [1000], callReloadOnFailure: false }
148
+ * );
149
+ */
150
+ const lazyWithRetry = (importFn, options) => {
151
+ return lazy(() => {
152
+ const globalLazyRetry = getOptions().lazyRetry ?? {};
153
+ const retryDelays = options?.retryDelays ?? globalLazyRetry.retryDelays ?? [1e3, 2e3];
154
+ const callReloadOnFailure = options?.callReloadOnFailure ?? globalLazyRetry.callReloadOnFailure ?? true;
155
+ const signal = options?.signal;
156
+ return retryImport(importFn, retryDelays, {
157
+ callReloadOnFailure,
158
+ ...signal !== void 0 && { signal }
159
+ });
160
+ });
161
+ };
162
+ //#endregion
163
+ //#region src/react/Spinner.tsx
164
+ /**
165
+ * Renders the spa-guard spinner inside a div.
166
+ * Returns null if spinner is disabled or no content available.
167
+ * All div props forwarded to wrapper element.
168
+ */
169
+ function Spinner(props) {
170
+ const opts = getOptions();
171
+ const content = opts.html?.spinner?.disabled ? void 0 : opts.html?.spinner?.content;
172
+ const innerHtml = useMemo(() => content ? { __html: content } : null, [content]);
173
+ if (!innerHtml) return null;
174
+ return /* @__PURE__ */ jsx("div", {
175
+ ...props,
176
+ dangerouslySetInnerHTML: innerHtml
177
+ });
178
+ }
179
+ //#endregion
180
+ //#region src/react/useSPAGuardEvents.ts
181
+ const useSPAGuardEvents = (callback) => {
182
+ const callbackRef = useRef(callback);
183
+ useEffect(() => {
184
+ callbackRef.current = callback;
185
+ });
186
+ useEffect(() => {
187
+ return subscribe((event) => {
188
+ callbackRef.current(event);
189
+ });
190
+ }, []);
191
+ };
192
+ //#endregion
193
+ //#region src/react/useSPAGuardChunkError.ts
194
+ const useSPAGuardChunkError = () => {
195
+ const [chunkError, setChunkError] = useState(null);
196
+ useSPAGuardEvents(useCallback((event) => {
197
+ if (event.name === "chunk-error") setChunkError(event);
198
+ }, []));
199
+ return chunkError;
200
+ };
201
+ //#endregion
202
+ //#region src/react/index.tsx
203
+ const useSpaGuardState = () => {
204
+ const [state, setState] = useState(() => {
205
+ if (globalThis.window === void 0) return {
206
+ currentAttempt: 0,
207
+ isFallbackShown: false,
208
+ isWaiting: false
209
+ };
210
+ return getState();
211
+ });
212
+ useEffect(() => {
213
+ return subscribeToState(setState);
214
+ }, []);
215
+ return state;
216
+ };
217
+ //#endregion
218
+ export { Spinner as a, DefaultErrorFallback as c, useSPAGuardEvents as i, useSpaGuardState as n, lazyWithRetry as o, useSPAGuardChunkError as r, DebugSyncErrorTrigger as s, ForceRetryError as t };
@@ -1,31 +1,35 @@
1
- import { type ReactNode } from "react";
2
- import { type SpaGuardState } from "../react";
1
+ import { n as SpaGuardState } from "../index-Xle6BwDr.mjs";
2
+ import { ReactNode } from "react";
3
+
4
+ //#region src/react-error-boundary/index.d.ts
3
5
  /**
4
6
  * Props for the ErrorBoundary component
5
7
  */
6
- export interface ErrorBoundaryProps {
7
- autoRetryChunkErrors?: boolean;
8
- children: ReactNode;
9
- fallback?: ((props: FallbackProps) => React.ReactElement) | React.ComponentType<FallbackProps>;
10
- fallbackRender?: (props: FallbackProps) => React.ReactElement;
11
- onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
12
- resetKeys?: Array<unknown>;
13
- sendBeaconOnError?: boolean;
8
+ interface ErrorBoundaryProps {
9
+ autoRetryChunkErrors?: boolean;
10
+ children: ReactNode;
11
+ fallback?: ((props: FallbackProps) => React.ReactElement) | React.ComponentType<FallbackProps>;
12
+ fallbackRender?: (props: FallbackProps) => React.ReactElement;
13
+ onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
14
+ resetKeys?: Array<unknown>;
15
+ sendBeaconOnError?: boolean;
14
16
  }
15
17
  /**
16
18
  * Props passed to the fallback component when an error is caught
17
19
  */
18
- export interface FallbackProps {
19
- error: Error;
20
- errorInfo: null | React.ErrorInfo;
21
- isChunkError: boolean;
22
- isRetrying: boolean;
23
- resetError: () => void;
24
- spaGuardState: SpaGuardState;
20
+ interface FallbackProps {
21
+ error: Error;
22
+ errorInfo: null | React.ErrorInfo;
23
+ isChunkError: boolean;
24
+ isRetrying: boolean;
25
+ resetError: () => void;
26
+ spaGuardState: SpaGuardState;
25
27
  }
26
28
  /**
27
29
  * Error boundary component with spa-guard integration.
28
30
  *
29
31
  * Catches errors in child components and automatically retries chunk loading errors.
30
32
  */
31
- export declare const ErrorBoundary: React.FC<ErrorBoundaryProps>;
33
+ declare const ErrorBoundary: React.FC<ErrorBoundaryProps>;
34
+ //#endregion
35
+ export { ErrorBoundary, ErrorBoundaryProps, FallbackProps };
@@ -1,83 +1,73 @@
1
- import {
2
- DefaultErrorFallback,
3
- useSpaGuardState
4
- } from "../chunk-6VYHMTKS.js";
5
-
6
- // src/react-error-boundary/index.tsx
1
+ import { c as DefaultErrorFallback, n as useSpaGuardState } from "../react-DuyDGIyU.mjs";
7
2
  import { Component } from "react";
8
3
  import { handleErrorWithSpaGuard, isChunkError } from "@ovineko/spa-guard/_internal";
9
4
  import { jsx } from "react/jsx-runtime";
10
- var DefaultFallback = ({
11
- error,
12
- isChunkError: isChunk,
13
- isRetrying,
14
- resetError,
15
- spaGuardState
16
- }) => /* @__PURE__ */ jsx(
17
- DefaultErrorFallback,
18
- {
19
- error,
20
- isChunkError: isChunk,
21
- isRetrying,
22
- onReset: resetError,
23
- spaGuardState
24
- }
25
- );
5
+ //#region src/react-error-boundary/index.tsx
6
+ const DefaultFallback = ({ error, isChunkError: isChunk, isRetrying, resetError, spaGuardState }) => /* @__PURE__ */ jsx(DefaultErrorFallback, {
7
+ error,
8
+ isChunkError: isChunk,
9
+ isRetrying,
10
+ onReset: resetError,
11
+ spaGuardState
12
+ });
26
13
  var ErrorBoundaryImpl = class extends Component {
27
- state = { error: null, errorInfo: null };
28
- static getDerivedStateFromError(error) {
29
- return { error };
30
- }
31
- componentDidCatch(error, errorInfo) {
32
- this.setState({ errorInfo });
33
- const { autoRetryChunkErrors, onError, sendBeaconOnError } = this.props;
34
- handleErrorWithSpaGuard(error, {
35
- autoRetryChunkErrors,
36
- errorInfo,
37
- eventName: "react-error-boundary",
38
- onError: () => onError?.(error, errorInfo),
39
- sendBeaconOnError
40
- });
41
- }
42
- componentDidUpdate(prevProps) {
43
- const { resetKeys = [] } = this.props;
44
- const prevResetKeys = prevProps.resetKeys ?? [];
45
- if (this.state.error !== null && (resetKeys.length !== prevResetKeys.length || resetKeys.some((key, i) => key !== prevResetKeys[i]))) {
46
- this.resetError();
47
- }
48
- }
49
- render() {
50
- const { error, errorInfo } = this.state;
51
- if (!error) {
52
- return this.props.children;
53
- }
54
- const { fallback: Fallback, fallbackRender, spaGuardState } = this.props;
55
- const isChunk = isChunkError(error);
56
- const isRetrying = spaGuardState.isWaiting && spaGuardState.currentAttempt > 0;
57
- const fallbackProps = {
58
- error,
59
- errorInfo,
60
- isChunkError: isChunk,
61
- isRetrying,
62
- resetError: this.resetError,
63
- spaGuardState
64
- };
65
- if (fallbackRender) {
66
- return fallbackRender(fallbackProps);
67
- }
68
- if (Fallback) {
69
- return /* @__PURE__ */ jsx(Fallback, { ...fallbackProps });
70
- }
71
- return /* @__PURE__ */ jsx(DefaultFallback, { ...fallbackProps });
72
- }
73
- resetError = () => {
74
- this.setState({ error: null, errorInfo: null });
75
- };
14
+ state = {
15
+ error: null,
16
+ errorInfo: null
17
+ };
18
+ static getDerivedStateFromError(error) {
19
+ return { error };
20
+ }
21
+ componentDidCatch(error, errorInfo) {
22
+ this.setState({ errorInfo });
23
+ const { autoRetryChunkErrors, onError, sendBeaconOnError } = this.props;
24
+ handleErrorWithSpaGuard(error, {
25
+ ...autoRetryChunkErrors !== void 0 && { autoRetryChunkErrors },
26
+ errorInfo,
27
+ eventName: "react-error-boundary",
28
+ onError: () => onError?.(error, errorInfo),
29
+ ...sendBeaconOnError !== void 0 && { sendBeaconOnError }
30
+ });
31
+ }
32
+ componentDidUpdate(prevProps) {
33
+ const { resetKeys = [] } = this.props;
34
+ const prevResetKeys = prevProps.resetKeys ?? [];
35
+ if (this.state.error !== null && (resetKeys.length !== prevResetKeys.length || resetKeys.some((key, i) => key !== prevResetKeys[i]))) this.resetError();
36
+ }
37
+ render() {
38
+ const { error, errorInfo } = this.state;
39
+ if (!error) return this.props.children;
40
+ const { fallback: Fallback, fallbackRender, spaGuardState } = this.props;
41
+ const fallbackProps = {
42
+ error,
43
+ errorInfo,
44
+ isChunkError: isChunkError(error),
45
+ isRetrying: spaGuardState.isWaiting && spaGuardState.currentAttempt > 0,
46
+ resetError: this.resetError,
47
+ spaGuardState
48
+ };
49
+ if (fallbackRender) return fallbackRender(fallbackProps);
50
+ if (Fallback) return /* @__PURE__ */ jsx(Fallback, { ...fallbackProps });
51
+ return /* @__PURE__ */ jsx(DefaultFallback, { ...fallbackProps });
52
+ }
53
+ resetError = () => {
54
+ this.setState({
55
+ error: null,
56
+ errorInfo: null
57
+ });
58
+ };
76
59
  };
77
- var ErrorBoundary = (props) => {
78
- const spaGuardState = useSpaGuardState();
79
- return /* @__PURE__ */ jsx(ErrorBoundaryImpl, { ...props, spaGuardState });
80
- };
81
- export {
82
- ErrorBoundary
60
+ /**
61
+ * Error boundary component with spa-guard integration.
62
+ *
63
+ * Catches errors in child components and automatically retries chunk loading errors.
64
+ */
65
+ const ErrorBoundary = (props) => {
66
+ const spaGuardState = useSpaGuardState();
67
+ return /* @__PURE__ */ jsx(ErrorBoundaryImpl, {
68
+ ...props,
69
+ spaGuardState
70
+ });
83
71
  };
72
+ //#endregion
73
+ export { ErrorBoundary };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ovineko/spa-guard-react",
3
- "version": "0.0.2-alpha-1",
3
+ "version": "0.0.4",
4
4
  "description": "React hooks, components, and error boundaries for spa-guard",
5
5
  "keywords": [
6
6
  "spa",
@@ -35,11 +35,8 @@
35
35
  "dist"
36
36
  ],
37
37
  "peerDependencies": {
38
- "react": "^19",
39
- "@ovineko/spa-guard": "0.0.2-alpha-1"
40
- },
41
- "engines": {
42
- "node": ">=22.15.0"
38
+ "react": ">=18",
39
+ "@ovineko/spa-guard": "0.0.4"
43
40
  },
44
41
  "publishConfig": {
45
42
  "access": "public"
@@ -1,17 +0,0 @@
1
- import type { SpaGuardState } from "@ovineko/spa-guard/runtime";
2
- interface DefaultErrorFallbackProps {
3
- error: unknown;
4
- isChunkError: boolean;
5
- isRetrying: boolean;
6
- onReset?: () => void;
7
- spaGuardState: SpaGuardState;
8
- }
9
- /**
10
- * Default fallback UI component for error boundaries.
11
- *
12
- * Uses two separate HTML templates: one for loading/retrying state
13
- * and one for error state. Renders via dangerouslySetInnerHTML with
14
- * virtual container + data attribute patching for dynamic content.
15
- */
16
- export declare const DefaultErrorFallback: React.FC<DefaultErrorFallbackProps>;
17
- export {};
@@ -1,223 +0,0 @@
1
- // src/react/index.tsx
2
- import { useEffect as useEffect3, useState as useState3 } from "react";
3
- import { getState, subscribeToState } from "@ovineko/spa-guard/runtime";
4
-
5
- // src/DefaultErrorFallback.tsx
6
- import { useLayoutEffect, useMemo, useRef } from "react";
7
- import {
8
- applyI18n,
9
- defaultErrorFallbackHtml,
10
- defaultLoadingFallbackHtml,
11
- getI18n,
12
- getOptions
13
- } from "@ovineko/spa-guard/_internal";
14
- import { jsx } from "react/jsx-runtime";
15
- var escapeHtml = (str) => str.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
16
- var reloadHandler = () => location.reload();
17
- function buildHtml(template, patches) {
18
- const container = document.createElement("div");
19
- container.innerHTML = template;
20
- if (patches.spinnerHtml) {
21
- const spinnerEl = container.querySelector("[data-spa-guard-spinner]");
22
- if (spinnerEl) {
23
- spinnerEl.innerHTML = patches.spinnerHtml;
24
- }
25
- }
26
- if (patches.content) {
27
- for (const [key, value] of Object.entries(patches.content)) {
28
- const el = container.querySelector(`[data-spa-guard-content="${key}"]`);
29
- if (el) {
30
- el.innerHTML = value;
31
- }
32
- }
33
- }
34
- if (patches.sections) {
35
- for (const [key, visible] of Object.entries(patches.sections)) {
36
- const el = container.querySelector(`[data-spa-guard-section="${key}"]`);
37
- if (el) {
38
- el.style.display = visible ? "block" : "none";
39
- }
40
- }
41
- }
42
- if (patches.actions) {
43
- for (const [key, visible] of Object.entries(patches.actions)) {
44
- const el = container.querySelector(`[data-spa-guard-action="${key}"]`);
45
- if (el) {
46
- el.style.display = visible ? "inline-block" : "none";
47
- }
48
- }
49
- }
50
- const t = getI18n();
51
- if (t) {
52
- applyI18n(container, t);
53
- }
54
- return container.innerHTML;
55
- }
56
- var DefaultErrorFallback = ({
57
- error,
58
- isChunkError: isChunk,
59
- isRetrying,
60
- onReset,
61
- spaGuardState
62
- }) => {
63
- const containerRef = useRef(null);
64
- const html = useMemo(() => {
65
- const opts = getOptions();
66
- if (isRetrying) {
67
- const loadingTemplate = opts.html?.loading?.content ?? defaultLoadingFallbackHtml;
68
- return buildHtml(loadingTemplate, {
69
- content: {
70
- attempt: String(spaGuardState.currentAttempt)
71
- },
72
- sections: {
73
- retrying: true
74
- },
75
- spinnerHtml: opts.html?.spinner?.content
76
- });
77
- }
78
- const heading = isChunk ? "Failed to load module" : "Something went wrong";
79
- const message = error instanceof Error ? error.message : String(error);
80
- const errorTemplate = opts.html?.fallback?.content ?? defaultErrorFallbackHtml;
81
- return buildHtml(errorTemplate, {
82
- actions: {
83
- "try-again": Boolean(onReset)
84
- },
85
- content: {
86
- heading: escapeHtml(heading),
87
- message: escapeHtml(message)
88
- }
89
- });
90
- }, [isRetrying, isChunk, error, onReset, spaGuardState.currentAttempt]);
91
- useLayoutEffect(() => {
92
- const el = containerRef.current;
93
- if (!el) {
94
- return;
95
- }
96
- const reloadBtn = el.querySelector('[data-spa-guard-action="reload"]');
97
- reloadBtn?.addEventListener("click", reloadHandler);
98
- const tryAgainHandler = onReset ? () => onReset() : null;
99
- const tryAgainBtn = onReset ? el.querySelector('[data-spa-guard-action="try-again"]') : null;
100
- if (tryAgainHandler && tryAgainBtn) {
101
- tryAgainBtn.addEventListener("click", tryAgainHandler);
102
- }
103
- return () => {
104
- reloadBtn?.removeEventListener("click", reloadHandler);
105
- if (tryAgainHandler && tryAgainBtn) {
106
- tryAgainBtn.removeEventListener("click", tryAgainHandler);
107
- }
108
- };
109
- }, [onReset, html]);
110
- const innerHtml = useMemo(() => ({ __html: html }), [html]);
111
- return /* @__PURE__ */ jsx("div", { dangerouslySetInnerHTML: innerHtml, ref: containerRef });
112
- };
113
-
114
- // src/react/DebugSyncErrorTrigger.tsx
115
- import { useEffect, useState } from "react";
116
- import { debugSyncErrorEventType } from "@ovineko/spa-guard/_internal";
117
- function DebugSyncErrorTrigger() {
118
- const [error, setError] = useState(null);
119
- useEffect(() => {
120
- const handler = (e) => {
121
- const detail = e.detail;
122
- if (detail?.error instanceof Error) {
123
- setError(detail.error);
124
- }
125
- };
126
- globalThis.addEventListener(debugSyncErrorEventType, handler);
127
- return () => {
128
- globalThis.removeEventListener(debugSyncErrorEventType, handler);
129
- };
130
- }, []);
131
- if (error) {
132
- throw error;
133
- }
134
- return null;
135
- }
136
-
137
- // src/react/lazyWithRetry.tsx
138
- import { lazy } from "react";
139
- import { getOptions as getOptions2, retryImport } from "@ovineko/spa-guard/_internal";
140
- var lazyWithRetry = (importFn, options) => {
141
- return lazy(() => {
142
- const globalLazyRetry = getOptions2().lazyRetry ?? {};
143
- const retryDelays = options?.retryDelays ?? globalLazyRetry.retryDelays ?? [1e3, 2e3];
144
- const callReloadOnFailure = options?.callReloadOnFailure ?? globalLazyRetry.callReloadOnFailure ?? true;
145
- const signal = options?.signal;
146
- return retryImport(importFn, retryDelays, { callReloadOnFailure, signal });
147
- });
148
- };
149
-
150
- // src/react/Spinner.tsx
151
- import { useMemo as useMemo2 } from "react";
152
- import { getOptions as getOptions3 } from "@ovineko/spa-guard/_internal";
153
- import { jsx as jsx2 } from "react/jsx-runtime";
154
- function Spinner(props) {
155
- const opts = getOptions3();
156
- const content = opts.html?.spinner?.disabled ? void 0 : opts.html?.spinner?.content;
157
- const innerHtml = useMemo2(() => content ? { __html: content } : null, [content]);
158
- if (!innerHtml) {
159
- return null;
160
- }
161
- return /* @__PURE__ */ jsx2("div", { ...props, dangerouslySetInnerHTML: innerHtml });
162
- }
163
-
164
- // src/react/useSPAGuardChunkError.ts
165
- import { useCallback, useState as useState2 } from "react";
166
-
167
- // src/react/useSPAGuardEvents.ts
168
- import { useEffect as useEffect2, useRef as useRef2 } from "react";
169
- import { subscribe } from "@ovineko/spa-guard/_internal";
170
- var useSPAGuardEvents = (callback) => {
171
- const callbackRef = useRef2(callback);
172
- useEffect2(() => {
173
- callbackRef.current = callback;
174
- });
175
- useEffect2(() => {
176
- return subscribe((event) => {
177
- callbackRef.current(event);
178
- });
179
- }, []);
180
- };
181
-
182
- // src/react/useSPAGuardChunkError.ts
183
- var useSPAGuardChunkError = () => {
184
- const [chunkError, setChunkError] = useState2(null);
185
- useSPAGuardEvents(
186
- useCallback((event) => {
187
- if (event.name === "chunk-error") {
188
- setChunkError(event);
189
- }
190
- }, [])
191
- );
192
- return chunkError;
193
- };
194
-
195
- // src/react/index.tsx
196
- import { ForceRetryError } from "@ovineko/spa-guard";
197
- var useSpaGuardState = () => {
198
- const [state, setState] = useState3(() => {
199
- if (globalThis.window === void 0) {
200
- return {
201
- currentAttempt: 0,
202
- isFallbackShown: false,
203
- isWaiting: false
204
- };
205
- }
206
- return getState();
207
- });
208
- useEffect3(() => {
209
- return subscribeToState(setState);
210
- }, []);
211
- return state;
212
- };
213
-
214
- export {
215
- DefaultErrorFallback,
216
- DebugSyncErrorTrigger,
217
- lazyWithRetry,
218
- Spinner,
219
- useSPAGuardEvents,
220
- useSPAGuardChunkError,
221
- useSpaGuardState,
222
- ForceRetryError
223
- };
@@ -1,13 +0,0 @@
1
- /**
2
- * Renders nothing normally. When a CustomEvent of type debugSyncErrorEventType
3
- * is dispatched on window, this component stores the error in state and throws
4
- * it during the next render, allowing a parent React Error Boundary to catch it.
5
- *
6
- * Place this component inside your ErrorBoundary:
7
- *
8
- * <ErrorBoundary fallback={<CrashPage />}>
9
- * <DebugSyncErrorTrigger />
10
- * <App />
11
- * </ErrorBoundary>
12
- */
13
- export declare function DebugSyncErrorTrigger(): null;
@@ -1,9 +0,0 @@
1
- import type { ComponentProps } from "react";
2
- type SpinnerProps = Omit<ComponentProps<"div">, "children" | "dangerouslySetInnerHTML">;
3
- /**
4
- * Renders the spa-guard spinner inside a div.
5
- * Returns null if spinner is disabled or no content available.
6
- * All div props forwarded to wrapper element.
7
- */
8
- export declare function Spinner(props: SpinnerProps): null | React.ReactElement;
9
- export {};
@@ -1,34 +0,0 @@
1
- import { type ComponentType, type LazyExoticComponent } from "react";
2
- import type { LazyRetryOptions } from "./types";
3
- export type { LazyRetryOptions } from "./types";
4
- /**
5
- * Creates a lazy-loaded React component with automatic retry on chunk load failures.
6
- *
7
- * On import failure, retries with configurable delays before falling back to
8
- * `triggerRetry()` for a full page reload.
9
- *
10
- * @param importFn - Function that performs the dynamic import
11
- * @param options - Per-import options that override global lazyRetry options
12
- * @returns A lazy React component with retry logic
13
- *
14
- * @example
15
- * // Basic usage with global options
16
- * const LazyHome = lazyWithRetry(() => import('./pages/Home'));
17
- *
18
- * @example
19
- * // Override retry delays for a critical component
20
- * const LazyCheckout = lazyWithRetry(
21
- * () => import('./pages/Checkout'),
22
- * { retryDelays: [500, 1000, 2000, 4000] }
23
- * );
24
- *
25
- * @example
26
- * // Disable page reload for a non-critical component
27
- * const LazyWidget = lazyWithRetry(
28
- * () => import('./widgets/Optional'),
29
- * { retryDelays: [1000], callReloadOnFailure: false }
30
- * );
31
- */
32
- export declare const lazyWithRetry: <T extends ComponentType<any>>(importFn: () => Promise<{
33
- default: T;
34
- }>, options?: LazyRetryOptions) => LazyExoticComponent<T>;
@@ -1,41 +0,0 @@
1
- /**
2
- * Per-import options for lazyWithRetry that override global lazyRetry options.
3
- *
4
- * @example
5
- * // Override retry delays for a critical component
6
- * const LazyCheckout = lazyWithRetry(
7
- * () => import('./pages/Checkout'),
8
- * { retryDelays: [500, 1000, 2000, 4000] } satisfies LazyRetryOptions
9
- * );
10
- */
11
- export interface LazyRetryOptions {
12
- /**
13
- * If true, triggers a full page reload via triggerRetry() after all retry attempts are exhausted.
14
- * If false, only throws the error to the error boundary without reload.
15
- * Overrides the global `window.__SPA_GUARD_OPTIONS__.lazyRetry.callReloadOnFailure`.
16
- *
17
- * @default true (inherited from global options)
18
- */
19
- callReloadOnFailure?: boolean;
20
- /**
21
- * Array of delays in milliseconds for retry attempts.
22
- * Each element represents one retry attempt with the given delay.
23
- * The number of elements determines the number of retry attempts.
24
- * Overrides the global `window.__SPA_GUARD_OPTIONS__.lazyRetry.retryDelays`.
25
- *
26
- * @default [1000, 2000] (inherited from global options)
27
- * @example [500, 1500, 3000] // 3 attempts: 500ms, 1.5s, 3s
28
- */
29
- retryDelays?: number[];
30
- /**
31
- * AbortSignal to cancel pending retry delays between import attempts.
32
- * When the signal fires, any pending setTimeout between retries is cleared
33
- * and the import promise rejects with an AbortError.
34
- *
35
- * Note: cancels only the wait periods between retry attempts, not an in-flight
36
- * dynamic import (JavaScript does not support cancelling in-flight module fetches).
37
- * Most useful when calling `retryImport` directly rather than through `lazyWithRetry`,
38
- * since `lazyWithRetry` captures the signal at module scope (not per component instance).
39
- */
40
- signal?: AbortSignal;
41
- }
@@ -1,2 +0,0 @@
1
- import type { SPAGuardEventChunkError } from "@ovineko/spa-guard/_internal";
2
- export declare const useSPAGuardChunkError: () => null | SPAGuardEventChunkError;
@@ -1,2 +0,0 @@
1
- import type { SPAGuardEvent } from "@ovineko/spa-guard/_internal";
2
- export declare const useSPAGuardEvents: (callback: (event: SPAGuardEvent) => void) => void;