@goliapkg/sentori-react 0.1.0 → 0.3.0
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/lib/SentoriErrorBoundary.d.ts +17 -11
- package/lib/SentoriErrorBoundary.d.ts.map +1 -1
- package/lib/SentoriErrorBoundary.js +24 -7
- package/lib/SentoriErrorBoundary.js.map +1 -1
- package/lib/SentoriSuspense.d.ts +29 -0
- package/lib/SentoriSuspense.d.ts.map +1 -0
- package/lib/SentoriSuspense.js +21 -0
- package/lib/SentoriSuspense.js.map +1 -0
- package/lib/router.d.ts +19 -0
- package/lib/router.d.ts.map +1 -0
- package/lib/router.js +34 -0
- package/lib/router.js.map +1 -0
- package/package.json +14 -3
- package/src/SentoriErrorBoundary.tsx +37 -10
- package/src/SentoriSuspense.tsx +38 -0
- package/src/__tests__/SentoriErrorBoundary.test.tsx +168 -9
- package/src/__tests__/SentoriSuspense.test.tsx +78 -0
- package/src/__tests__/router.test.tsx +76 -0
- package/src/router.ts +37 -0
|
@@ -1,20 +1,26 @@
|
|
|
1
1
|
import { type ErrorInfo, type ReactNode } from 'react';
|
|
2
|
+
type FallbackRender = (props: {
|
|
3
|
+
error: Error;
|
|
4
|
+
reset: () => void;
|
|
5
|
+
}) => ReactNode;
|
|
2
6
|
type Props = {
|
|
3
7
|
children: ReactNode;
|
|
4
|
-
/**
|
|
5
|
-
*
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Rendered after an error is caught. Either a plain ReactNode
|
|
10
|
+
* (most common — a static error screen) or a render-prop that
|
|
11
|
+
* receives the error and a `reset` callback so the fallback can
|
|
12
|
+
* offer a retry button.
|
|
13
|
+
*/
|
|
14
|
+
fallback: FallbackRender | ReactNode;
|
|
10
15
|
/** Optional additional logging hook. Runs after Sentori capture. */
|
|
11
16
|
onError?: (error: Error, info: ErrorInfo) => void;
|
|
17
|
+
/**
|
|
18
|
+
* Shallow-compared on update. Any change resets the boundary,
|
|
19
|
+
* letting parents recover from a caught error by passing fresh
|
|
20
|
+
* keys (e.g. a route path, a query key, a user id).
|
|
21
|
+
*/
|
|
22
|
+
resetKeys?: unknown[];
|
|
12
23
|
};
|
|
13
|
-
/**
|
|
14
|
-
* Wraps `<SentoriErrorBoundaryInner>` so we can grab the capture
|
|
15
|
-
* function from context — class components can't use hooks directly,
|
|
16
|
-
* but they CAN receive props from a thin functional wrapper.
|
|
17
|
-
*/
|
|
18
24
|
export declare function SentoriErrorBoundary(props: Props): import("react/jsx-runtime").JSX.Element;
|
|
19
25
|
export {};
|
|
20
26
|
//# sourceMappingURL=SentoriErrorBoundary.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SentoriErrorBoundary.d.ts","sourceRoot":"","sources":["../src/SentoriErrorBoundary.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAa,KAAK,SAAS,EAAE,KAAK,SAAS,EAAE,MAAM,OAAO,CAAA;AAIjE,KAAK,
|
|
1
|
+
{"version":3,"file":"SentoriErrorBoundary.d.ts","sourceRoot":"","sources":["../src/SentoriErrorBoundary.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAa,KAAK,SAAS,EAAE,KAAK,SAAS,EAAE,MAAM,OAAO,CAAA;AAIjE,KAAK,cAAc,GAAG,CAAC,KAAK,EAAE;IAAE,KAAK,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,IAAI,CAAA;CAAE,KAAK,SAAS,CAAA;AAE/E,KAAK,KAAK,GAAG;IACX,QAAQ,EAAE,SAAS,CAAA;IACnB;;;;;OAKG;IACH,QAAQ,EAAE,cAAc,GAAG,SAAS,CAAA;IACpC,oEAAoE;IACpE,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,SAAS,KAAK,IAAI,CAAA;IACjD;;;;OAIG;IACH,SAAS,CAAC,EAAE,OAAO,EAAE,CAAA;CACtB,CAAA;AAID,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,KAAK,2CAWhD"}
|
|
@@ -1,11 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { Component } from 'react';
|
|
3
3
|
import { useSentoriCtx } from './SentoriProvider.js';
|
|
4
|
-
/**
|
|
5
|
-
* Wraps `<SentoriErrorBoundaryInner>` so we can grab the capture
|
|
6
|
-
* function from context — class components can't use hooks directly,
|
|
7
|
-
* but they CAN receive props from a thin functional wrapper.
|
|
8
|
-
*/
|
|
9
4
|
export function SentoriErrorBoundary(props) {
|
|
10
5
|
const { captureError } = useSentoriCtx();
|
|
11
6
|
return (_jsx(SentoriErrorBoundaryInner, { ...props, capture: (err, info) => {
|
|
@@ -21,12 +16,34 @@ class SentoriErrorBoundaryInner extends Component {
|
|
|
21
16
|
componentDidCatch(error, info) {
|
|
22
17
|
this.props.capture(error, info);
|
|
23
18
|
}
|
|
19
|
+
componentDidUpdate(prev) {
|
|
20
|
+
if (this.state.error && resetKeysChanged(prev.resetKeys, this.props.resetKeys)) {
|
|
21
|
+
this.setState({ error: null });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
24
|
reset = () => this.setState({ error: null });
|
|
25
25
|
render() {
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
const { error } = this.state;
|
|
27
|
+
if (error) {
|
|
28
|
+
const { fallback } = this.props;
|
|
29
|
+
return typeof fallback === 'function'
|
|
30
|
+
? fallback({ error, reset: this.reset })
|
|
31
|
+
: fallback;
|
|
28
32
|
}
|
|
29
33
|
return this.props.children;
|
|
30
34
|
}
|
|
31
35
|
}
|
|
36
|
+
function resetKeysChanged(prev, next) {
|
|
37
|
+
if (prev === next)
|
|
38
|
+
return false;
|
|
39
|
+
if (!prev || !next)
|
|
40
|
+
return prev !== next;
|
|
41
|
+
if (prev.length !== next.length)
|
|
42
|
+
return true;
|
|
43
|
+
for (let i = 0; i < prev.length; i++) {
|
|
44
|
+
if (!Object.is(prev[i], next[i]))
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
32
49
|
//# sourceMappingURL=SentoriErrorBoundary.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SentoriErrorBoundary.js","sourceRoot":"","sources":["../src/SentoriErrorBoundary.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,SAAS,EAAkC,MAAM,OAAO,CAAA;AAEjE,OAAO,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAA;
|
|
1
|
+
{"version":3,"file":"SentoriErrorBoundary.js","sourceRoot":"","sources":["../src/SentoriErrorBoundary.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,SAAS,EAAkC,MAAM,OAAO,CAAA;AAEjE,OAAO,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAA;AAyBpD,MAAM,UAAU,oBAAoB,CAAC,KAAY;IAC/C,MAAM,EAAE,YAAY,EAAE,GAAG,aAAa,EAAE,CAAA;IACxC,OAAO,CACL,KAAC,yBAAyB,OACpB,KAAK,EACT,OAAO,EAAE,CAAC,GAAG,EAAE,IAAI,EAAE,EAAE;YACrB,YAAY,CAAC,GAAG,EAAE,EAAE,IAAI,EAAE,EAAE,MAAM,EAAE,qBAAqB,EAAE,EAAE,CAAC,CAAA;YAC9D,KAAK,CAAC,OAAO,EAAE,CAAC,GAAG,EAAE,IAAI,CAAC,CAAA;QAC5B,CAAC,GACD,CACH,CAAA;AACH,CAAC;AAED,MAAM,yBAA0B,SAAQ,SAGvC;IACC,KAAK,GAAU,EAAE,KAAK,EAAE,IAAI,EAAE,CAAA;IAE9B,MAAM,CAAC,wBAAwB,CAAC,KAAY;QAC1C,OAAO,EAAE,KAAK,EAAE,CAAA;IAClB,CAAC;IAED,iBAAiB,CAAC,KAAY,EAAE,IAAe;QAC7C,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,EAAE,IAAI,CAAC,CAAA;IACjC,CAAC;IAED,kBAAkB,CAAC,IAAqB;QACtC,IAAI,IAAI,CAAC,KAAK,CAAC,KAAK,IAAI,gBAAgB,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC,EAAE,CAAC;YAC/E,IAAI,CAAC,QAAQ,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAA;QAChC,CAAC;IACH,CAAC;IAED,KAAK,GAAG,GAAS,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAA;IAElD,MAAM;QACJ,MAAM,EAAE,KAAK,EAAE,GAAG,IAAI,CAAC,KAAK,CAAA;QAC5B,IAAI,KAAK,EAAE,CAAC;YACV,MAAM,EAAE,QAAQ,EAAE,GAAG,IAAI,CAAC,KAAK,CAAA;YAC/B,OAAO,OAAO,QAAQ,KAAK,UAAU;gBACnC,CAAC,CAAE,QAA2B,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,CAAC;gBAC5D,CAAC,CAAC,QAAQ,CAAA;QACd,CAAC;QACD,OAAO,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAA;IAC5B,CAAC;CACF;AAED,SAAS,gBAAgB,CAAC,IAAgB,EAAE,IAAgB;IAC1D,IAAI,IAAI,KAAK,IAAI;QAAE,OAAO,KAAK,CAAA;IAC/B,IAAI,CAAC,IAAI,IAAI,CAAC,IAAI;QAAE,OAAO,IAAI,KAAK,IAAI,CAAA;IACxC,IAAI,IAAI,CAAC,MAAM,KAAK,IAAI,CAAC,MAAM;QAAE,OAAO,IAAI,CAAA;IAC5C,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QACrC,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC;YAAE,OAAO,IAAI,CAAA;IAC/C,CAAC;IACD,OAAO,KAAK,CAAA;AACd,CAAC"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
type FallbackRender = (props: {
|
|
3
|
+
error: Error;
|
|
4
|
+
reset: () => void;
|
|
5
|
+
}) => ReactNode;
|
|
6
|
+
/**
|
|
7
|
+
* `<Suspense>` + `<SentoriErrorBoundary>` composed together. Any
|
|
8
|
+
* error thrown during render, whether it's a synchronous throw or a
|
|
9
|
+
* rejected promise surfaced through Suspense, is caught by the
|
|
10
|
+
* inner boundary and forwarded to `captureError`.
|
|
11
|
+
*
|
|
12
|
+
* Use when you want a one-liner around a data-fetching subtree —
|
|
13
|
+
* the loading state and the error state share the same fallback by
|
|
14
|
+
* default; pass `errorFallback` if they need to differ.
|
|
15
|
+
*
|
|
16
|
+
* <SentoriSuspense fallback={<Skeleton />} errorFallback={<ErrorCard />}>
|
|
17
|
+
* <UserProfile />
|
|
18
|
+
* </SentoriSuspense>
|
|
19
|
+
*/
|
|
20
|
+
export declare function SentoriSuspense({ children, errorFallback, fallback, }: {
|
|
21
|
+
children: ReactNode;
|
|
22
|
+
/** Optional separate fallback for caught errors. Falls back to
|
|
23
|
+
* `fallback` if not provided. */
|
|
24
|
+
errorFallback?: FallbackRender | ReactNode;
|
|
25
|
+
/** Loading state shown by the inner `<Suspense>`. */
|
|
26
|
+
fallback: ReactNode;
|
|
27
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
28
|
+
export {};
|
|
29
|
+
//# sourceMappingURL=SentoriSuspense.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"SentoriSuspense.d.ts","sourceRoot":"","sources":["../src/SentoriSuspense.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAY,KAAK,SAAS,EAAE,MAAM,OAAO,CAAA;AAIhD,KAAK,cAAc,GAAG,CAAC,KAAK,EAAE;IAAE,KAAK,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,IAAI,CAAA;CAAE,KAAK,SAAS,CAAA;AAE/E;;;;;;;;;;;;;GAaG;AACH,wBAAgB,eAAe,CAAC,EAC9B,QAAQ,EACR,aAAa,EACb,QAAQ,GACT,EAAE;IACD,QAAQ,EAAE,SAAS,CAAA;IACnB;sCACkC;IAClC,aAAa,CAAC,EAAE,cAAc,GAAG,SAAS,CAAA;IAC1C,qDAAqD;IACrD,QAAQ,EAAE,SAAS,CAAA;CACpB,2CAMA"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Suspense } from 'react';
|
|
3
|
+
import { SentoriErrorBoundary } from './SentoriErrorBoundary.js';
|
|
4
|
+
/**
|
|
5
|
+
* `<Suspense>` + `<SentoriErrorBoundary>` composed together. Any
|
|
6
|
+
* error thrown during render, whether it's a synchronous throw or a
|
|
7
|
+
* rejected promise surfaced through Suspense, is caught by the
|
|
8
|
+
* inner boundary and forwarded to `captureError`.
|
|
9
|
+
*
|
|
10
|
+
* Use when you want a one-liner around a data-fetching subtree —
|
|
11
|
+
* the loading state and the error state share the same fallback by
|
|
12
|
+
* default; pass `errorFallback` if they need to differ.
|
|
13
|
+
*
|
|
14
|
+
* <SentoriSuspense fallback={<Skeleton />} errorFallback={<ErrorCard />}>
|
|
15
|
+
* <UserProfile />
|
|
16
|
+
* </SentoriSuspense>
|
|
17
|
+
*/
|
|
18
|
+
export function SentoriSuspense({ children, errorFallback, fallback, }) {
|
|
19
|
+
return (_jsx(SentoriErrorBoundary, { fallback: errorFallback ?? fallback, children: _jsx(Suspense, { fallback: fallback, children: children }) }));
|
|
20
|
+
}
|
|
21
|
+
//# sourceMappingURL=SentoriSuspense.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"SentoriSuspense.js","sourceRoot":"","sources":["../src/SentoriSuspense.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,QAAQ,EAAkB,MAAM,OAAO,CAAA;AAEhD,OAAO,EAAE,oBAAoB,EAAE,MAAM,2BAA2B,CAAA;AAIhE;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,eAAe,CAAC,EAC9B,QAAQ,EACR,aAAa,EACb,QAAQ,GAQT;IACC,OAAO,CACL,KAAC,oBAAoB,IAAC,QAAQ,EAAE,aAAa,IAAI,QAAQ,YACvD,KAAC,QAAQ,IAAC,QAAQ,EAAE,QAAQ,YAAG,QAAQ,GAAY,GAC9B,CACxB,CAAA;AACH,CAAC"}
|
package/lib/router.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subscribe to `react-router` navigation and push a `nav` breadcrumb
|
|
3
|
+
* on every pathname/search/hash change. Mount once high in the tree
|
|
4
|
+
* (inside the `Router` and inside `SentoriProvider`):
|
|
5
|
+
*
|
|
6
|
+
* function AppShell() {
|
|
7
|
+
* useSentoriRouter()
|
|
8
|
+
* return <Outlet />
|
|
9
|
+
* }
|
|
10
|
+
*
|
|
11
|
+
* The first render does NOT emit a breadcrumb — only actual
|
|
12
|
+
* transitions are recorded.
|
|
13
|
+
*
|
|
14
|
+
* Peer dependency: `react-router >= 7`. This hook is in a separate
|
|
15
|
+
* entry point so apps not using react-router don't pay the import
|
|
16
|
+
* cost or trip a missing-module error.
|
|
17
|
+
*/
|
|
18
|
+
export declare function useSentoriRouter(): void;
|
|
19
|
+
//# sourceMappingURL=router.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"router.d.ts","sourceRoot":"","sources":["../src/router.ts"],"names":[],"mappings":"AAKA;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,gBAAgB,IAAI,IAAI,CAcvC"}
|
package/lib/router.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
import { useLocation } from 'react-router';
|
|
3
|
+
import { useSentoriCtx } from './SentoriProvider.js';
|
|
4
|
+
/**
|
|
5
|
+
* Subscribe to `react-router` navigation and push a `nav` breadcrumb
|
|
6
|
+
* on every pathname/search/hash change. Mount once high in the tree
|
|
7
|
+
* (inside the `Router` and inside `SentoriProvider`):
|
|
8
|
+
*
|
|
9
|
+
* function AppShell() {
|
|
10
|
+
* useSentoriRouter()
|
|
11
|
+
* return <Outlet />
|
|
12
|
+
* }
|
|
13
|
+
*
|
|
14
|
+
* The first render does NOT emit a breadcrumb — only actual
|
|
15
|
+
* transitions are recorded.
|
|
16
|
+
*
|
|
17
|
+
* Peer dependency: `react-router >= 7`. This hook is in a separate
|
|
18
|
+
* entry point so apps not using react-router don't pay the import
|
|
19
|
+
* cost or trip a missing-module error.
|
|
20
|
+
*/
|
|
21
|
+
export function useSentoriRouter() {
|
|
22
|
+
const { addBreadcrumb } = useSentoriCtx();
|
|
23
|
+
const location = useLocation();
|
|
24
|
+
const prevRef = useRef(null);
|
|
25
|
+
const next = location.pathname + location.search + location.hash;
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
const prev = prevRef.current;
|
|
28
|
+
if (prev !== null && prev !== next) {
|
|
29
|
+
addBreadcrumb('nav', { from: prev, to: next });
|
|
30
|
+
}
|
|
31
|
+
prevRef.current = next;
|
|
32
|
+
}, [addBreadcrumb, next]);
|
|
33
|
+
}
|
|
34
|
+
//# sourceMappingURL=router.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"router.js","sourceRoot":"","sources":["../src/router.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,OAAO,CAAA;AACzC,OAAO,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAE1C,OAAO,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAA;AAEpD;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,UAAU,gBAAgB;IAC9B,MAAM,EAAE,aAAa,EAAE,GAAG,aAAa,EAAE,CAAA;IACzC,MAAM,QAAQ,GAAG,WAAW,EAAE,CAAA;IAC9B,MAAM,OAAO,GAAG,MAAM,CAAgB,IAAI,CAAC,CAAA;IAE3C,MAAM,IAAI,GAAG,QAAQ,CAAC,QAAQ,GAAG,QAAQ,CAAC,MAAM,GAAG,QAAQ,CAAC,IAAI,CAAA;IAEhE,SAAS,CAAC,GAAG,EAAE;QACb,MAAM,IAAI,GAAG,OAAO,CAAC,OAAO,CAAA;QAC5B,IAAI,IAAI,KAAK,IAAI,IAAI,IAAI,KAAK,IAAI,EAAE,CAAC;YACnC,aAAa,CAAC,KAAK,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC,CAAA;QAChD,CAAC;QACD,OAAO,CAAC,OAAO,GAAG,IAAI,CAAA;IACxB,CAAC,EAAE,CAAC,aAAa,EAAE,IAAI,CAAC,CAAC,CAAA;AAC3B,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goliapkg/sentori-react",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "React adapter for Sentori — Provider, ErrorBoundary, and hooks built on @goliapkg/sentori-javascript.",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "React adapter for Sentori — Provider, ErrorBoundary (resetKeys + render-prop), Suspense, react-router breadcrumbs, and hooks built on @goliapkg/sentori-javascript.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://sentori.golia.jp",
|
|
7
7
|
"repository": {
|
|
@@ -25,6 +25,10 @@
|
|
|
25
25
|
".": {
|
|
26
26
|
"types": "./lib/index.d.ts",
|
|
27
27
|
"default": "./lib/index.js"
|
|
28
|
+
},
|
|
29
|
+
"./router": {
|
|
30
|
+
"types": "./lib/router.d.ts",
|
|
31
|
+
"default": "./lib/router.js"
|
|
28
32
|
}
|
|
29
33
|
},
|
|
30
34
|
"files": [
|
|
@@ -39,7 +43,13 @@
|
|
|
39
43
|
"prepack": "bun run build"
|
|
40
44
|
},
|
|
41
45
|
"peerDependencies": {
|
|
42
|
-
"react": ">=18"
|
|
46
|
+
"react": ">=18",
|
|
47
|
+
"react-router": ">=7"
|
|
48
|
+
},
|
|
49
|
+
"peerDependenciesMeta": {
|
|
50
|
+
"react-router": {
|
|
51
|
+
"optional": true
|
|
52
|
+
}
|
|
43
53
|
},
|
|
44
54
|
"dependencies": {
|
|
45
55
|
"@goliapkg/sentori-core": "0.1.0",
|
|
@@ -52,6 +62,7 @@
|
|
|
52
62
|
"@types/react": "^19",
|
|
53
63
|
"react": "^19",
|
|
54
64
|
"react-dom": "^19",
|
|
65
|
+
"react-router": "^7",
|
|
55
66
|
"typescript": "^5"
|
|
56
67
|
},
|
|
57
68
|
"publishConfig": {
|
|
@@ -2,22 +2,29 @@ import { Component, type ErrorInfo, type ReactNode } from 'react'
|
|
|
2
2
|
|
|
3
3
|
import { useSentoriCtx } from './SentoriProvider.js'
|
|
4
4
|
|
|
5
|
+
type FallbackRender = (props: { error: Error; reset: () => void }) => ReactNode
|
|
6
|
+
|
|
5
7
|
type Props = {
|
|
6
8
|
children: ReactNode
|
|
7
|
-
/**
|
|
8
|
-
*
|
|
9
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Rendered after an error is caught. Either a plain ReactNode
|
|
11
|
+
* (most common — a static error screen) or a render-prop that
|
|
12
|
+
* receives the error and a `reset` callback so the fallback can
|
|
13
|
+
* offer a retry button.
|
|
14
|
+
*/
|
|
15
|
+
fallback: FallbackRender | ReactNode
|
|
10
16
|
/** Optional additional logging hook. Runs after Sentori capture. */
|
|
11
17
|
onError?: (error: Error, info: ErrorInfo) => void
|
|
18
|
+
/**
|
|
19
|
+
* Shallow-compared on update. Any change resets the boundary,
|
|
20
|
+
* letting parents recover from a caught error by passing fresh
|
|
21
|
+
* keys (e.g. a route path, a query key, a user id).
|
|
22
|
+
*/
|
|
23
|
+
resetKeys?: unknown[]
|
|
12
24
|
}
|
|
13
25
|
|
|
14
26
|
type State = { error: Error | null }
|
|
15
27
|
|
|
16
|
-
/**
|
|
17
|
-
* Wraps `<SentoriErrorBoundaryInner>` so we can grab the capture
|
|
18
|
-
* function from context — class components can't use hooks directly,
|
|
19
|
-
* but they CAN receive props from a thin functional wrapper.
|
|
20
|
-
*/
|
|
21
28
|
export function SentoriErrorBoundary(props: Props) {
|
|
22
29
|
const { captureError } = useSentoriCtx()
|
|
23
30
|
return (
|
|
@@ -45,12 +52,32 @@ class SentoriErrorBoundaryInner extends Component<
|
|
|
45
52
|
this.props.capture(error, info)
|
|
46
53
|
}
|
|
47
54
|
|
|
55
|
+
componentDidUpdate(prev: Readonly<Props>): void {
|
|
56
|
+
if (this.state.error && resetKeysChanged(prev.resetKeys, this.props.resetKeys)) {
|
|
57
|
+
this.setState({ error: null })
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
48
61
|
reset = (): void => this.setState({ error: null })
|
|
49
62
|
|
|
50
63
|
render(): ReactNode {
|
|
51
|
-
|
|
52
|
-
|
|
64
|
+
const { error } = this.state
|
|
65
|
+
if (error) {
|
|
66
|
+
const { fallback } = this.props
|
|
67
|
+
return typeof fallback === 'function'
|
|
68
|
+
? (fallback as FallbackRender)({ error, reset: this.reset })
|
|
69
|
+
: fallback
|
|
53
70
|
}
|
|
54
71
|
return this.props.children
|
|
55
72
|
}
|
|
56
73
|
}
|
|
74
|
+
|
|
75
|
+
function resetKeysChanged(prev?: unknown[], next?: unknown[]): boolean {
|
|
76
|
+
if (prev === next) return false
|
|
77
|
+
if (!prev || !next) return prev !== next
|
|
78
|
+
if (prev.length !== next.length) return true
|
|
79
|
+
for (let i = 0; i < prev.length; i++) {
|
|
80
|
+
if (!Object.is(prev[i], next[i])) return true
|
|
81
|
+
}
|
|
82
|
+
return false
|
|
83
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Suspense, type ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
import { SentoriErrorBoundary } from './SentoriErrorBoundary.js'
|
|
4
|
+
|
|
5
|
+
type FallbackRender = (props: { error: Error; reset: () => void }) => ReactNode
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `<Suspense>` + `<SentoriErrorBoundary>` composed together. Any
|
|
9
|
+
* error thrown during render, whether it's a synchronous throw or a
|
|
10
|
+
* rejected promise surfaced through Suspense, is caught by the
|
|
11
|
+
* inner boundary and forwarded to `captureError`.
|
|
12
|
+
*
|
|
13
|
+
* Use when you want a one-liner around a data-fetching subtree —
|
|
14
|
+
* the loading state and the error state share the same fallback by
|
|
15
|
+
* default; pass `errorFallback` if they need to differ.
|
|
16
|
+
*
|
|
17
|
+
* <SentoriSuspense fallback={<Skeleton />} errorFallback={<ErrorCard />}>
|
|
18
|
+
* <UserProfile />
|
|
19
|
+
* </SentoriSuspense>
|
|
20
|
+
*/
|
|
21
|
+
export function SentoriSuspense({
|
|
22
|
+
children,
|
|
23
|
+
errorFallback,
|
|
24
|
+
fallback,
|
|
25
|
+
}: {
|
|
26
|
+
children: ReactNode
|
|
27
|
+
/** Optional separate fallback for caught errors. Falls back to
|
|
28
|
+
* `fallback` if not provided. */
|
|
29
|
+
errorFallback?: FallbackRender | ReactNode
|
|
30
|
+
/** Loading state shown by the inner `<Suspense>`. */
|
|
31
|
+
fallback: ReactNode
|
|
32
|
+
}) {
|
|
33
|
+
return (
|
|
34
|
+
<SentoriErrorBoundary fallback={errorFallback ?? fallback}>
|
|
35
|
+
<Suspense fallback={fallback}>{children}</Suspense>
|
|
36
|
+
</SentoriErrorBoundary>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { render, screen } from '@testing-library/react'
|
|
1
|
+
import { fireEvent, render, screen } from '@testing-library/react'
|
|
2
2
|
import { describe, expect, test } from 'bun:test'
|
|
3
|
+
import { useState } from 'react'
|
|
3
4
|
|
|
4
5
|
import { SentoriErrorBoundary } from '../SentoriErrorBoundary.js'
|
|
5
6
|
import { SentoriProvider } from '../SentoriProvider.js'
|
|
@@ -17,6 +18,19 @@ const Boom = (): never => {
|
|
|
17
18
|
throw new Error('boom-from-render')
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
// React logs an "uncaught error" to console.error when a boundary
|
|
22
|
+
// catches; silence that during render-throw tests so the test output
|
|
23
|
+
// stays readable.
|
|
24
|
+
function silenceConsoleErrorDuring<T>(fn: () => T): T {
|
|
25
|
+
const original = console.error
|
|
26
|
+
console.error = () => {}
|
|
27
|
+
try {
|
|
28
|
+
return fn()
|
|
29
|
+
} finally {
|
|
30
|
+
console.error = original
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
20
34
|
describe('SentoriErrorBoundary', () => {
|
|
21
35
|
test('renders children when nothing throws', () => {
|
|
22
36
|
render(
|
|
@@ -29,11 +43,8 @@ describe('SentoriErrorBoundary', () => {
|
|
|
29
43
|
expect(screen.getByText('ok')).toBeDefined()
|
|
30
44
|
})
|
|
31
45
|
|
|
32
|
-
test('renders fallback when child throws', () => {
|
|
33
|
-
|
|
34
|
-
const original = console.error
|
|
35
|
-
console.error = () => {}
|
|
36
|
-
try {
|
|
46
|
+
test('renders fallback render-prop when child throws', () => {
|
|
47
|
+
silenceConsoleErrorDuring(() => {
|
|
37
48
|
render(
|
|
38
49
|
<SentoriProvider {...PROVIDER_PROPS}>
|
|
39
50
|
<SentoriErrorBoundary
|
|
@@ -43,9 +54,157 @@ describe('SentoriErrorBoundary', () => {
|
|
|
43
54
|
</SentoriErrorBoundary>
|
|
44
55
|
</SentoriProvider>,
|
|
45
56
|
)
|
|
46
|
-
}
|
|
47
|
-
console.error = original
|
|
48
|
-
}
|
|
57
|
+
})
|
|
49
58
|
expect(screen.getByText('caught: boom-from-render')).toBeDefined()
|
|
50
59
|
})
|
|
60
|
+
|
|
61
|
+
test('accepts a ReactNode fallback (no render-prop)', () => {
|
|
62
|
+
silenceConsoleErrorDuring(() => {
|
|
63
|
+
render(
|
|
64
|
+
<SentoriProvider {...PROVIDER_PROPS}>
|
|
65
|
+
<SentoriErrorBoundary fallback={<div>static-fallback</div>}>
|
|
66
|
+
<Boom />
|
|
67
|
+
</SentoriErrorBoundary>
|
|
68
|
+
</SentoriProvider>,
|
|
69
|
+
)
|
|
70
|
+
})
|
|
71
|
+
expect(screen.getByText('static-fallback')).toBeDefined()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('reset() callback clears the caught error', () => {
|
|
75
|
+
// A child that throws on the first render and renders cleanly
|
|
76
|
+
// once a flag flips. Pressing the fallback's Retry button calls
|
|
77
|
+
// reset() AND flips the flag, so the boundary re-renders
|
|
78
|
+
// children without a re-throw.
|
|
79
|
+
function FlakyChild({ shouldThrow }: { shouldThrow: boolean }) {
|
|
80
|
+
if (shouldThrow) throw new Error('flaky-boom')
|
|
81
|
+
return <div>recovered</div>
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function Harness() {
|
|
85
|
+
const [throws, setThrows] = useState(true)
|
|
86
|
+
return (
|
|
87
|
+
<SentoriErrorBoundary
|
|
88
|
+
fallback={({ reset }) => (
|
|
89
|
+
<button
|
|
90
|
+
onClick={() => {
|
|
91
|
+
setThrows(false)
|
|
92
|
+
reset()
|
|
93
|
+
}}
|
|
94
|
+
type="button"
|
|
95
|
+
>
|
|
96
|
+
Retry
|
|
97
|
+
</button>
|
|
98
|
+
)}
|
|
99
|
+
>
|
|
100
|
+
<FlakyChild shouldThrow={throws} />
|
|
101
|
+
</SentoriErrorBoundary>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
silenceConsoleErrorDuring(() => {
|
|
106
|
+
render(
|
|
107
|
+
<SentoriProvider {...PROVIDER_PROPS}>
|
|
108
|
+
<Harness />
|
|
109
|
+
</SentoriProvider>,
|
|
110
|
+
)
|
|
111
|
+
})
|
|
112
|
+
expect(screen.getByText('Retry')).toBeDefined()
|
|
113
|
+
silenceConsoleErrorDuring(() => fireEvent.click(screen.getByText('Retry')))
|
|
114
|
+
expect(screen.getByText('recovered')).toBeDefined()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('resetKeys change clears the caught error automatically', () => {
|
|
118
|
+
function FlakyChild({ shouldThrow }: { shouldThrow: boolean }) {
|
|
119
|
+
if (shouldThrow) throw new Error('flaky-boom')
|
|
120
|
+
return <div>recovered-by-keys</div>
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function Harness() {
|
|
124
|
+
const [keyA, setKeyA] = useState('one')
|
|
125
|
+
const [throws, setThrows] = useState(true)
|
|
126
|
+
return (
|
|
127
|
+
<>
|
|
128
|
+
<button
|
|
129
|
+
onClick={() => {
|
|
130
|
+
setThrows(false)
|
|
131
|
+
setKeyA('two')
|
|
132
|
+
}}
|
|
133
|
+
type="button"
|
|
134
|
+
>
|
|
135
|
+
Switch
|
|
136
|
+
</button>
|
|
137
|
+
<SentoriErrorBoundary
|
|
138
|
+
fallback={<div>error-state</div>}
|
|
139
|
+
resetKeys={[keyA]}
|
|
140
|
+
>
|
|
141
|
+
<FlakyChild shouldThrow={throws} />
|
|
142
|
+
</SentoriErrorBoundary>
|
|
143
|
+
</>
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
silenceConsoleErrorDuring(() => {
|
|
148
|
+
render(
|
|
149
|
+
<SentoriProvider {...PROVIDER_PROPS}>
|
|
150
|
+
<Harness />
|
|
151
|
+
</SentoriProvider>,
|
|
152
|
+
)
|
|
153
|
+
})
|
|
154
|
+
expect(screen.getByText('error-state')).toBeDefined()
|
|
155
|
+
silenceConsoleErrorDuring(() => fireEvent.click(screen.getByText('Switch')))
|
|
156
|
+
expect(screen.getByText('recovered-by-keys')).toBeDefined()
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test('onError receives the error and React ErrorInfo', () => {
|
|
160
|
+
let captured: { error: Error | null; info: unknown } = { error: null, info: null }
|
|
161
|
+
|
|
162
|
+
silenceConsoleErrorDuring(() => {
|
|
163
|
+
render(
|
|
164
|
+
<SentoriProvider {...PROVIDER_PROPS}>
|
|
165
|
+
<SentoriErrorBoundary
|
|
166
|
+
fallback={<div>fb</div>}
|
|
167
|
+
onError={(error, info) => {
|
|
168
|
+
captured = { error, info }
|
|
169
|
+
}}
|
|
170
|
+
>
|
|
171
|
+
<Boom />
|
|
172
|
+
</SentoriErrorBoundary>
|
|
173
|
+
</SentoriProvider>,
|
|
174
|
+
)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
expect(captured.error?.message).toBe('boom-from-render')
|
|
178
|
+
expect(captured.info).toBeDefined()
|
|
179
|
+
// React 19's ErrorInfo at minimum has componentStack.
|
|
180
|
+
expect((captured.info as { componentStack?: string }).componentStack).toBeDefined()
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test('inner boundary catches without bubbling to outer', () => {
|
|
184
|
+
let outerSawError = false
|
|
185
|
+
|
|
186
|
+
silenceConsoleErrorDuring(() => {
|
|
187
|
+
render(
|
|
188
|
+
<SentoriProvider {...PROVIDER_PROPS}>
|
|
189
|
+
<SentoriErrorBoundary
|
|
190
|
+
fallback={<div>outer-fb</div>}
|
|
191
|
+
onError={() => {
|
|
192
|
+
outerSawError = true
|
|
193
|
+
}}
|
|
194
|
+
>
|
|
195
|
+
<SentoriErrorBoundary fallback={<div>inner-fb</div>}>
|
|
196
|
+
<Boom />
|
|
197
|
+
</SentoriErrorBoundary>
|
|
198
|
+
<div>sibling-stays</div>
|
|
199
|
+
</SentoriErrorBoundary>
|
|
200
|
+
</SentoriProvider>,
|
|
201
|
+
)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
expect(screen.getByText('inner-fb')).toBeDefined()
|
|
205
|
+
// Sibling next to the inner boundary keeps rendering — the outer
|
|
206
|
+
// boundary's subtree is intact because the inner caught.
|
|
207
|
+
expect(screen.getByText('sibling-stays')).toBeDefined()
|
|
208
|
+
expect(outerSawError).toBe(false)
|
|
209
|
+
})
|
|
51
210
|
})
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
2
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
3
|
+
|
|
4
|
+
import { SentoriProvider } from '../SentoriProvider.js'
|
|
5
|
+
import { SentoriSuspense } from '../SentoriSuspense.js'
|
|
6
|
+
|
|
7
|
+
const PROVIDER_PROPS = {
|
|
8
|
+
config: {
|
|
9
|
+
environment: 'test',
|
|
10
|
+
ingestUrl: 'http://localhost:0',
|
|
11
|
+
release: 'test@0.0.0',
|
|
12
|
+
token: 'st_pk_testtesttesttesttesttesttest',
|
|
13
|
+
},
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function silenceConsoleErrorDuring<T>(fn: () => T): T {
|
|
17
|
+
const original = console.error
|
|
18
|
+
console.error = () => {}
|
|
19
|
+
try {
|
|
20
|
+
return fn()
|
|
21
|
+
} finally {
|
|
22
|
+
console.error = original
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('SentoriSuspense', () => {
|
|
27
|
+
afterEach(() => cleanup())
|
|
28
|
+
|
|
29
|
+
test('renders children when nothing throws', () => {
|
|
30
|
+
render(
|
|
31
|
+
<SentoriProvider {...PROVIDER_PROPS}>
|
|
32
|
+
<SentoriSuspense fallback={<div>loading</div>}>
|
|
33
|
+
<div>child-ok</div>
|
|
34
|
+
</SentoriSuspense>
|
|
35
|
+
</SentoriProvider>,
|
|
36
|
+
)
|
|
37
|
+
expect(screen.getByText('child-ok')).toBeDefined()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('synchronously thrown error inside is caught and renders errorFallback', () => {
|
|
41
|
+
function Boom(): never {
|
|
42
|
+
throw new Error('sync-boom')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
silenceConsoleErrorDuring(() => {
|
|
46
|
+
render(
|
|
47
|
+
<SentoriProvider {...PROVIDER_PROPS}>
|
|
48
|
+
<SentoriSuspense
|
|
49
|
+
errorFallback={<div>error-fallback</div>}
|
|
50
|
+
fallback={<div>loading</div>}
|
|
51
|
+
>
|
|
52
|
+
<Boom />
|
|
53
|
+
</SentoriSuspense>
|
|
54
|
+
</SentoriProvider>,
|
|
55
|
+
)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
expect(screen.getByText('error-fallback')).toBeDefined()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('errorFallback defaults to fallback when not provided', () => {
|
|
62
|
+
function Boom(): never {
|
|
63
|
+
throw new Error('sync-boom-default')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
silenceConsoleErrorDuring(() => {
|
|
67
|
+
render(
|
|
68
|
+
<SentoriProvider {...PROVIDER_PROPS}>
|
|
69
|
+
<SentoriSuspense fallback={<div>shared-fallback</div>}>
|
|
70
|
+
<Boom />
|
|
71
|
+
</SentoriSuspense>
|
|
72
|
+
</SentoriProvider>,
|
|
73
|
+
)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
expect(screen.getByText('shared-fallback')).toBeDefined()
|
|
77
|
+
})
|
|
78
|
+
})
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
3
|
+
import { Link, MemoryRouter, Route, Routes } from 'react-router'
|
|
4
|
+
|
|
5
|
+
import { clearBreadcrumbs, getBreadcrumbs } from '@goliapkg/sentori-javascript'
|
|
6
|
+
|
|
7
|
+
import { useSentoriRouter } from '../router.js'
|
|
8
|
+
import { SentoriProvider } from '../SentoriProvider.js'
|
|
9
|
+
|
|
10
|
+
const PROVIDER_PROPS = {
|
|
11
|
+
config: {
|
|
12
|
+
environment: 'test',
|
|
13
|
+
ingestUrl: 'http://localhost:0',
|
|
14
|
+
release: 'test@0.0.0',
|
|
15
|
+
token: 'st_pk_testtesttesttesttesttesttest',
|
|
16
|
+
},
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function Shell() {
|
|
20
|
+
useSentoriRouter()
|
|
21
|
+
return (
|
|
22
|
+
<>
|
|
23
|
+
<Link to="/orders">orders</Link>
|
|
24
|
+
<Link to="/billing">billing</Link>
|
|
25
|
+
<Routes>
|
|
26
|
+
<Route element={<div>home</div>} path="/" />
|
|
27
|
+
<Route element={<div>orders-page</div>} path="/orders" />
|
|
28
|
+
<Route element={<div>billing-page</div>} path="/billing" />
|
|
29
|
+
</Routes>
|
|
30
|
+
</>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('useSentoriRouter', () => {
|
|
35
|
+
beforeEach(() => clearBreadcrumbs())
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
cleanup()
|
|
38
|
+
clearBreadcrumbs()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('initial mount does NOT emit a nav breadcrumb', () => {
|
|
42
|
+
render(
|
|
43
|
+
<SentoriProvider {...PROVIDER_PROPS}>
|
|
44
|
+
<MemoryRouter initialEntries={['/']}>
|
|
45
|
+
<Shell />
|
|
46
|
+
</MemoryRouter>
|
|
47
|
+
</SentoriProvider>,
|
|
48
|
+
)
|
|
49
|
+
expect(screen.getByText('home')).toBeDefined()
|
|
50
|
+
expect(getBreadcrumbs().filter((b) => b.type === 'nav')).toHaveLength(0)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('navigation emits a nav breadcrumb with from/to', () => {
|
|
54
|
+
render(
|
|
55
|
+
<SentoriProvider {...PROVIDER_PROPS}>
|
|
56
|
+
<MemoryRouter initialEntries={['/']}>
|
|
57
|
+
<Shell />
|
|
58
|
+
</MemoryRouter>
|
|
59
|
+
</SentoriProvider>,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
fireEvent.click(screen.getByText('orders'))
|
|
63
|
+
expect(screen.getByText('orders-page')).toBeDefined()
|
|
64
|
+
|
|
65
|
+
const navs = getBreadcrumbs().filter((b) => b.type === 'nav')
|
|
66
|
+
expect(navs).toHaveLength(1)
|
|
67
|
+
expect(navs[0]?.data).toEqual({ from: '/', to: '/orders' })
|
|
68
|
+
|
|
69
|
+
fireEvent.click(screen.getByText('billing'))
|
|
70
|
+
expect(screen.getByText('billing-page')).toBeDefined()
|
|
71
|
+
|
|
72
|
+
const navsAfter = getBreadcrumbs().filter((b) => b.type === 'nav')
|
|
73
|
+
expect(navsAfter).toHaveLength(2)
|
|
74
|
+
expect(navsAfter[1]?.data).toEqual({ from: '/orders', to: '/billing' })
|
|
75
|
+
})
|
|
76
|
+
})
|
package/src/router.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react'
|
|
2
|
+
import { useLocation } from 'react-router'
|
|
3
|
+
|
|
4
|
+
import { useSentoriCtx } from './SentoriProvider.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Subscribe to `react-router` navigation and push a `nav` breadcrumb
|
|
8
|
+
* on every pathname/search/hash change. Mount once high in the tree
|
|
9
|
+
* (inside the `Router` and inside `SentoriProvider`):
|
|
10
|
+
*
|
|
11
|
+
* function AppShell() {
|
|
12
|
+
* useSentoriRouter()
|
|
13
|
+
* return <Outlet />
|
|
14
|
+
* }
|
|
15
|
+
*
|
|
16
|
+
* The first render does NOT emit a breadcrumb — only actual
|
|
17
|
+
* transitions are recorded.
|
|
18
|
+
*
|
|
19
|
+
* Peer dependency: `react-router >= 7`. This hook is in a separate
|
|
20
|
+
* entry point so apps not using react-router don't pay the import
|
|
21
|
+
* cost or trip a missing-module error.
|
|
22
|
+
*/
|
|
23
|
+
export function useSentoriRouter(): void {
|
|
24
|
+
const { addBreadcrumb } = useSentoriCtx()
|
|
25
|
+
const location = useLocation()
|
|
26
|
+
const prevRef = useRef<null | string>(null)
|
|
27
|
+
|
|
28
|
+
const next = location.pathname + location.search + location.hash
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
const prev = prevRef.current
|
|
32
|
+
if (prev !== null && prev !== next) {
|
|
33
|
+
addBreadcrumb('nav', { from: prev, to: next })
|
|
34
|
+
}
|
|
35
|
+
prevRef.current = next
|
|
36
|
+
}, [addBreadcrumb, next])
|
|
37
|
+
}
|