@goliapkg/sentori-react 0.1.0 → 0.4.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.
@@ -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
- /** Rendered after an error is caught. Receives the error and a
5
- * `reset` callback that clears the boundary so retries can run. */
6
- fallback: (props: {
7
- error: Error;
8
- reset: () => void;
9
- }) => ReactNode;
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,KAAK,GAAG;IACX,QAAQ,EAAE,SAAS,CAAA;IACnB;wEACoE;IACpE,QAAQ,EAAE,CAAC,KAAK,EAAE;QAAE,KAAK,EAAE,KAAK,CAAC;QAAC,KAAK,EAAE,MAAM,IAAI,CAAA;KAAE,KAAK,SAAS,CAAA;IACnE,oEAAoE;IACpE,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,SAAS,KAAK,IAAI,CAAA;CAClD,CAAA;AAID;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,KAAK,2CAWhD"}
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
- if (this.state.error) {
27
- return this.props.fallback({ error: this.state.error, reset: this.reset });
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;AAapD;;;;GAIG;AACH,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,KAAK,GAAG,GAAS,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAA;IAElD,MAAM;QACJ,IAAI,IAAI,CAAC,KAAK,CAAC,KAAK,EAAE,CAAC;YACrB,OAAO,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,CAAC,KAAK,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,CAAC,CAAA;QAC5E,CAAC;QACD,OAAO,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAA;IAC5B,CAAC;CACF"}
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"}
@@ -0,0 +1,39 @@
1
+ import { type ReactNode } from 'react';
2
+ /**
3
+ * Wrap a subtree so its mount-to-unmount lifespan becomes a
4
+ * `react.render` span. Useful for measuring how long a heavy
5
+ * component sat on screen — a data table, a chart, a Suspense'd
6
+ * data fetch:
7
+ *
8
+ * <TraceRender op="react.render" name="OrdersTable">
9
+ * <OrdersTable />
10
+ * </TraceRender>
11
+ *
12
+ * Implementation notes:
13
+ *
14
+ * - The span opens when the component renders for the first time
15
+ * (in the body of the function, before children render — so child
16
+ * spans pick this one up as their parent via `activeSpan()` if
17
+ * they're synchronous; React renders top-down but yields between
18
+ * commits, so async children won't necessarily attribute to this
19
+ * span unless they wrap with `withSpan`).
20
+ * - The span closes in a `useEffect` cleanup. That runs at unmount
21
+ * in normal mode, or twice in StrictMode (mount-unmount-mount).
22
+ * StrictMode double-invocation just emits the span twice; this is
23
+ * a known dev-mode artifact and matches how React's profiler
24
+ * accounts for it.
25
+ * - Re-renders due to prop / state change do NOT restart the span.
26
+ * The mount/unmount boundary is the lifespan. Callers wanting
27
+ * per-render timing should use `useRenderTrace` instead (TODO).
28
+ */
29
+ export declare function TraceRender({ children, data, name, op, tags, }: {
30
+ children: ReactNode;
31
+ /** Span data, attached at finish time. */
32
+ data?: Record<string, unknown>;
33
+ /** Defaults to `op`. */
34
+ name?: string;
35
+ /** Defaults to `react.render`. */
36
+ op?: string;
37
+ tags?: Record<string, string>;
38
+ }): ReactNode;
39
+ //# sourceMappingURL=SentoriTrace.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"SentoriTrace.d.ts","sourceRoot":"","sources":["../src/SentoriTrace.tsx"],"names":[],"mappings":"AAAA,OAAO,EAA8B,KAAK,SAAS,EAAE,MAAM,OAAO,CAAA;AAIlE;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,WAAW,CAAC,EAC1B,QAAQ,EACR,IAAI,EACJ,IAAI,EACJ,EAAmB,EACnB,IAAI,GACL,EAAE;IACD,QAAQ,EAAE,SAAS,CAAA;IACnB,0CAA0C;IAC1C,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAC9B,wBAAwB;IACxB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,kCAAkC;IAClC,EAAE,CAAC,EAAE,MAAM,CAAA;IACX,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAC9B,GAAG,SAAS,CA6BZ"}
@@ -0,0 +1,55 @@
1
+ import { useEffect, useMemo, useRef } from 'react';
2
+ import { startSpan } from '@goliapkg/sentori-core';
3
+ /**
4
+ * Wrap a subtree so its mount-to-unmount lifespan becomes a
5
+ * `react.render` span. Useful for measuring how long a heavy
6
+ * component sat on screen — a data table, a chart, a Suspense'd
7
+ * data fetch:
8
+ *
9
+ * <TraceRender op="react.render" name="OrdersTable">
10
+ * <OrdersTable />
11
+ * </TraceRender>
12
+ *
13
+ * Implementation notes:
14
+ *
15
+ * - The span opens when the component renders for the first time
16
+ * (in the body of the function, before children render — so child
17
+ * spans pick this one up as their parent via `activeSpan()` if
18
+ * they're synchronous; React renders top-down but yields between
19
+ * commits, so async children won't necessarily attribute to this
20
+ * span unless they wrap with `withSpan`).
21
+ * - The span closes in a `useEffect` cleanup. That runs at unmount
22
+ * in normal mode, or twice in StrictMode (mount-unmount-mount).
23
+ * StrictMode double-invocation just emits the span twice; this is
24
+ * a known dev-mode artifact and matches how React's profiler
25
+ * accounts for it.
26
+ * - Re-renders due to prop / state change do NOT restart the span.
27
+ * The mount/unmount boundary is the lifespan. Callers wanting
28
+ * per-render timing should use `useRenderTrace` instead (TODO).
29
+ */
30
+ export function TraceRender({ children, data, name, op = 'react.render', tags, }) {
31
+ // Lazy-init via useMemo so the span is created exactly once across
32
+ // re-renders. Returning the handle from useMemo also means the
33
+ // effect cleanup captures the same reference.
34
+ const span = useMemo(() => startSpan(op, { data, name: name ?? op, tags }),
35
+ // We deliberately do NOT include op/name/data/tags in deps —
36
+ // changing them after first render shouldn't reopen the span;
37
+ // the lifespan is "this component instance", not "these props".
38
+ // eslint-disable-next-line react-hooks/exhaustive-deps
39
+ []);
40
+ // The handle is alive across renders but should not be exposed to
41
+ // child components (they make their own spans). We hold it via ref
42
+ // so React's strict-mode-friendly invariants are preserved.
43
+ const spanRef = useRef(span);
44
+ spanRef.current = span;
45
+ useEffect(() => {
46
+ return () => {
47
+ // Finish on unmount. Second call is a no-op (SpanHandle's
48
+ // own contract), so StrictMode double-effects don't double-push.
49
+ spanRef.current?.finish({ status: 'ok' });
50
+ spanRef.current = null;
51
+ };
52
+ }, []);
53
+ return children;
54
+ }
55
+ //# sourceMappingURL=SentoriTrace.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"SentoriTrace.js","sourceRoot":"","sources":["../src/SentoriTrace.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,OAAO,EAAE,MAAM,EAAkB,MAAM,OAAO,CAAA;AAElE,OAAO,EAAE,SAAS,EAAmB,MAAM,wBAAwB,CAAA;AAEnE;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,MAAM,UAAU,WAAW,CAAC,EAC1B,QAAQ,EACR,IAAI,EACJ,IAAI,EACJ,EAAE,GAAG,cAAc,EACnB,IAAI,GAUL;IACC,mEAAmE;IACnE,+DAA+D;IAC/D,8CAA8C;IAC9C,MAAM,IAAI,GAAG,OAAO,CAClB,GAAG,EAAE,CAAC,SAAS,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,IAAI,EAAE,EAAE,IAAI,EAAE,CAAC;IACrD,6DAA6D;IAC7D,8DAA8D;IAC9D,gEAAgE;IAChE,uDAAuD;IACvD,EAAE,CACH,CAAA;IAED,kEAAkE;IAClE,mEAAmE;IACnE,4DAA4D;IAC5D,MAAM,OAAO,GAAG,MAAM,CAAoB,IAAI,CAAC,CAAA;IAC/C,OAAO,CAAC,OAAO,GAAG,IAAI,CAAA;IAEtB,SAAS,CAAC,GAAG,EAAE;QACb,OAAO,GAAG,EAAE;YACV,0DAA0D;YAC1D,iEAAiE;YACjE,OAAO,CAAC,OAAO,EAAE,MAAM,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAA;YACzC,OAAO,CAAC,OAAO,GAAG,IAAI,CAAA;QACxB,CAAC,CAAA;IACH,CAAC,EAAE,EAAE,CAAC,CAAA;IAEN,OAAO,QAAQ,CAAA;AACjB,CAAC"}
package/lib/index.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export { SentoriProvider } from './SentoriProvider.js';
2
2
  export { SentoriErrorBoundary } from './SentoriErrorBoundary.js';
3
+ export { SentoriSuspense } from './SentoriSuspense.js';
4
+ export { TraceRender } from './SentoriTrace.js';
3
5
  export { useSentori, useCaptureError } from './hooks.js';
4
6
  export type { Breadcrumb, BreadcrumbType, CaptureExtras, SentoriContextValue, SentoriReactConfig, Tags, User, } from './types.js';
5
7
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAA;AACtD,OAAO,EAAE,oBAAoB,EAAE,MAAM,2BAA2B,CAAA;AAChE,OAAO,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAExD,YAAY,EACV,UAAU,EACV,cAAc,EACd,aAAa,EACb,mBAAmB,EACnB,kBAAkB,EAClB,IAAI,EACJ,IAAI,GACL,MAAM,YAAY,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAA;AACtD,OAAO,EAAE,oBAAoB,EAAE,MAAM,2BAA2B,CAAA;AAChE,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAA;AACtD,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAC/C,OAAO,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAExD,YAAY,EACV,UAAU,EACV,cAAc,EACd,aAAa,EACb,mBAAmB,EACnB,kBAAkB,EAClB,IAAI,EACJ,IAAI,GACL,MAAM,YAAY,CAAA"}
package/lib/index.js CHANGED
@@ -1,4 +1,6 @@
1
1
  export { SentoriProvider } from './SentoriProvider.js';
2
2
  export { SentoriErrorBoundary } from './SentoriErrorBoundary.js';
3
+ export { SentoriSuspense } from './SentoriSuspense.js';
4
+ export { TraceRender } from './SentoriTrace.js';
3
5
  export { useSentori, useCaptureError } from './hooks.js';
4
6
  //# sourceMappingURL=index.js.map
package/lib/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAA;AACtD,OAAO,EAAE,oBAAoB,EAAE,MAAM,2BAA2B,CAAA;AAChE,OAAO,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAA;AACtD,OAAO,EAAE,oBAAoB,EAAE,MAAM,2BAA2B,CAAA;AAChE,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAA;AACtD,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAC/C,OAAO,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA"}
@@ -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.1.0",
4
- "description": "React adapter for Sentori — Provider, ErrorBoundary, and hooks built on @goliapkg/sentori-javascript.",
3
+ "version": "0.4.0",
4
+ "description": "React adapter for Sentori — Provider, ErrorBoundary (resetKeys + render-prop), Suspense, TraceRender, react-router breadcrumbs + auto-tracing.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://sentori.golia.jp",
7
7
  "repository": {
@@ -25,6 +25,14 @@
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"
32
+ },
33
+ "./trace": {
34
+ "types": "./lib/SentoriTrace.d.ts",
35
+ "default": "./lib/SentoriTrace.js"
28
36
  }
29
37
  },
30
38
  "files": [
@@ -39,11 +47,17 @@
39
47
  "prepack": "bun run build"
40
48
  },
41
49
  "peerDependencies": {
42
- "react": ">=18"
50
+ "react": ">=18",
51
+ "react-router": ">=7"
52
+ },
53
+ "peerDependenciesMeta": {
54
+ "react-router": {
55
+ "optional": true
56
+ }
43
57
  },
44
58
  "dependencies": {
45
- "@goliapkg/sentori-core": "0.1.0",
46
- "@goliapkg/sentori-javascript": "0.2.0"
59
+ "@goliapkg/sentori-core": "0.3.0",
60
+ "@goliapkg/sentori-javascript": "0.3.0"
47
61
  },
48
62
  "devDependencies": {
49
63
  "@happy-dom/global-registrator": "^17",
@@ -52,6 +66,7 @@
52
66
  "@types/react": "^19",
53
67
  "react": "^19",
54
68
  "react-dom": "^19",
69
+ "react-router": "^7",
55
70
  "typescript": "^5"
56
71
  },
57
72
  "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
- /** Rendered after an error is caught. Receives the error and a
8
- * `reset` callback that clears the boundary so retries can run. */
9
- fallback: (props: { error: Error; reset: () => void }) => ReactNode
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
- if (this.state.error) {
52
- return this.props.fallback({ error: this.state.error, reset: this.reset })
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
+ }
@@ -0,0 +1,76 @@
1
+ import { useEffect, useMemo, useRef, type ReactNode } from 'react'
2
+
3
+ import { startSpan, type SpanHandle } from '@goliapkg/sentori-core'
4
+
5
+ /**
6
+ * Wrap a subtree so its mount-to-unmount lifespan becomes a
7
+ * `react.render` span. Useful for measuring how long a heavy
8
+ * component sat on screen — a data table, a chart, a Suspense'd
9
+ * data fetch:
10
+ *
11
+ * <TraceRender op="react.render" name="OrdersTable">
12
+ * <OrdersTable />
13
+ * </TraceRender>
14
+ *
15
+ * Implementation notes:
16
+ *
17
+ * - The span opens when the component renders for the first time
18
+ * (in the body of the function, before children render — so child
19
+ * spans pick this one up as their parent via `activeSpan()` if
20
+ * they're synchronous; React renders top-down but yields between
21
+ * commits, so async children won't necessarily attribute to this
22
+ * span unless they wrap with `withSpan`).
23
+ * - The span closes in a `useEffect` cleanup. That runs at unmount
24
+ * in normal mode, or twice in StrictMode (mount-unmount-mount).
25
+ * StrictMode double-invocation just emits the span twice; this is
26
+ * a known dev-mode artifact and matches how React's profiler
27
+ * accounts for it.
28
+ * - Re-renders due to prop / state change do NOT restart the span.
29
+ * The mount/unmount boundary is the lifespan. Callers wanting
30
+ * per-render timing should use `useRenderTrace` instead (TODO).
31
+ */
32
+ export function TraceRender({
33
+ children,
34
+ data,
35
+ name,
36
+ op = 'react.render',
37
+ tags,
38
+ }: {
39
+ children: ReactNode
40
+ /** Span data, attached at finish time. */
41
+ data?: Record<string, unknown>
42
+ /** Defaults to `op`. */
43
+ name?: string
44
+ /** Defaults to `react.render`. */
45
+ op?: string
46
+ tags?: Record<string, string>
47
+ }): ReactNode {
48
+ // Lazy-init via useMemo so the span is created exactly once across
49
+ // re-renders. Returning the handle from useMemo also means the
50
+ // effect cleanup captures the same reference.
51
+ const span = useMemo(
52
+ () => startSpan(op, { data, name: name ?? op, tags }),
53
+ // We deliberately do NOT include op/name/data/tags in deps —
54
+ // changing them after first render shouldn't reopen the span;
55
+ // the lifespan is "this component instance", not "these props".
56
+ // eslint-disable-next-line react-hooks/exhaustive-deps
57
+ [],
58
+ )
59
+
60
+ // The handle is alive across renders but should not be exposed to
61
+ // child components (they make their own spans). We hold it via ref
62
+ // so React's strict-mode-friendly invariants are preserved.
63
+ const spanRef = useRef<null | SpanHandle>(span)
64
+ spanRef.current = span
65
+
66
+ useEffect(() => {
67
+ return () => {
68
+ // Finish on unmount. Second call is a no-op (SpanHandle's
69
+ // own contract), so StrictMode double-effects don't double-push.
70
+ spanRef.current?.finish({ status: 'ok' })
71
+ spanRef.current = null
72
+ }
73
+ }, [])
74
+
75
+ return children
76
+ }
@@ -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
- // Suppress React's noisy "uncaught error" log in the test output.
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
- } finally {
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,103 @@
1
+ import { clearSpans, drainSpans } from '@goliapkg/sentori-core'
2
+ import { cleanup, render } from '@testing-library/react'
3
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
4
+
5
+ import { TraceRender } from '../SentoriTrace.js'
6
+
7
+ beforeEach(() => clearSpans())
8
+ afterEach(() => {
9
+ cleanup()
10
+ clearSpans()
11
+ })
12
+
13
+ describe('TraceRender', () => {
14
+ test('renders children', () => {
15
+ const { getByText } = render(
16
+ <TraceRender name="page">
17
+ <span>hello</span>
18
+ </TraceRender>,
19
+ )
20
+ expect(getByText('hello')).toBeDefined()
21
+ })
22
+
23
+ test('opens a react.render span on mount, finishes on unmount', () => {
24
+ const { unmount } = render(
25
+ <TraceRender name="OrdersTable">
26
+ <div>orders</div>
27
+ </TraceRender>,
28
+ )
29
+
30
+ // Span is open but not yet pushed to buffer.
31
+ expect(drainSpans()).toHaveLength(0)
32
+
33
+ unmount()
34
+
35
+ const spans = drainSpans()
36
+ expect(spans).toHaveLength(1)
37
+ expect(spans[0]?.op).toBe('react.render')
38
+ expect(spans[0]?.name).toBe('OrdersTable')
39
+ expect(spans[0]?.status).toBe('ok')
40
+ expect(spans[0]?.durationMs).toBeGreaterThanOrEqual(0)
41
+ })
42
+
43
+ test('custom op + tags + data flow through to finished span', () => {
44
+ const { unmount } = render(
45
+ <TraceRender
46
+ data={{ rowCount: 42 }}
47
+ name="dashboard mount"
48
+ op="react.mount"
49
+ tags={{ route: 'dashboard' }}
50
+ >
51
+ <div />
52
+ </TraceRender>,
53
+ )
54
+ unmount()
55
+
56
+ const sp = drainSpans()[0]!
57
+ expect(sp.op).toBe('react.mount')
58
+ expect(sp.name).toBe('dashboard mount')
59
+ expect(sp.tags).toMatchObject({ route: 'dashboard' })
60
+ expect(sp.data).toEqual({ rowCount: 42 })
61
+ })
62
+
63
+ test('name defaults to op when omitted', () => {
64
+ const { unmount } = render(
65
+ <TraceRender op="react.mount">
66
+ <div />
67
+ </TraceRender>,
68
+ )
69
+ unmount()
70
+
71
+ expect(drainSpans()[0]?.name).toBe('react.mount')
72
+ })
73
+
74
+ test('multiple sequential mounts emit independent spans', () => {
75
+ const first = render(<TraceRender name="a"><div /></TraceRender>)
76
+ first.unmount()
77
+ const second = render(<TraceRender name="b"><div /></TraceRender>)
78
+ second.unmount()
79
+
80
+ const spans = drainSpans()
81
+ expect(spans).toHaveLength(2)
82
+ expect(spans.map((s) => s.name).sort()).toEqual(['a', 'b'])
83
+ })
84
+
85
+ test('re-render with new props does NOT restart the span', () => {
86
+ const { rerender, unmount } = render(
87
+ <TraceRender name="first">
88
+ <div />
89
+ </TraceRender>,
90
+ )
91
+ rerender(
92
+ <TraceRender name="second-name-ignored">
93
+ <div />
94
+ </TraceRender>,
95
+ )
96
+ unmount()
97
+
98
+ const spans = drainSpans()
99
+ expect(spans).toHaveLength(1)
100
+ // First-render name wins (lifespan is the component instance).
101
+ expect(spans[0]?.name).toBe('first')
102
+ })
103
+ })
@@ -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/index.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export { SentoriProvider } from './SentoriProvider.js'
2
2
  export { SentoriErrorBoundary } from './SentoriErrorBoundary.js'
3
+ export { SentoriSuspense } from './SentoriSuspense.js'
4
+ export { TraceRender } from './SentoriTrace.js'
3
5
  export { useSentori, useCaptureError } from './hooks.js'
4
6
 
5
7
  export type {
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
+ }