@lensmcp/react-instrumentation 1.18.4 → 1.18.7
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 +1 -10
- package/lib/babel-plugin.js +1 -259
- package/lib/context.js +1 -6
- package/lib/fiber-renders.js +1 -237
- package/lib/flow-fetch.js +1 -143
- package/lib/hoc.js +1 -45
- package/lib/identity.js +1 -74
- package/lib/lensmcp-root.js +1 -59
- package/lib/publish.js +1 -236
- package/lib/trace-slot.js +1 -67
- package/lib/traced-hooks.js +1 -143
- package/lib/types.js +1 -1
- package/package.json +2 -2
package/index.js
CHANGED
|
@@ -1,10 +1 @@
|
|
|
1
|
-
export
|
|
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";
|
package/lib/babel-plugin.js
CHANGED
|
@@ -1,259 +1 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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");
|
package/lib/fiber-renders.js
CHANGED
|
@@ -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");
|