@lensmcp/react-instrumentation 1.18.4 → 1.18.6

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.js CHANGED
@@ -1,10 +1 @@
1
- export { LensmcpRoot } from './lib/lensmcp-root.js';
2
- export { LensmcpContext, useLensmcpContext } from './lib/context.js';
3
- export { tracedUseEffect, tracedUseMemo, tracedUseCallback, } from './lib/traced-hooks.js';
4
- export { TraceSlot } from './lib/trace-slot.js';
5
- export { withTraceComponent } from './lib/hoc.js';
6
- export { installFlowFetch } from './lib/flow-fetch.js';
7
- export { installFiberRenderPublisher, resetFiberRenderPublisherForTests } from './lib/fiber-renders.js';
8
- export { publish, setPublisher, drainQueue, runInFlow, currentFlow, activeFlow, extendFlowWindow, beginBackgroundFlow, withFlow, } from './lib/publish.js';
9
- export { componentLogicalId, hookLogicalId, slotLogicalId, makeInstanceId, depsHash, resetIdentityState, } from './lib/identity.js';
10
- export { default as lensmcpBabelPlugin } from './lib/babel-plugin.js';
1
+ "use strict";export{LensmcpRoot}from"./lib/lensmcp-root.js";export{LensmcpContext,useLensmcpContext}from"./lib/context.js";export{tracedUseEffect,tracedUseMemo,tracedUseCallback}from"./lib/traced-hooks.js";export{TraceSlot}from"./lib/trace-slot.js";export{withTraceComponent}from"./lib/hoc.js";export{installFlowFetch}from"./lib/flow-fetch.js";export{installFiberRenderPublisher,resetFiberRenderPublisherForTests}from"./lib/fiber-renders.js";export{publish,setPublisher,drainQueue,runInFlow,currentFlow,activeFlow,extendFlowWindow,beginBackgroundFlow,withFlow}from"./lib/publish.js";export{componentLogicalId,hookLogicalId,slotLogicalId,makeInstanceId,depsHash,resetIdentityState}from"./lib/identity.js";export{default as lensmcpBabelPlugin}from"./lib/babel-plugin.js";
@@ -1,259 +1 @@
1
- const HELPER_IMPORT = '@lensmcp/react-instrumentation';
2
- const HOOK_MAP = {
3
- useEffect: 'tracedUseEffect',
4
- useMemo: 'tracedUseMemo',
5
- useCallback: 'tracedUseCallback',
6
- };
7
- // User interactions that constitute a flow origin. Deliberately excludes
8
- // high-frequency events (mousemove/scroll/pointermove) to avoid flow spam.
9
- const DEFAULT_FLOW_EVENTS = [
10
- 'onClick',
11
- 'onDoubleClick',
12
- 'onContextMenu',
13
- 'onSubmit',
14
- 'onChange',
15
- ];
16
- const ORIGIN_TYPE = {
17
- onSubmit: 'form-submit',
18
- onChange: 'input-change',
19
- };
20
- export default function lensmcpBabelPlugin(api) {
21
- const types = api.types;
22
- return {
23
- name: '@lensmcp/react-instrumentation/babel-plugin',
24
- visitor: {
25
- Program: {
26
- enter(path, state) {
27
- state.needHelpers = new Set();
28
- state.needRoot = false;
29
- state.hookIndex = 0;
30
- state.flowEvents = new Set(state.opts.flowEvents ?? DEFAULT_FLOW_EVENTS);
31
- state.rootVars = new Set();
32
- if (state.opts.production ?? process.env['NODE_ENV'] === 'production') {
33
- state.fileIgnored = true;
34
- return;
35
- }
36
- const leading = path.node.body[0]?.leadingComments ?? path.node.directives.flatMap((d) => d.leadingComments ?? []);
37
- if ((leading ?? []).some((c) => c.value.includes('@lensmcp-ignore'))) {
38
- state.fileIgnored = true;
39
- }
40
- const file = state.file?.opts?.filename;
41
- const rootDir = state.opts.rootDir ?? process.cwd();
42
- if (file && file.startsWith(rootDir)) {
43
- state.relativePath = file.slice(rootDir.length).replace(/^\/+/, '');
44
- }
45
- else if (file) {
46
- state.relativePath = file;
47
- }
48
- },
49
- exit(path, state) {
50
- if (state.fileIgnored)
51
- return;
52
- const helpers = [...(state.needHelpers ?? [])];
53
- if (state.needRoot)
54
- helpers.push('LensmcpRoot', 'installFlowFetch');
55
- if (helpers.length === 0)
56
- return;
57
- // Only import helpers not already imported from our package.
58
- const already = collectImported(path.node.body, HELPER_IMPORT, types);
59
- const missing = helpers.filter((h) => !already.has(h));
60
- if (missing.length > 0) {
61
- path.node.body.unshift(types.importDeclaration(missing.map((h) => types.importSpecifier(types.identifier(h), types.identifier(h))), types.stringLiteral(HELPER_IMPORT)));
62
- }
63
- // Install the flow-aware fetch once, after the imports.
64
- if (state.needRoot) {
65
- const firstNonImport = path.node.body.findIndex((n) => !types.isImportDeclaration(n));
66
- const callStmt = types.expressionStatement(types.callExpression(types.identifier('installFlowFetch'), []));
67
- path.node.body.splice(firstNonImport < 0 ? path.node.body.length : firstNonImport, 0, callStmt);
68
- }
69
- },
70
- },
71
- VariableDeclarator(path, state) {
72
- if (state.fileIgnored)
73
- return;
74
- // Track `const root = createRoot(...)` so the common two-statement
75
- // bootstrap (`root.render(<App/>)`, often inside a .then callback)
76
- // also gets the LensmcpRoot wrap — not just the fluent one-liner.
77
- if (state.opts.autoWrapRoot !== false &&
78
- types.isIdentifier(path.node.id) &&
79
- path.node.init &&
80
- isRootFactoryCall(path.node.init, types)) {
81
- state.rootVars.add(path.node.id.name);
82
- }
83
- },
84
- CallExpression(path, state) {
85
- if (state.fileIgnored)
86
- return;
87
- const callee = path.node.callee;
88
- // 1. Hook auto-wrap: useEffect/useMemo/useCallback(...) → traced*.
89
- if (state.opts.autoWrapHooks !== false && types.isIdentifier(callee) && HOOK_MAP[callee.name]) {
90
- const traced = HOOK_MAP[callee.name];
91
- // Skip if already a traced call or first arg is already an id string.
92
- const id = `${state.relativePath ?? '(anon)'}:${path.node.loc?.start.line ?? 0}:${state.hookIndex++}`;
93
- path.node.callee = types.identifier(traced);
94
- path.node.arguments.unshift(types.stringLiteral(id));
95
- state.needHelpers.add(traced);
96
- return;
97
- }
98
- // 2. Root auto-wrap: createRoot(x).render(EXPR) → .render(<LensmcpRoot>{EXPR}</LensmcpRoot>).
99
- if (state.opts.autoWrapRoot !== false &&
100
- types.isMemberExpression(callee) &&
101
- types.isIdentifier(callee.property, { name: 'render' }) &&
102
- (isRootFactoryCall(callee.object, types) ||
103
- (types.isIdentifier(callee.object) && state.rootVars?.has(callee.object.name))) &&
104
- path.node.arguments.length >= 1) {
105
- const arg = path.node.arguments[0];
106
- if (arg && !isAlreadyLensmcpRoot(arg, types)) {
107
- path.node.arguments[0] = types.jsxElement(types.jsxOpeningElement(types.jsxIdentifier('LensmcpRoot'), [], false), types.jsxClosingElement(types.jsxIdentifier('LensmcpRoot')), [types.jsxExpressionContainer(arg)], false);
108
- state.needRoot = true;
109
- }
110
- }
111
- },
112
- JSXOpeningElement(path, state) {
113
- if (state.fileIgnored)
114
- return;
115
- // Already tagged? Skip.
116
- const attrs = path.node.attributes;
117
- if (hasAttr(attrs, 'data-agent-component'))
118
- return;
119
- if (hasAttr(attrs, 'data-lensmcp-ignore'))
120
- return;
121
- const leading = path.parent.leadingComments ?? path.node.leadingComments;
122
- if ((leading ?? []).some((c) => c.value.includes('@lensmcp-ignore'))) {
123
- return;
124
- }
125
- // Auto-wrap user-interaction handlers with `withFlow` so a click/
126
- // submit/change starts a correlated flow (origin → backend) with no
127
- // app code. Done before tagging so we only walk authored attributes.
128
- if (state.opts.autoFlowHandlers !== false) {
129
- const loc = path.node.loc;
130
- const line = loc?.start.line ?? 0;
131
- const file = state.relativePath ?? '(anon)';
132
- for (const attr of path.node.attributes) {
133
- if (attr.type !== 'JSXAttribute' ||
134
- attr.name.type !== 'JSXIdentifier' ||
135
- !state.flowEvents.has(attr.name.name) ||
136
- !attr.value ||
137
- attr.value.type !== 'JSXExpressionContainer') {
138
- continue;
139
- }
140
- const expr = attr.value.expression;
141
- if (expr.type === 'JSXEmptyExpression' ||
142
- isAlreadyWithFlow(expr, types)) {
143
- continue;
144
- }
145
- const event = attr.name.name;
146
- attr.value.expression = types.callExpression(types.identifier('withFlow'), [
147
- expr,
148
- types.objectExpression([
149
- types.objectProperty(types.identifier('originType'), types.stringLiteral(ORIGIN_TYPE[event] ?? 'user-click')),
150
- types.objectProperty(types.identifier('originNodeId'), types.stringLiteral(`ui:${file}:${line}:${event}`)),
151
- ]),
152
- ]);
153
- state.needHelpers.add('withFlow');
154
- }
155
- }
156
- const name = describeElementName(path.node.name);
157
- if (!name)
158
- return;
159
- // Never stamp React special element types — they validate their props
160
- // (React 19 errors with "Invalid prop supplied to React.Fragment") and
161
- // render no DOM to identify anyway. Includes aliased context providers
162
- // (`const XProvider = Ctx.Provider`), which a `Fragment`-only check misses.
163
- if (isUnstampableElement(name))
164
- return;
165
- const newAttrs = [
166
- types.jsxAttribute(types.jsxIdentifier('data-agent-component'), types.stringLiteral(name)),
167
- ];
168
- if (state.opts.includeSource !== false) {
169
- const loc = path.node.loc;
170
- const line = loc?.start.line ?? 0;
171
- const file = state.relativePath ?? '(anon)';
172
- newAttrs.push(types.jsxAttribute(types.jsxIdentifier('data-agent-source'), types.stringLiteral(`${file}:${line}`)));
173
- }
174
- path.node.attributes.push(...newAttrs);
175
- },
176
- },
177
- };
178
- }
179
- function hasAttr(attrs, name) {
180
- return attrs.some((a) => a.type === 'JSXAttribute' &&
181
- a.name.type === 'JSXIdentifier' &&
182
- a.name.name === name);
183
- }
184
- /** Names already imported from `source` in this program body. */
185
- function collectImported(body, source, types) {
186
- const out = new Set();
187
- for (const node of body) {
188
- if (types.isImportDeclaration(node) && node.source.value === source) {
189
- for (const spec of node.specifiers) {
190
- if (types.isImportSpecifier(spec) && types.isIdentifier(spec.imported)) {
191
- out.add(spec.imported.name);
192
- }
193
- }
194
- }
195
- }
196
- return out;
197
- }
198
- /** Is `node` a `createRoot(...)` / `hydrateRoot(...)` call (bare or member)? */
199
- function isRootFactoryCall(node, types) {
200
- if (!types.isCallExpression(node))
201
- return false;
202
- const callee = node.callee;
203
- const names = new Set(['createRoot', 'hydrateRoot']);
204
- if (types.isIdentifier(callee))
205
- return names.has(callee.name);
206
- if (types.isMemberExpression(callee) && types.isIdentifier(callee.property)) {
207
- return names.has(callee.property.name);
208
- }
209
- return false;
210
- }
211
- /** Already wrapped with `withFlow(...)`? Keeps the transform idempotent. */
212
- function isAlreadyWithFlow(node, types) {
213
- return (types.isCallExpression(node) &&
214
- types.isIdentifier(node.callee, { name: 'withFlow' }));
215
- }
216
- /** Already wrapped in <LensmcpRoot>? */
217
- function isAlreadyLensmcpRoot(arg, types) {
218
- return (types.isJSXElement(arg) &&
219
- types.isJSXIdentifier(arg.openingElement.name, { name: 'LensmcpRoot' }));
220
- }
221
- // React special element types reject unknown props (validated like fragments
222
- // in React 19). `<>…</>` is a JSXFragment and never reaches the visitor; this
223
- // covers the named forms — including aliases that END in Provider/Consumer
224
- // (`const NavigationSlotsProvider = Ctx.Provider`), which render no DOM and
225
- // would otherwise crash the host app's console with prop-validation errors.
226
- const SPECIAL_UNSTAMPABLE = new Set([
227
- 'Fragment',
228
- 'StrictMode',
229
- 'Suspense',
230
- 'SuspenseList',
231
- 'Profiler',
232
- ]);
233
- function isUnstampableElement(name) {
234
- const last = name.split('.').pop() ?? name;
235
- if (SPECIAL_UNSTAMPABLE.has(last))
236
- return true;
237
- return last.endsWith('Provider') || last.endsWith('Consumer');
238
- }
239
- function describeElementName(node) {
240
- switch (node.type) {
241
- case 'JSXIdentifier':
242
- return node.name;
243
- case 'JSXMemberExpression': {
244
- const parts = [];
245
- let cur = node;
246
- while (cur.type === 'JSXMemberExpression') {
247
- parts.unshift(cur.property.name);
248
- cur = cur.object;
249
- }
250
- if (cur.type === 'JSXIdentifier')
251
- parts.unshift(cur.name);
252
- return parts.join('.');
253
- }
254
- case 'JSXNamespacedName':
255
- return `${node.namespace.name}:${node.name.name}`;
256
- default:
257
- return undefined;
258
- }
259
- }
1
+ "use strict";var w=Object.defineProperty;var d=(o,e)=>w(o,"name",{value:e,configurable:!0});var j=Object.defineProperty,c=d((o,e)=>j(o,"name",{value:e,configurable:!0}),"a");const h="@lensmcp/react-instrumentation",b={useEffect:"tracedUseEffect",useMemo:"tracedUseMemo",useCallback:"tracedUseCallback"},C=["onClick","onDoubleClick","onContextMenu","onSubmit","onChange"],J={onSubmit:"form-submit",onChange:"input-change"};export default function v(o){const e=o.types;return{name:"@lensmcp/react-instrumentation/babel-plugin",visitor:{Program:{enter(t,n){if(n.needHelpers=new Set,n.needRoot=!1,n.hookIndex=0,n.flowEvents=new Set(n.opts.flowEvents??C),n.rootVars=new Set,n.opts.production??process.env.NODE_ENV==="production"){n.fileIgnored=!0;return}(t.node.body[0]?.leadingComments??t.node.directives.flatMap(a=>a.leadingComments??[])??[]).some(a=>a.value.includes("@lensmcp-ignore"))&&(n.fileIgnored=!0);const r=n.file?.opts?.filename,i=n.opts.rootDir??process.cwd();r&&r.startsWith(i)?n.relativePath=r.slice(i.length).replace(/^\/+/,""):r&&(n.relativePath=r)},exit(t,n){if(n.fileIgnored)return;const r=[...n.needHelpers??[]];if(n.needRoot&&r.push("LensmcpRoot","installFlowFetch"),r.length===0)return;const i=x(t.node.body,h,e),a=r.filter(s=>!i.has(s));if(a.length>0&&t.node.body.unshift(e.importDeclaration(a.map(s=>e.importSpecifier(e.identifier(s),e.identifier(s))),e.stringLiteral(h))),n.needRoot){const s=t.node.body.findIndex(l=>!e.isImportDeclaration(l)),p=e.expressionStatement(e.callExpression(e.identifier("installFlowFetch"),[]));t.node.body.splice(s<0?t.node.body.length:s,0,p)}}},VariableDeclarator(t,n){n.fileIgnored||n.opts.autoWrapRoot!==!1&&e.isIdentifier(t.node.id)&&t.node.init&&f(t.node.init,e)&&n.rootVars.add(t.node.id.name)},CallExpression(t,n){if(n.fileIgnored)return;const r=t.node.callee;if(n.opts.autoWrapHooks!==!1&&e.isIdentifier(r)&&b[r.name]){const i=b[r.name],a=`${n.relativePath??"(anon)"}:${t.node.loc?.start.line??0}:${n.hookIndex++}`;t.node.callee=e.identifier(i),t.node.arguments.unshift(e.stringLiteral(a)),n.needHelpers.add(i);return}if(n.opts.autoWrapRoot!==!1&&e.isMemberExpression(r)&&e.isIdentifier(r.property,{name:"render"})&&(f(r.object,e)||e.isIdentifier(r.object)&&n.rootVars?.has(r.object.name))&&t.node.arguments.length>=1){const i=t.node.arguments[0];i&&!y(i,e)&&(t.node.arguments[0]=e.jsxElement(e.jsxOpeningElement(e.jsxIdentifier("LensmcpRoot"),[],!1),e.jsxClosingElement(e.jsxIdentifier("LensmcpRoot")),[e.jsxExpressionContainer(i)],!1),n.needRoot=!0)}},JSXOpeningElement(t,n){if(n.fileIgnored)return;const r=t.node.attributes;if(u(r,"data-agent-component")||u(r,"data-lensmcp-ignore")||(t.parent.leadingComments??t.node.leadingComments??[]).some(s=>s.value.includes("@lensmcp-ignore")))return;if(n.opts.autoFlowHandlers!==!1){const s=t.node.loc?.start.line??0,p=n.relativePath??"(anon)";for(const l of t.node.attributes){if(l.type!=="JSXAttribute"||l.name.type!=="JSXIdentifier"||!n.flowEvents.has(l.name.name)||!l.value||l.value.type!=="JSXExpressionContainer")continue;const m=l.value.expression;if(m.type==="JSXEmptyExpression"||I(m,e))continue;const g=l.name.name;l.value.expression=e.callExpression(e.identifier("withFlow"),[m,e.objectExpression([e.objectProperty(e.identifier("originType"),e.stringLiteral(J[g]??"user-click")),e.objectProperty(e.identifier("originNodeId"),e.stringLiteral(`ui:${p}:${s}:${g}`))])]),n.needHelpers.add("withFlow")}}const i=S(t.node.name);if(!i||E(i))return;const a=[e.jsxAttribute(e.jsxIdentifier("data-agent-component"),e.stringLiteral(i))];if(n.opts.includeSource!==!1){const s=t.node.loc?.start.line??0,p=n.relativePath??"(anon)";a.push(e.jsxAttribute(e.jsxIdentifier("data-agent-source"),e.stringLiteral(`${p}:${s}`)))}t.node.attributes.push(...a)}}}}d(v,"y"),c(v,"lensmcpBabelPlugin");function u(o,e){return o.some(t=>t.type==="JSXAttribute"&&t.name.type==="JSXIdentifier"&&t.name.name===e)}d(u,"b"),c(u,"hasAttr");function x(o,e,t){const n=new Set;for(const r of o)if(t.isImportDeclaration(r)&&r.source.value===e)for(const i of r.specifiers)t.isImportSpecifier(i)&&t.isIdentifier(i.imported)&&n.add(i.imported.name);return n}d(x,"h"),c(x,"collectImported");function f(o,e){if(!e.isCallExpression(o))return!1;const t=o.callee,n=new Set(["createRoot","hydrateRoot"]);return e.isIdentifier(t)?n.has(t.name):e.isMemberExpression(t)&&e.isIdentifier(t.property)?n.has(t.property.name):!1}d(f,"I"),c(f,"isRootFactoryCall");function I(o,e){return e.isCallExpression(o)&&e.isIdentifier(o.callee,{name:"withFlow"})}d(I,"v"),c(I,"isAlreadyWithFlow");function y(o,e){return e.isJSXElement(o)&&e.isJSXIdentifier(o.openingElement.name,{name:"LensmcpRoot"})}d(y,"C"),c(y,"isAlreadyLensmcpRoot");const R=new Set(["Fragment","StrictMode","Suspense","SuspenseList","Profiler"]);function E(o){const e=o.split(".").pop()??o;return R.has(e)?!0:e.endsWith("Provider")||e.endsWith("Consumer")}d(E,"L"),c(E,"isUnstampableElement");function S(o){switch(o.type){case"JSXIdentifier":return o.name;case"JSXMemberExpression":{const e=[];let t=o;for(;t.type==="JSXMemberExpression";)e.unshift(t.property.name),t=t.object;return t.type==="JSXIdentifier"&&e.unshift(t.name),e.join(".")}case"JSXNamespacedName":return`${o.namespace.name}:${o.name.name}`;default:return}}d(S,"P"),c(S,"describeElementName");
package/lib/context.js CHANGED
@@ -1,6 +1 @@
1
- import { createContext, useContext } from 'react';
2
- const defaultCtx = { rendererId: 'lensmcp-root' };
3
- export const LensmcpContext = createContext(defaultCtx);
4
- export function useLensmcpContext() {
5
- return useContext(LensmcpContext);
6
- }
1
+ "use strict";var r=Object.defineProperty;var n=(e,t)=>r(e,"name",{value:t,configurable:!0});var o=Object.defineProperty,s=n((e,t)=>o(e,"name",{value:t,configurable:!0}),"t");import{createContext as c,useContext as a}from"react";const p={rendererId:"lensmcp-root"};export const LensmcpContext=c(p);export function useLensmcpContext(){return a(LensmcpContext)}n(useLensmcpContext,"useLensmcpContext"),s(useLensmcpContext,"useLensmcpContext");
@@ -1,237 +1 @@
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
+ "use strict";var M=Object.defineProperty;var d=(r,e)=>M(r,"name",{value:e,configurable:!0});var P=Object.defineProperty,u=d((r,e)=>P(r,"name",{value:e,configurable:!0}),"d");import{makeInstanceId as $}from"./identity.js";import{publish as f}from"./publish.js";const k=1,R=0,D=1,F=11,O=14,T=15,_=15e3,b=25,y=15,j=8;let h=!1,m=!1;function w(r){const e=r.type;if(!(e==null||typeof e=="string"))switch(r.tag){case R:case D:return e.displayName||e.name||void 0;case F:return e.displayName||e.render?.displayName||e.render?.name||void 0;case O:case T:return e.displayName||e.type?.displayName||e.type?.name||e.name||void 0;default:return}}d(w,"k"),u(w,"componentNameOf");function N(r){const e=r.alternate?.memoizedProps,o=r.memoizedProps;if(!e||!o||e===o)return[];const t=[];for(const n of Object.keys(o))if(n!=="children"&&!Object.is(e[n],o[n])&&(t.push(n),t.length>=j))break;return t}d(N,"C"),u(N,"changedPropKeys");function v(r){const e=[];let o=0;const t=[];for(r.current.child&&t.push({node:r.current.child,parentName:void 0});t.length>0;){const{node:n,parentName:s}=t.pop();if(o+=1,o>_)break;let c=s;const i=w(n),a=(n.flags&k)!==0;if(i&&a){const l=n.alternate==null,p=[];if(!l){const g=N(n);g.length>0&&p.push({type:"props",changedKeys:g}),s&&p.push({type:"parent-render",parentRenderId:s}),p.length===0&&p.push({type:"react-state",hookIndex:-1})}e.push({name:i,phase:l?"mount":"update",durationMs:typeof n.actualDuration=="number"?n.actualDuration:0,why:p,parentName:s}),c=i}n.sibling&&t.push({node:n.sibling,parentName:s}),n.child&&t.push({node:n.child,parentName:c})}return e}d(v,"D"),u(v,"collectCommitRenders");function I(r,e){const o=r.length>b?[...r].sort((t,n)=>n.durationMs-t.durationMs).slice(0,b):r;for(const t of o){const n=`react:component:(fiber):${t.name}`,{instanceId:s}=$(n),c={id:s,componentInstanceId:s,componentLogicalId:n,componentName:t.name,rendererId:"fiber",phase:t.phase,actualDurationMs:t.durationMs,baseDurationMs:t.durationMs,startTime:e,commitTime:e,why:t.why};f({source:"react",category:"render",severity:"info",title:`render ${t.name} (${t.phase})`,fingerprint:`react-render:${n}`,raw:{kind:"render",render:c}})}r.length>o.length&&f({source:"react",category:"render",severity:"debug",title:`render commit truncated: ${r.length-o.length} of ${r.length} renders dropped (per-commit cap)`,fingerprint:"react-render:commit-truncated",raw:{kind:"render-commit-truncated",total:r.length,published:o.length}})}d(I,"P"),u(I,"publishRecords");export function installFiberRenderPublisher(){if(h||m||typeof window>"u")return;const r=window.__LENSMCP_FIBER__;if(!r||typeof r.subscribe!="function")return;h=!0;let e=y,o=0;const t=new Map;let n;const s=u(()=>{if(n=void 0,e=y,o===0)return;const c=[...t.entries()].sort((i,a)=>a[1]-i[1]).slice(0,5);f({source:"react",category:"render",severity:"warning",title:`render storm: ${o} commits/s over budget \u2014 top: ${c.map(([i,a])=>`${i}\xD7${a}`).join(", ")}`,fingerprint:"react-render:storm",raw:{kind:"render-storm",commits:o,components:Object.fromEntries(c)}}),o=0,t.clear()},"flushStorm");r.subscribe((c,i)=>{if(!m)try{if(!i||!i.current)return;const a=v(i);if(a.length===0)return;if(e>0)e-=1,I(a,Date.now());else{o+=1;for(const l of a)t.set(l.name,(t.get(l.name)??0)+1)}n||(n=setTimeout(s,1e3))}catch{m=!0}})}d(installFiberRenderPublisher,"installFiberRenderPublisher"),u(installFiberRenderPublisher,"installFiberRenderPublisher");export function resetFiberRenderPublisherForTests(){h=!1,m=!1}d(resetFiberRenderPublisherForTests,"resetFiberRenderPublisherForTests"),u(resetFiberRenderPublisherForTests,"resetFiberRenderPublisherForTests");