@goliapkg/sentori-react 0.3.0 → 0.4.1

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,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"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@goliapkg/sentori-react",
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.",
3
+ "version": "0.4.1",
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": {
@@ -29,6 +29,10 @@
29
29
  "./router": {
30
30
  "types": "./lib/router.d.ts",
31
31
  "default": "./lib/router.js"
32
+ },
33
+ "./trace": {
34
+ "types": "./lib/SentoriTrace.d.ts",
35
+ "default": "./lib/SentoriTrace.js"
32
36
  }
33
37
  },
34
38
  "files": [
@@ -52,8 +56,8 @@
52
56
  }
53
57
  },
54
58
  "dependencies": {
55
- "@goliapkg/sentori-core": "0.1.0",
56
- "@goliapkg/sentori-javascript": "0.2.0"
59
+ "@goliapkg/sentori-core": "0.3.0",
60
+ "@goliapkg/sentori-javascript": "0.3.1"
57
61
  },
58
62
  "devDependencies": {
59
63
  "@happy-dom/global-registrator": "^17",
@@ -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
+ }
@@ -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
+ })
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 {