@lensmcp/react-instrumentation 1.16.31 → 1.17.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/index.d.ts CHANGED
@@ -4,6 +4,7 @@ export { tracedUseEffect, tracedUseMemo, tracedUseCallback, } from './lib/traced
4
4
  export { TraceSlot, type TraceSlotProps } from './lib/trace-slot.js';
5
5
  export { withTraceComponent, type WithTraceOptions } from './lib/hoc.js';
6
6
  export { installFlowFetch, type FlowFetchOptions } from './lib/flow-fetch.js';
7
+ export { installFiberRenderPublisher, resetFiberRenderPublisherForTests } from './lib/fiber-renders.js';
7
8
  export { publish, setPublisher, drainQueue, runInFlow, currentFlow, activeFlow, extendFlowWindow, beginBackgroundFlow, withFlow, type PublishEnvelope, type PublishCategory, type FlowSpec, } from './lib/publish.js';
8
9
  export { componentLogicalId, hookLogicalId, slotLogicalId, makeInstanceId, depsHash, resetIdentityState, } from './lib/identity.js';
9
10
  export type { RenderRecord, EffectRecord, HookRecord, SlotChangeRecord, ComponentMountRecord, ReactPublishPayload, } from './lib/types.js';
package/index.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,KAAK,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAC3E,OAAO,EAAE,cAAc,EAAE,iBAAiB,EAAE,KAAK,sBAAsB,EAAE,MAAM,kBAAkB,CAAC;AAClG,OAAO,EACL,eAAe,EACf,aAAa,EACb,iBAAiB,GAClB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAE,SAAS,EAAE,KAAK,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrE,OAAO,EAAE,kBAAkB,EAAE,KAAK,gBAAgB,EAAE,MAAM,cAAc,CAAC;AACzE,OAAO,EAAE,gBAAgB,EAAE,KAAK,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAC9E,OAAO,EACL,OAAO,EACP,YAAY,EACZ,UAAU,EACV,SAAS,EACT,WAAW,EACX,UAAU,EACV,gBAAgB,EAChB,mBAAmB,EACnB,QAAQ,EACR,KAAK,eAAe,EACpB,KAAK,eAAe,EACpB,KAAK,QAAQ,GACd,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EACL,kBAAkB,EAClB,aAAa,EACb,aAAa,EACb,cAAc,EACd,QAAQ,EACR,kBAAkB,GACnB,MAAM,mBAAmB,CAAC;AAC3B,YAAY,EACV,YAAY,EACZ,YAAY,EACZ,UAAU,EACV,gBAAgB,EAChB,oBAAoB,EACpB,mBAAmB,GACpB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,OAAO,IAAI,kBAAkB,EAAE,KAAK,mBAAmB,EAAE,MAAM,uBAAuB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,KAAK,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAC3E,OAAO,EAAE,cAAc,EAAE,iBAAiB,EAAE,KAAK,sBAAsB,EAAE,MAAM,kBAAkB,CAAC;AAClG,OAAO,EACL,eAAe,EACf,aAAa,EACb,iBAAiB,GAClB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAE,SAAS,EAAE,KAAK,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrE,OAAO,EAAE,kBAAkB,EAAE,KAAK,gBAAgB,EAAE,MAAM,cAAc,CAAC;AACzE,OAAO,EAAE,gBAAgB,EAAE,KAAK,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAC9E,OAAO,EAAE,2BAA2B,EAAE,iCAAiC,EAAE,MAAM,wBAAwB,CAAC;AACxG,OAAO,EACL,OAAO,EACP,YAAY,EACZ,UAAU,EACV,SAAS,EACT,WAAW,EACX,UAAU,EACV,gBAAgB,EAChB,mBAAmB,EACnB,QAAQ,EACR,KAAK,eAAe,EACpB,KAAK,eAAe,EACpB,KAAK,QAAQ,GACd,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EACL,kBAAkB,EAClB,aAAa,EACb,aAAa,EACb,cAAc,EACd,QAAQ,EACR,kBAAkB,GACnB,MAAM,mBAAmB,CAAC;AAC3B,YAAY,EACV,YAAY,EACZ,YAAY,EACZ,UAAU,EACV,gBAAgB,EAChB,oBAAoB,EACpB,mBAAmB,GACpB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,OAAO,IAAI,kBAAkB,EAAE,KAAK,mBAAmB,EAAE,MAAM,uBAAuB,CAAC"}
package/index.js CHANGED
@@ -4,6 +4,7 @@ export { tracedUseEffect, tracedUseMemo, tracedUseCallback, } from './lib/traced
4
4
  export { TraceSlot } from './lib/trace-slot.js';
5
5
  export { withTraceComponent } from './lib/hoc.js';
6
6
  export { installFlowFetch } from './lib/flow-fetch.js';
7
+ export { installFiberRenderPublisher, resetFiberRenderPublisherForTests } from './lib/fiber-renders.js';
7
8
  export { publish, setPublisher, drainQueue, runInFlow, currentFlow, activeFlow, extendFlowWindow, beginBackgroundFlow, withFlow, } from './lib/publish.js';
8
9
  export { componentLogicalId, hookLogicalId, slotLogicalId, makeInstanceId, depsHash, resetIdentityState, } from './lib/identity.js';
9
10
  export { default as lensmcpBabelPlugin } from './lib/babel-plugin.js';
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Install the fiber-commit subscriber. Idempotent; a no-op outside the
3
+ * browser or when the client runtime's hook bus is absent (old runtime).
4
+ */
5
+ export declare function installFiberRenderPublisher(): void;
6
+ /** Test seam: reset module state between specs. */
7
+ export declare function resetFiberRenderPublisherForTests(): void;
8
+ //# sourceMappingURL=fiber-renders.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fiber-renders.d.ts","sourceRoot":"","sources":["../../src/lib/fiber-renders.ts"],"names":[],"mappings":"AAuMA;;;GAGG;AACH,wBAAgB,2BAA2B,IAAI,IAAI,CAsDlD;AAED,mDAAmD;AACnD,wBAAgB,iCAAiC,IAAI,IAAI,CAGxD"}
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Per-component render attribution — the fiber-walk publisher.
3
+ *
4
+ * `LensmcpRoot`'s single root `<Profiler>` can only say "the tree
5
+ * committed" (every record lands as `lensmcp-root` with
6
+ * `why: profiler-onrender`), so `react://components/hot`,
7
+ * `react://renders/slow` and `graph_explain_rerender` cannot name a
8
+ * component. This module closes that gap the way React DevTools /
9
+ * react-scan do: the injected client runtime installs (or patches) the
10
+ * `__REACT_DEVTOOLS_GLOBAL_HOOK__` BEFORE react-dom evaluates and
11
+ * re-publishes every `onCommitFiberRoot` through a tiny pub/sub
12
+ * (`window.__LENSMCP_FIBER__`); this module subscribes, walks the
13
+ * committed fiber tree, and publishes one `RenderRecord` per component
14
+ * render — real names, real `actualDuration`, and a best-effort `why`
15
+ * (changed prop keys / parent-render / own state).
16
+ *
17
+ * FLOOD CONTROL (the valtio-instrumentation lesson: a 26k-event storm
18
+ * from 22 renders): per commit only the top `MAX_RECORDS_PER_COMMIT`
19
+ * renders by duration are published, and a token bucket caps attributed
20
+ * commits per second — beyond it, renders are folded into ONE
21
+ * "render-storm" warning per second naming the hottest components.
22
+ * That warning is itself the signal a timer-driven progress indicator
23
+ * (a 500ms setInterval → setState per row) should be a DOM mutation.
24
+ *
25
+ * Strictly passive and defensive: every entry point is wrapped, and any
26
+ * throw disables the walker for the rest of the session.
27
+ */
28
+ import { makeInstanceId } from './identity.js';
29
+ import { publish } from './publish.js';
30
+ /** react-reconciler ReactFiberFlags.PerformedWork — stable at 0b1 since React 16. */
31
+ const PERFORMED_WORK = 0b1;
32
+ /** react-reconciler ReactWorkTags for renderable user components. */
33
+ const TAG_FUNCTION = 0; // FunctionComponent
34
+ const TAG_CLASS = 1; // ClassComponent
35
+ const TAG_FORWARD_REF = 11;
36
+ const TAG_MEMO = 14; // MemoComponent
37
+ const TAG_SIMPLE_MEMO = 15; // SimpleMemoComponent
38
+ const MAX_NODES_PER_COMMIT = 15_000;
39
+ const MAX_RECORDS_PER_COMMIT = 25;
40
+ /** Attributed commits per second; beyond this, fold into the storm summary. */
41
+ const COMMIT_BUDGET_PER_SEC = 15;
42
+ const MAX_CHANGED_KEYS = 8;
43
+ let installed = false;
44
+ let disabled = false;
45
+ /** Extract a human component name from a fiber, unwrapping memo/forwardRef. */
46
+ function componentNameOf(fiber) {
47
+ const t = fiber.type;
48
+ if (t == null || typeof t === 'string')
49
+ return undefined; // host / text
50
+ switch (fiber.tag) {
51
+ case TAG_FUNCTION:
52
+ case TAG_CLASS:
53
+ return t.displayName || t.name || undefined;
54
+ case TAG_FORWARD_REF:
55
+ return t.displayName || t.render?.displayName || t.render?.name || undefined;
56
+ case TAG_MEMO:
57
+ case TAG_SIMPLE_MEMO:
58
+ return t.displayName || t.type?.displayName || t.type?.name || t.name || undefined;
59
+ default:
60
+ return undefined;
61
+ }
62
+ }
63
+ /** Shallow prop diff against the alternate — the changed keys, capped. */
64
+ function changedPropKeys(fiber) {
65
+ const prev = fiber.alternate?.memoizedProps;
66
+ const next = fiber.memoizedProps;
67
+ if (!prev || !next || prev === next)
68
+ return [];
69
+ const keys = [];
70
+ for (const k of Object.keys(next)) {
71
+ if (k === 'children')
72
+ continue;
73
+ if (!Object.is(prev[k], next[k])) {
74
+ keys.push(k);
75
+ if (keys.length >= MAX_CHANGED_KEYS)
76
+ break;
77
+ }
78
+ }
79
+ return keys;
80
+ }
81
+ /** Walk one committed tree; collect every component render (bounded). */
82
+ function collectCommitRenders(root) {
83
+ const out = [];
84
+ let visited = 0;
85
+ // Iterative DFS (child → sibling), tracking the nearest RENDERED component ancestor.
86
+ const stack = [];
87
+ if (root.current.child)
88
+ stack.push({ node: root.current.child, parentName: undefined });
89
+ while (stack.length > 0) {
90
+ const { node, parentName } = stack.pop();
91
+ visited += 1;
92
+ if (visited > MAX_NODES_PER_COMMIT)
93
+ break;
94
+ let ownParentName = parentName;
95
+ const name = componentNameOf(node);
96
+ const rendered = (node.flags & PERFORMED_WORK) !== 0;
97
+ if (name && rendered) {
98
+ const isMount = node.alternate == null;
99
+ const why = [];
100
+ if (!isMount) {
101
+ const keys = changedPropKeys(node);
102
+ if (keys.length > 0)
103
+ why.push({ type: 'props', changedKeys: keys });
104
+ if (parentName)
105
+ why.push({ type: 'parent-render', parentRenderId: parentName });
106
+ if (why.length === 0)
107
+ why.push({ type: 'react-state', hookIndex: -1 }); // own update, hook unknown
108
+ }
109
+ out.push({
110
+ name,
111
+ phase: isMount ? 'mount' : 'update',
112
+ durationMs: typeof node.actualDuration === 'number' ? node.actualDuration : 0,
113
+ why,
114
+ parentName,
115
+ });
116
+ ownParentName = name;
117
+ }
118
+ if (node.sibling)
119
+ stack.push({ node: node.sibling, parentName });
120
+ if (node.child)
121
+ stack.push({ node: node.child, parentName: ownParentName });
122
+ }
123
+ return out;
124
+ }
125
+ function publishRecords(renders, commitAt) {
126
+ // Rank by duration so a capped commit keeps the expensive renders.
127
+ const ranked = renders.length > MAX_RECORDS_PER_COMMIT
128
+ ? [...renders].sort((a, b) => b.durationMs - a.durationMs).slice(0, MAX_RECORDS_PER_COMMIT)
129
+ : renders;
130
+ for (const r of ranked) {
131
+ const logicalId = `react:component:(fiber):${r.name}`;
132
+ const { instanceId } = makeInstanceId(logicalId);
133
+ const record = {
134
+ id: instanceId,
135
+ componentInstanceId: instanceId,
136
+ componentLogicalId: logicalId,
137
+ componentName: r.name,
138
+ rendererId: 'fiber',
139
+ phase: r.phase,
140
+ actualDurationMs: r.durationMs,
141
+ baseDurationMs: r.durationMs,
142
+ startTime: commitAt,
143
+ commitTime: commitAt,
144
+ why: r.why,
145
+ };
146
+ publish({
147
+ source: 'react',
148
+ category: 'render',
149
+ severity: 'info',
150
+ title: `render ${r.name} (${r.phase})`,
151
+ fingerprint: `react-render:${logicalId}`,
152
+ raw: { kind: 'render', render: record },
153
+ });
154
+ }
155
+ if (renders.length > ranked.length) {
156
+ publish({
157
+ source: 'react',
158
+ category: 'render',
159
+ severity: 'debug',
160
+ title: `render commit truncated: ${renders.length - ranked.length} of ${renders.length} renders dropped (per-commit cap)`,
161
+ fingerprint: 'react-render:commit-truncated',
162
+ raw: { kind: 'render-commit-truncated', total: renders.length, published: ranked.length },
163
+ });
164
+ }
165
+ }
166
+ /**
167
+ * Install the fiber-commit subscriber. Idempotent; a no-op outside the
168
+ * browser or when the client runtime's hook bus is absent (old runtime).
169
+ */
170
+ export function installFiberRenderPublisher() {
171
+ if (installed || disabled)
172
+ return;
173
+ if (typeof window === 'undefined')
174
+ return;
175
+ const bus = window.__LENSMCP_FIBER__;
176
+ if (!bus || typeof bus.subscribe !== 'function')
177
+ return; // runtime predates the hook shim
178
+ installed = true;
179
+ // Storm accounting: a token bucket for fully-attributed commits; the
180
+ // overflow folds into one warning per second naming the hottest components.
181
+ let tokens = COMMIT_BUDGET_PER_SEC;
182
+ let stormCommits = 0;
183
+ const stormCounts = new Map();
184
+ let flushTimer;
185
+ const flushStorm = () => {
186
+ flushTimer = undefined;
187
+ tokens = COMMIT_BUDGET_PER_SEC;
188
+ if (stormCommits === 0)
189
+ return;
190
+ const top = [...stormCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5);
191
+ publish({
192
+ source: 'react',
193
+ category: 'render',
194
+ severity: 'warning',
195
+ title: `render storm: ${stormCommits} commits/s over budget — top: ${top.map(([n, c]) => `${n}×${c}`).join(', ')}`,
196
+ fingerprint: 'react-render:storm',
197
+ raw: {
198
+ kind: 'render-storm',
199
+ commits: stormCommits,
200
+ components: Object.fromEntries(top),
201
+ },
202
+ });
203
+ stormCommits = 0;
204
+ stormCounts.clear();
205
+ };
206
+ bus.subscribe((_rendererID, root) => {
207
+ if (disabled)
208
+ return;
209
+ try {
210
+ if (!root || !root.current)
211
+ return;
212
+ const renders = collectCommitRenders(root);
213
+ if (renders.length === 0)
214
+ return;
215
+ if (tokens > 0) {
216
+ tokens -= 1;
217
+ publishRecords(renders, Date.now());
218
+ }
219
+ else {
220
+ stormCommits += 1;
221
+ for (const r of renders)
222
+ stormCounts.set(r.name, (stormCounts.get(r.name) ?? 0) + 1);
223
+ }
224
+ if (!flushTimer)
225
+ flushTimer = setTimeout(flushStorm, 1_000);
226
+ }
227
+ catch {
228
+ // Never risk the host app: one throw disables the walker for the session.
229
+ disabled = true;
230
+ }
231
+ });
232
+ }
233
+ /** Test seam: reset module state between specs. */
234
+ export function resetFiberRenderPublisherForTests() {
235
+ installed = false;
236
+ disabled = false;
237
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"flow-fetch.d.ts","sourceRoot":"","sources":["../../src/lib/flow-fetch.ts"],"names":[],"mappings":"AAgBA,MAAM,WAAW,gBAAgB;IAC/B,gEAAgE;IAChE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,8EAA8E;IAC9E,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAWD,wBAAgB,gBAAgB,CAAC,OAAO,GAAE,gBAAqB,GAAG,MAAM,IAAI,CA2C3E"}
1
+ {"version":3,"file":"flow-fetch.d.ts","sourceRoot":"","sources":["../../src/lib/flow-fetch.ts"],"names":[],"mappings":"AAgBA,MAAM,WAAW,gBAAgB;IAC/B,gEAAgE;IAChE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,8EAA8E;IAC9E,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAWD,wBAAgB,gBAAgB,CAAC,OAAO,GAAE,gBAAqB,GAAG,MAAM,IAAI,CA+C3E"}
package/lib/flow-fetch.js CHANGED
@@ -35,6 +35,10 @@ export function installFlowFetch(options = {}) {
35
35
  const flowHeader = options.flowHeader ?? 'x-lensmcp-flow-id';
36
36
  const originHeader = options.originHeader ?? 'x-lensmcp-origin-node-id';
37
37
  const original = g.fetch.bind(globalThis);
38
+ // Tell the injected client runtime's own (inner) fetch wrapper to stop
39
+ // emitting: from here on THIS wrapper publishes the network events, and
40
+ // both firing double-counts every request in the timeline.
41
+ globalThis['__LENSMCP_FLOW_FETCH__'] = true;
38
42
  const patched = ((input, init) => {
39
43
  // Sync stack (a wrapped handler) OR the causality window (page-load,
40
44
  // post-response continuations) — both mean this fetch belongs to a flow.
@@ -1 +1 @@
1
- {"version":3,"file":"lensmcp-root.d.ts","sourceRoot":"","sources":["../../src/lib/lensmcp-root.tsx"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,SAAS,EAGf,MAAM,OAAO,CAAC;AAMf,MAAM,WAAW,gBAAgB;IAC/B,gEAAgE;IAChE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,wEAAwE;IACxE,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,SAAS,CAAC;CACrB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,gBAAgB,GAAG,SAAS,CAuD9D"}
1
+ {"version":3,"file":"lensmcp-root.d.ts","sourceRoot":"","sources":["../../src/lib/lensmcp-root.tsx"],"names":[],"mappings":"AAAA,OAAO,EAGL,KAAK,SAAS,EAGf,MAAM,OAAO,CAAC;AAYf,MAAM,WAAW,gBAAgB;IAC/B,gEAAgE;IAChE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,wEAAwE;IACxE,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,SAAS,CAAC;CACrB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,gBAAgB,GAAG,SAAS,CAuD9D"}
@@ -3,6 +3,12 @@ import { Profiler, useMemo, useRef, } from 'react';
3
3
  import { LensmcpContext } from './context.js';
4
4
  import { publish } from './publish.js';
5
5
  import { makeInstanceId } from './identity.js';
6
+ import { installFiberRenderPublisher } from './fiber-renders.js';
7
+ // Per-component render attribution rides the client runtime's DevTools-hook
8
+ // bus (see fiber-renders.ts). Module-scope so importing LensmcpRoot (the
9
+ // babel autoWrapRoot path) is enough — no API change for consumers.
10
+ if (typeof window !== 'undefined')
11
+ installFiberRenderPublisher();
6
12
  /**
7
13
  * Wraps the React tree in a `<Profiler>` and a context provider so the
8
14
  * hook helpers can tag their events with the right renderer / page /
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lensmcp/react-instrumentation",
3
- "version": "1.16.31",
3
+ "version": "1.17.0",
4
4
  "type": "module",
5
5
  "main": "./index.js",
6
6
  "module": "./index.js",
@@ -19,7 +19,7 @@
19
19
  }
20
20
  },
21
21
  "dependencies": {
22
- "@lensmcp/protocol-types": "1.16.31",
22
+ "@lensmcp/protocol-types": "1.17.0",
23
23
  "tslib": "^2.3.0"
24
24
  },
25
25
  "peerDependencies": {