@ovineko/spa-guard-react 0.0.1-alpha-18

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.
@@ -0,0 +1,17 @@
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 {};
@@ -0,0 +1,223 @@
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.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.spinner?.disabled ? void 0 : opts.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
+ };
@@ -0,0 +1,13 @@
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;
@@ -0,0 +1,9 @@
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 {};
@@ -0,0 +1,10 @@
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;
@@ -0,0 +1,20 @@
1
+ import {
2
+ DebugSyncErrorTrigger,
3
+ DefaultErrorFallback,
4
+ ForceRetryError,
5
+ Spinner,
6
+ lazyWithRetry,
7
+ useSPAGuardChunkError,
8
+ useSPAGuardEvents,
9
+ useSpaGuardState
10
+ } from "../chunk-RR7KT33N.js";
11
+ export {
12
+ DebugSyncErrorTrigger,
13
+ DefaultErrorFallback,
14
+ ForceRetryError,
15
+ Spinner,
16
+ lazyWithRetry,
17
+ useSPAGuardChunkError,
18
+ useSPAGuardEvents,
19
+ useSpaGuardState
20
+ };
@@ -0,0 +1,34 @@
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
+ * `attemptReload()` 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>;
@@ -0,0 +1,42 @@
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
+ * Call attemptReload() after all retry attempts are exhausted.
14
+ * If true, triggers page reload logic after all retries fail.
15
+ * If false, only throws the error to the error boundary without reload.
16
+ * Overrides the global `window.__SPA_GUARD_OPTIONS__.lazyRetry.callReloadOnFailure`.
17
+ *
18
+ * @default true (inherited from global options)
19
+ */
20
+ callReloadOnFailure?: boolean;
21
+ /**
22
+ * Array of delays in milliseconds for retry attempts.
23
+ * Each element represents one retry attempt with the given delay.
24
+ * The number of elements determines the number of retry attempts.
25
+ * Overrides the global `window.__SPA_GUARD_OPTIONS__.lazyRetry.retryDelays`.
26
+ *
27
+ * @default [1000, 2000] (inherited from global options)
28
+ * @example [500, 1500, 3000] // 3 attempts: 500ms, 1.5s, 3s
29
+ */
30
+ retryDelays?: number[];
31
+ /**
32
+ * AbortSignal to cancel pending retry delays between import attempts.
33
+ * When the signal fires, any pending setTimeout between retries is cleared
34
+ * and the import promise rejects with an AbortError.
35
+ *
36
+ * Note: cancels only the wait periods between retry attempts, not an in-flight
37
+ * dynamic import (JavaScript does not support cancelling in-flight module fetches).
38
+ * Most useful when calling `retryImport` directly rather than through `lazyWithRetry`,
39
+ * since `lazyWithRetry` captures the signal at module scope (not per component instance).
40
+ */
41
+ signal?: AbortSignal;
42
+ }
@@ -0,0 +1,2 @@
1
+ import type { SPAGuardEventChunkError } from "@ovineko/spa-guard/_internal";
2
+ export declare const useSPAGuardChunkError: () => null | SPAGuardEventChunkError;
@@ -0,0 +1,2 @@
1
+ import type { SPAGuardEvent } from "@ovineko/spa-guard/_internal";
2
+ export declare const useSPAGuardEvents: (callback: (event: SPAGuardEvent) => void) => void;
@@ -0,0 +1,31 @@
1
+ import { type ReactNode } from "react";
2
+ import { type SpaGuardState } from "../react";
3
+ /**
4
+ * Props for the ErrorBoundary component
5
+ */
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;
14
+ }
15
+ /**
16
+ * Props passed to the fallback component when an error is caught
17
+ */
18
+ export interface FallbackProps {
19
+ error: Error;
20
+ errorInfo: null | React.ErrorInfo;
21
+ isChunkError: boolean;
22
+ isRetrying: boolean;
23
+ resetError: () => void;
24
+ spaGuardState: SpaGuardState;
25
+ }
26
+ /**
27
+ * Error boundary component with spa-guard integration.
28
+ *
29
+ * Catches errors in child components and automatically retries chunk loading errors.
30
+ */
31
+ export declare const ErrorBoundary: React.FC<ErrorBoundaryProps>;
@@ -0,0 +1,83 @@
1
+ import {
2
+ DefaultErrorFallback,
3
+ useSpaGuardState
4
+ } from "../chunk-RR7KT33N.js";
5
+
6
+ // src/react-error-boundary/index.tsx
7
+ import { Component } from "react";
8
+ import { handleErrorWithSpaGuard, isChunkError } from "@ovineko/spa-guard/_internal";
9
+ 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
+ );
26
+ 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 (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
+ };
76
+ };
77
+ var ErrorBoundary = (props) => {
78
+ const spaGuardState = useSpaGuardState();
79
+ return /* @__PURE__ */ jsx(ErrorBoundaryImpl, { ...props, spaGuardState });
80
+ };
81
+ export {
82
+ ErrorBoundary
83
+ };
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@ovineko/spa-guard-react",
3
+ "version": "0.0.1-alpha-18",
4
+ "description": "React hooks, components, and error boundaries for spa-guard",
5
+ "keywords": [
6
+ "spa",
7
+ "react",
8
+ "error-boundary",
9
+ "chunk-load-error"
10
+ ],
11
+ "homepage": "https://github.com/ovineko/ovineko/tree/main/spa-guard/react",
12
+ "bugs": {
13
+ "url": "https://github.com/ovineko/ovineko/issues"
14
+ },
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/ovineko/ovineko.git",
18
+ "directory": "spa-guard/react"
19
+ },
20
+ "license": "MIT",
21
+ "author": "Alexander Svinarev <shibanet0@gmail.com> (shibanet0.com)",
22
+ "sideEffects": false,
23
+ "type": "module",
24
+ "exports": {
25
+ ".": {
26
+ "types": "./dist/react/index.d.ts",
27
+ "default": "./dist/react/index.js"
28
+ },
29
+ "./error-boundary": {
30
+ "types": "./dist/react-error-boundary/index.d.ts",
31
+ "default": "./dist/react-error-boundary/index.js"
32
+ }
33
+ },
34
+ "files": [
35
+ "dist",
36
+ "README.md"
37
+ ],
38
+ "dependencies": {
39
+ "@ovineko/spa-guard": "0.0.1-alpha-18"
40
+ },
41
+ "peerDependencies": {
42
+ "react": "^19"
43
+ },
44
+ "engines": {
45
+ "node": ">=22.15.0"
46
+ },
47
+ "publishConfig": {
48
+ "access": "public"
49
+ }
50
+ }