trickle-observe 0.2.56 → 0.2.59
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/dist/vite-plugin.js +89 -3
- package/dist-esm/vite-plugin.js +89 -3
- package/package.json +1 -1
- package/src/vite-plugin.test.ts +103 -0
- package/src/vite-plugin.ts +120 -3
package/dist/vite-plugin.js
CHANGED
|
@@ -612,11 +612,88 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
|
|
|
612
612
|
bodyInsertions.push({ position: openBrace + 1, name, lineNo, propsExpr });
|
|
613
613
|
}
|
|
614
614
|
}
|
|
615
|
+
const hookInsertions = [];
|
|
616
|
+
if (isReactFile) {
|
|
617
|
+
// Match useEffect(, useMemo(, useCallback( — also handles React.useEffect(, etc.
|
|
618
|
+
const hookCallRegex = /\b(useEffect|useMemo|useCallback)\s*\(/g;
|
|
619
|
+
let hookMatch;
|
|
620
|
+
while ((hookMatch = hookCallRegex.exec(source)) !== null) {
|
|
621
|
+
const hookName = hookMatch[1];
|
|
622
|
+
const afterParen = hookMatch.index + hookMatch[0].length;
|
|
623
|
+
// Skip past optional 'async '
|
|
624
|
+
let pos = afterParen;
|
|
625
|
+
while (pos < source.length && (source[pos] === ' ' || source[pos] === '\t' || source[pos] === '\n'))
|
|
626
|
+
pos++;
|
|
627
|
+
if (source.slice(pos, pos + 6) === 'async ') {
|
|
628
|
+
pos += 6;
|
|
629
|
+
while (pos < source.length && (source[pos] === ' ' || source[pos] === '\t'))
|
|
630
|
+
pos++;
|
|
631
|
+
}
|
|
632
|
+
// Expect a callback: arrow fn `(` or `identifier =>` or `function`
|
|
633
|
+
if (source[pos] !== '(' && !/^[a-zA-Z_$]/.test(source[pos]) && source.slice(pos, pos + 8) !== 'function')
|
|
634
|
+
continue;
|
|
635
|
+
// Find the opening `{` of the callback body depending on callback form:
|
|
636
|
+
// 1. Arrow with parens: (x, y) => { — call findFunctionBodyBrace from inside the (
|
|
637
|
+
// 2. Named/anon function: function() { — find the ( first
|
|
638
|
+
// 3. Single identifier: props => { — skip identifier, find =>, find {
|
|
639
|
+
let callbackBodyBrace = -1;
|
|
640
|
+
if (source[pos] === '(') {
|
|
641
|
+
// Arrow function with param list: () => { ... } or (x) => { ... }
|
|
642
|
+
callbackBodyBrace = findFunctionBodyBrace(source, pos + 1);
|
|
643
|
+
}
|
|
644
|
+
else if (source.slice(pos, pos + 8) === 'function') {
|
|
645
|
+
// function() {} or function name() {}
|
|
646
|
+
let funcPos = pos + 8;
|
|
647
|
+
while (funcPos < source.length && /\s/.test(source[funcPos]))
|
|
648
|
+
funcPos++;
|
|
649
|
+
if (/[a-zA-Z_$]/.test(source[funcPos])) {
|
|
650
|
+
while (funcPos < source.length && /[a-zA-Z0-9_$]/.test(source[funcPos]))
|
|
651
|
+
funcPos++;
|
|
652
|
+
}
|
|
653
|
+
while (funcPos < source.length && source[funcPos] !== '(')
|
|
654
|
+
funcPos++;
|
|
655
|
+
if (funcPos < source.length) {
|
|
656
|
+
callbackBodyBrace = findFunctionBodyBrace(source, funcPos + 1);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
else {
|
|
660
|
+
// Single identifier param: props => { ... }
|
|
661
|
+
let idEnd = pos;
|
|
662
|
+
while (idEnd < source.length && /[a-zA-Z0-9_$]/.test(source[idEnd]))
|
|
663
|
+
idEnd++;
|
|
664
|
+
let arrowPos = idEnd;
|
|
665
|
+
while (arrowPos < source.length && (source[arrowPos] === ' ' || source[arrowPos] === '\t'))
|
|
666
|
+
arrowPos++;
|
|
667
|
+
if (source.slice(arrowPos, arrowPos + 2) === '=>') {
|
|
668
|
+
arrowPos += 2;
|
|
669
|
+
while (arrowPos < source.length && (source[arrowPos] === ' ' || source[arrowPos] === '\t' || source[arrowPos] === '\n'))
|
|
670
|
+
arrowPos++;
|
|
671
|
+
if (source[arrowPos] === '{')
|
|
672
|
+
callbackBodyBrace = arrowPos;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
if (callbackBodyBrace === -1)
|
|
676
|
+
continue;
|
|
677
|
+
// Verify nothing suspicious between pos and the `{` (no semicolons, no other hook calls)
|
|
678
|
+
const between = source.slice(pos, callbackBodyBrace);
|
|
679
|
+
if (between.includes(';') || /\buseEffect\b|\buseMemo\b|\buseCallback\b/.test(between))
|
|
680
|
+
continue;
|
|
681
|
+
const closeBrace = findClosingBrace(source, callbackBodyBrace);
|
|
682
|
+
if (closeBrace === -1)
|
|
683
|
+
continue;
|
|
684
|
+
let lineNo = 1;
|
|
685
|
+
for (let i = 0; i < hookMatch.index; i++) {
|
|
686
|
+
if (source[i] === '\n')
|
|
687
|
+
lineNo++;
|
|
688
|
+
}
|
|
689
|
+
hookInsertions.push({ wrapStart: afterParen, wrapEnd: closeBrace + 1, hookName, lineNo });
|
|
690
|
+
}
|
|
691
|
+
}
|
|
615
692
|
// Find variable declarations for tracing
|
|
616
693
|
const varInsertions = traceVars ? findVarDeclarations(source) : [];
|
|
617
694
|
// Find destructured variable declarations for tracing
|
|
618
695
|
const destructInsertions = traceVars ? findDestructuredDeclarations(source) : [];
|
|
619
|
-
if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && bodyInsertions.length === 0)
|
|
696
|
+
if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && bodyInsertions.length === 0 && hookInsertions.length === 0)
|
|
620
697
|
return source;
|
|
621
698
|
// Fix line numbers: Vite transforms (TypeScript stripping) may change line numbers.
|
|
622
699
|
// Map transformed line numbers to original source line numbers.
|
|
@@ -641,7 +718,7 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
|
|
|
641
718
|
const importLines = [
|
|
642
719
|
`import { wrapFunction as __trickle_wrapFn, configure as __trickle_configure } from 'trickle-observe';`,
|
|
643
720
|
];
|
|
644
|
-
if (varInsertions.length > 0 || destructInsertions.length > 0 || bodyInsertions.length > 0) {
|
|
721
|
+
if (varInsertions.length > 0 || destructInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0) {
|
|
645
722
|
importLines.push(`import { mkdirSync as __trickle_mkdirSync, appendFileSync as __trickle_appendFileSync } from 'node:fs';`, `import { join as __trickle_join } from 'node:path';`);
|
|
646
723
|
}
|
|
647
724
|
const prefixLines = [
|
|
@@ -670,7 +747,11 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
|
|
|
670
747
|
}
|
|
671
748
|
// Add React component render tracker if needed
|
|
672
749
|
if (bodyInsertions.length > 0) {
|
|
673
|
-
prefixLines.push(`if (!globalThis.__trickle_react_renders) { globalThis.__trickle_react_renders = new Map(); }`, `function __trickle_rc(name, line, props) {`, ` try {`, ` const key = ${JSON.stringify(filename)} + ':' + line;`, ` const count = (globalThis.__trickle_react_renders.get(key) || 0) + 1;`, ` globalThis.__trickle_react_renders.set(key, count);`, ` const dir = process.env.TRICKLE_LOCAL_DIR || __trickle_join(process.cwd(), '.trickle');`, ` try { __trickle_mkdirSync(dir, { recursive: true }); } catch(e) {}`, ` const f = __trickle_join(dir, 'variables.jsonl');`, ` const rec = { kind: 'react_render', file: ${JSON.stringify(filename)}, line: line, component: name, renderCount: count, timestamp: Date.now() / 1000 };`, ` if (props !== undefined && props !== null && typeof props === 'object') {`, ` try {`, ` const propKeys = Object.keys(props).filter(k => k !== 'children');`, ` const propSample = {};`, ` for (const k of propKeys.slice(0, 10)) {`, ` const v = props[k];`, ` const t = typeof v;`, ` if (t === 'string') propSample[k] = v.length > 40 ? v.slice(0, 40) + '...' : v;`, ` else if (t === 'number' || t === 'boolean') propSample[k] = v;`, ` else if (v === null || v === undefined) propSample[k] = v;`, ` else if (Array.isArray(v)) propSample[k] = '[' +
|
|
750
|
+
prefixLines.push(`if (!globalThis.__trickle_react_renders) { globalThis.__trickle_react_renders = new Map(); }`, `if (!globalThis.__trickle_react_prev_props) { globalThis.__trickle_react_prev_props = new Map(); }`, `function __trickle_rc(name, line, props) {`, ` try {`, ` const key = ${JSON.stringify(filename)} + ':' + line;`, ` const count = (globalThis.__trickle_react_renders.get(key) || 0) + 1;`, ` globalThis.__trickle_react_renders.set(key, count);`, ` const dir = process.env.TRICKLE_LOCAL_DIR || __trickle_join(process.cwd(), '.trickle');`, ` try { __trickle_mkdirSync(dir, { recursive: true }); } catch(e) {}`, ` const f = __trickle_join(dir, 'variables.jsonl');`, ` const rec = { kind: 'react_render', file: ${JSON.stringify(filename)}, line: line, component: name, renderCount: count, timestamp: Date.now() / 1000 };`, ` if (props !== undefined && props !== null && typeof props === 'object') {`, ` try {`, ` const propKeys = Object.keys(props).filter(k => k !== 'children');`, ` const propSample = {};`, ` for (const k of propKeys.slice(0, 10)) {`, ` const v = props[k];`, ` const t = typeof v;`, ` if (t === 'string') propSample[k] = v.length > 40 ? v.slice(0, 40) + '...' : v;`, ` else if (t === 'number' || t === 'boolean') propSample[k] = v;`, ` else if (v === null || v === undefined) propSample[k] = v;`, ` else if (Array.isArray(v)) propSample[k] = '[arr:' + v.length + ']';`, ` else if (t === 'function') propSample[k] = '[fn]';`, ` else propSample[k] = '[object]';`, ` }`, ` rec.props = propSample;`, ` rec.propKeys = propKeys;`, ` // Detect which props changed vs previous render`, ` const prevProps = globalThis.__trickle_react_prev_props.get(key);`, ` if (prevProps && count > 1) {`, ` const changedProps = [];`, ` const allKeys = new Set([...Object.keys(prevProps), ...Object.keys(propSample)]);`, ` for (const k of allKeys) {`, ` const prev = prevProps[k];`, ` const curr = propSample[k];`, ` if (String(prev) !== String(curr)) {`, ` changedProps.push({ key: k, from: prev, to: curr });`, ` }`, ` }`, ` if (changedProps.length > 0) rec.changedProps = changedProps;`, ` }`, ` globalThis.__trickle_react_prev_props.set(key, propSample);`, ` } catch(e2) {}`, ` }`, ` __trickle_appendFileSync(f, JSON.stringify(rec) + '\\n');`, ` } catch(e) {}`, `}`);
|
|
751
|
+
}
|
|
752
|
+
// Add React hook tracker if needed
|
|
753
|
+
if (hookInsertions.length > 0) {
|
|
754
|
+
prefixLines.push(`if (!globalThis.__trickle_hook_counts) { globalThis.__trickle_hook_counts = new Map(); }`, `function __trickle_hw(hookName, line, cb) {`, ` return function(...args) {`, ` try {`, ` const key = ${JSON.stringify(filename)} + ':' + line + ':' + hookName;`, ` const n = (globalThis.__trickle_hook_counts.get(key) || 0) + 1;`, ` globalThis.__trickle_hook_counts.set(key, n);`, ` const dir = process.env.TRICKLE_LOCAL_DIR || __trickle_join(process.cwd(), '.trickle');`, ` try { __trickle_mkdirSync(dir, { recursive: true }); } catch(e2) {}`, ` __trickle_appendFileSync(__trickle_join(dir, 'variables.jsonl'),`, ` JSON.stringify({ kind: 'react_hook', hookName, file: ${JSON.stringify(filename)}, line, invokeCount: n, timestamp: Date.now() / 1000 }) + '\\n');`, ` } catch(e) {}`, ` return cb(...args);`, ` };`, `}`);
|
|
674
755
|
}
|
|
675
756
|
prefixLines.push('');
|
|
676
757
|
const prefix = prefixLines.join('\n');
|
|
@@ -701,6 +782,11 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
|
|
|
701
782
|
code: `\ntry{__trickle_rc(${JSON.stringify(name)},${lineNo},${propsExpr})}catch(__e){}\n`,
|
|
702
783
|
});
|
|
703
784
|
}
|
|
785
|
+
// Hook insertions: each hook needs TWO insertions (wrapper start before callback, `)` after)
|
|
786
|
+
for (const { wrapStart, wrapEnd, hookName, lineNo } of hookInsertions) {
|
|
787
|
+
allInsertions.push({ position: wrapStart, code: `__trickle_hw(${JSON.stringify(hookName)},${lineNo},` });
|
|
788
|
+
allInsertions.push({ position: wrapEnd, code: `)` });
|
|
789
|
+
}
|
|
704
790
|
// Sort by position descending (insert from end to preserve earlier positions)
|
|
705
791
|
allInsertions.sort((a, b) => b.position - a.position);
|
|
706
792
|
let result = source;
|
package/dist-esm/vite-plugin.js
CHANGED
|
@@ -606,11 +606,88 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
|
|
|
606
606
|
bodyInsertions.push({ position: openBrace + 1, name, lineNo, propsExpr });
|
|
607
607
|
}
|
|
608
608
|
}
|
|
609
|
+
const hookInsertions = [];
|
|
610
|
+
if (isReactFile) {
|
|
611
|
+
// Match useEffect(, useMemo(, useCallback( — also handles React.useEffect(, etc.
|
|
612
|
+
const hookCallRegex = /\b(useEffect|useMemo|useCallback)\s*\(/g;
|
|
613
|
+
let hookMatch;
|
|
614
|
+
while ((hookMatch = hookCallRegex.exec(source)) !== null) {
|
|
615
|
+
const hookName = hookMatch[1];
|
|
616
|
+
const afterParen = hookMatch.index + hookMatch[0].length;
|
|
617
|
+
// Skip past optional 'async '
|
|
618
|
+
let pos = afterParen;
|
|
619
|
+
while (pos < source.length && (source[pos] === ' ' || source[pos] === '\t' || source[pos] === '\n'))
|
|
620
|
+
pos++;
|
|
621
|
+
if (source.slice(pos, pos + 6) === 'async ') {
|
|
622
|
+
pos += 6;
|
|
623
|
+
while (pos < source.length && (source[pos] === ' ' || source[pos] === '\t'))
|
|
624
|
+
pos++;
|
|
625
|
+
}
|
|
626
|
+
// Expect a callback: arrow fn `(` or `identifier =>` or `function`
|
|
627
|
+
if (source[pos] !== '(' && !/^[a-zA-Z_$]/.test(source[pos]) && source.slice(pos, pos + 8) !== 'function')
|
|
628
|
+
continue;
|
|
629
|
+
// Find the opening `{` of the callback body depending on callback form:
|
|
630
|
+
// 1. Arrow with parens: (x, y) => { — call findFunctionBodyBrace from inside the (
|
|
631
|
+
// 2. Named/anon function: function() { — find the ( first
|
|
632
|
+
// 3. Single identifier: props => { — skip identifier, find =>, find {
|
|
633
|
+
let callbackBodyBrace = -1;
|
|
634
|
+
if (source[pos] === '(') {
|
|
635
|
+
// Arrow function with param list: () => { ... } or (x) => { ... }
|
|
636
|
+
callbackBodyBrace = findFunctionBodyBrace(source, pos + 1);
|
|
637
|
+
}
|
|
638
|
+
else if (source.slice(pos, pos + 8) === 'function') {
|
|
639
|
+
// function() {} or function name() {}
|
|
640
|
+
let funcPos = pos + 8;
|
|
641
|
+
while (funcPos < source.length && /\s/.test(source[funcPos]))
|
|
642
|
+
funcPos++;
|
|
643
|
+
if (/[a-zA-Z_$]/.test(source[funcPos])) {
|
|
644
|
+
while (funcPos < source.length && /[a-zA-Z0-9_$]/.test(source[funcPos]))
|
|
645
|
+
funcPos++;
|
|
646
|
+
}
|
|
647
|
+
while (funcPos < source.length && source[funcPos] !== '(')
|
|
648
|
+
funcPos++;
|
|
649
|
+
if (funcPos < source.length) {
|
|
650
|
+
callbackBodyBrace = findFunctionBodyBrace(source, funcPos + 1);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
else {
|
|
654
|
+
// Single identifier param: props => { ... }
|
|
655
|
+
let idEnd = pos;
|
|
656
|
+
while (idEnd < source.length && /[a-zA-Z0-9_$]/.test(source[idEnd]))
|
|
657
|
+
idEnd++;
|
|
658
|
+
let arrowPos = idEnd;
|
|
659
|
+
while (arrowPos < source.length && (source[arrowPos] === ' ' || source[arrowPos] === '\t'))
|
|
660
|
+
arrowPos++;
|
|
661
|
+
if (source.slice(arrowPos, arrowPos + 2) === '=>') {
|
|
662
|
+
arrowPos += 2;
|
|
663
|
+
while (arrowPos < source.length && (source[arrowPos] === ' ' || source[arrowPos] === '\t' || source[arrowPos] === '\n'))
|
|
664
|
+
arrowPos++;
|
|
665
|
+
if (source[arrowPos] === '{')
|
|
666
|
+
callbackBodyBrace = arrowPos;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
if (callbackBodyBrace === -1)
|
|
670
|
+
continue;
|
|
671
|
+
// Verify nothing suspicious between pos and the `{` (no semicolons, no other hook calls)
|
|
672
|
+
const between = source.slice(pos, callbackBodyBrace);
|
|
673
|
+
if (between.includes(';') || /\buseEffect\b|\buseMemo\b|\buseCallback\b/.test(between))
|
|
674
|
+
continue;
|
|
675
|
+
const closeBrace = findClosingBrace(source, callbackBodyBrace);
|
|
676
|
+
if (closeBrace === -1)
|
|
677
|
+
continue;
|
|
678
|
+
let lineNo = 1;
|
|
679
|
+
for (let i = 0; i < hookMatch.index; i++) {
|
|
680
|
+
if (source[i] === '\n')
|
|
681
|
+
lineNo++;
|
|
682
|
+
}
|
|
683
|
+
hookInsertions.push({ wrapStart: afterParen, wrapEnd: closeBrace + 1, hookName, lineNo });
|
|
684
|
+
}
|
|
685
|
+
}
|
|
609
686
|
// Find variable declarations for tracing
|
|
610
687
|
const varInsertions = traceVars ? findVarDeclarations(source) : [];
|
|
611
688
|
// Find destructured variable declarations for tracing
|
|
612
689
|
const destructInsertions = traceVars ? findDestructuredDeclarations(source) : [];
|
|
613
|
-
if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && bodyInsertions.length === 0)
|
|
690
|
+
if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && bodyInsertions.length === 0 && hookInsertions.length === 0)
|
|
614
691
|
return source;
|
|
615
692
|
// Fix line numbers: Vite transforms (TypeScript stripping) may change line numbers.
|
|
616
693
|
// Map transformed line numbers to original source line numbers.
|
|
@@ -635,7 +712,7 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
|
|
|
635
712
|
const importLines = [
|
|
636
713
|
`import { wrapFunction as __trickle_wrapFn, configure as __trickle_configure } from 'trickle-observe';`,
|
|
637
714
|
];
|
|
638
|
-
if (varInsertions.length > 0 || destructInsertions.length > 0 || bodyInsertions.length > 0) {
|
|
715
|
+
if (varInsertions.length > 0 || destructInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0) {
|
|
639
716
|
importLines.push(`import { mkdirSync as __trickle_mkdirSync, appendFileSync as __trickle_appendFileSync } from 'node:fs';`, `import { join as __trickle_join } from 'node:path';`);
|
|
640
717
|
}
|
|
641
718
|
const prefixLines = [
|
|
@@ -664,7 +741,11 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
|
|
|
664
741
|
}
|
|
665
742
|
// Add React component render tracker if needed
|
|
666
743
|
if (bodyInsertions.length > 0) {
|
|
667
|
-
prefixLines.push(`if (!globalThis.__trickle_react_renders) { globalThis.__trickle_react_renders = new Map(); }`, `function __trickle_rc(name, line, props) {`, ` try {`, ` const key = ${JSON.stringify(filename)} + ':' + line;`, ` const count = (globalThis.__trickle_react_renders.get(key) || 0) + 1;`, ` globalThis.__trickle_react_renders.set(key, count);`, ` const dir = process.env.TRICKLE_LOCAL_DIR || __trickle_join(process.cwd(), '.trickle');`, ` try { __trickle_mkdirSync(dir, { recursive: true }); } catch(e) {}`, ` const f = __trickle_join(dir, 'variables.jsonl');`, ` const rec = { kind: 'react_render', file: ${JSON.stringify(filename)}, line: line, component: name, renderCount: count, timestamp: Date.now() / 1000 };`, ` if (props !== undefined && props !== null && typeof props === 'object') {`, ` try {`, ` const propKeys = Object.keys(props).filter(k => k !== 'children');`, ` const propSample = {};`, ` for (const k of propKeys.slice(0, 10)) {`, ` const v = props[k];`, ` const t = typeof v;`, ` if (t === 'string') propSample[k] = v.length > 40 ? v.slice(0, 40) + '...' : v;`, ` else if (t === 'number' || t === 'boolean') propSample[k] = v;`, ` else if (v === null || v === undefined) propSample[k] = v;`, ` else if (Array.isArray(v)) propSample[k] = '[' +
|
|
744
|
+
prefixLines.push(`if (!globalThis.__trickle_react_renders) { globalThis.__trickle_react_renders = new Map(); }`, `if (!globalThis.__trickle_react_prev_props) { globalThis.__trickle_react_prev_props = new Map(); }`, `function __trickle_rc(name, line, props) {`, ` try {`, ` const key = ${JSON.stringify(filename)} + ':' + line;`, ` const count = (globalThis.__trickle_react_renders.get(key) || 0) + 1;`, ` globalThis.__trickle_react_renders.set(key, count);`, ` const dir = process.env.TRICKLE_LOCAL_DIR || __trickle_join(process.cwd(), '.trickle');`, ` try { __trickle_mkdirSync(dir, { recursive: true }); } catch(e) {}`, ` const f = __trickle_join(dir, 'variables.jsonl');`, ` const rec = { kind: 'react_render', file: ${JSON.stringify(filename)}, line: line, component: name, renderCount: count, timestamp: Date.now() / 1000 };`, ` if (props !== undefined && props !== null && typeof props === 'object') {`, ` try {`, ` const propKeys = Object.keys(props).filter(k => k !== 'children');`, ` const propSample = {};`, ` for (const k of propKeys.slice(0, 10)) {`, ` const v = props[k];`, ` const t = typeof v;`, ` if (t === 'string') propSample[k] = v.length > 40 ? v.slice(0, 40) + '...' : v;`, ` else if (t === 'number' || t === 'boolean') propSample[k] = v;`, ` else if (v === null || v === undefined) propSample[k] = v;`, ` else if (Array.isArray(v)) propSample[k] = '[arr:' + v.length + ']';`, ` else if (t === 'function') propSample[k] = '[fn]';`, ` else propSample[k] = '[object]';`, ` }`, ` rec.props = propSample;`, ` rec.propKeys = propKeys;`, ` // Detect which props changed vs previous render`, ` const prevProps = globalThis.__trickle_react_prev_props.get(key);`, ` if (prevProps && count > 1) {`, ` const changedProps = [];`, ` const allKeys = new Set([...Object.keys(prevProps), ...Object.keys(propSample)]);`, ` for (const k of allKeys) {`, ` const prev = prevProps[k];`, ` const curr = propSample[k];`, ` if (String(prev) !== String(curr)) {`, ` changedProps.push({ key: k, from: prev, to: curr });`, ` }`, ` }`, ` if (changedProps.length > 0) rec.changedProps = changedProps;`, ` }`, ` globalThis.__trickle_react_prev_props.set(key, propSample);`, ` } catch(e2) {}`, ` }`, ` __trickle_appendFileSync(f, JSON.stringify(rec) + '\\n');`, ` } catch(e) {}`, `}`);
|
|
745
|
+
}
|
|
746
|
+
// Add React hook tracker if needed
|
|
747
|
+
if (hookInsertions.length > 0) {
|
|
748
|
+
prefixLines.push(`if (!globalThis.__trickle_hook_counts) { globalThis.__trickle_hook_counts = new Map(); }`, `function __trickle_hw(hookName, line, cb) {`, ` return function(...args) {`, ` try {`, ` const key = ${JSON.stringify(filename)} + ':' + line + ':' + hookName;`, ` const n = (globalThis.__trickle_hook_counts.get(key) || 0) + 1;`, ` globalThis.__trickle_hook_counts.set(key, n);`, ` const dir = process.env.TRICKLE_LOCAL_DIR || __trickle_join(process.cwd(), '.trickle');`, ` try { __trickle_mkdirSync(dir, { recursive: true }); } catch(e2) {}`, ` __trickle_appendFileSync(__trickle_join(dir, 'variables.jsonl'),`, ` JSON.stringify({ kind: 'react_hook', hookName, file: ${JSON.stringify(filename)}, line, invokeCount: n, timestamp: Date.now() / 1000 }) + '\\n');`, ` } catch(e) {}`, ` return cb(...args);`, ` };`, `}`);
|
|
668
749
|
}
|
|
669
750
|
prefixLines.push('');
|
|
670
751
|
const prefix = prefixLines.join('\n');
|
|
@@ -695,6 +776,11 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
|
|
|
695
776
|
code: `\ntry{__trickle_rc(${JSON.stringify(name)},${lineNo},${propsExpr})}catch(__e){}\n`,
|
|
696
777
|
});
|
|
697
778
|
}
|
|
779
|
+
// Hook insertions: each hook needs TWO insertions (wrapper start before callback, `)` after)
|
|
780
|
+
for (const { wrapStart, wrapEnd, hookName, lineNo } of hookInsertions) {
|
|
781
|
+
allInsertions.push({ position: wrapStart, code: `__trickle_hw(${JSON.stringify(hookName)},${lineNo},` });
|
|
782
|
+
allInsertions.push({ position: wrapEnd, code: `)` });
|
|
783
|
+
}
|
|
698
784
|
// Sort by position descending (insert from end to preserve earlier positions)
|
|
699
785
|
allInsertions.sort((a, b) => b.position - a.position);
|
|
700
786
|
let result = source;
|
package/package.json
CHANGED
package/src/vite-plugin.test.ts
CHANGED
|
@@ -201,3 +201,106 @@ describe('Correct function body brace detection', () => {
|
|
|
201
201
|
assert.ok(wrapIdx > bodyIdx, 'function wrap should be after the function body');
|
|
202
202
|
});
|
|
203
203
|
});
|
|
204
|
+
|
|
205
|
+
// ── Re-render cause detection ─────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
describe('Re-render cause detection', () => {
|
|
208
|
+
it('emits changedProps tracking code in transformed output', () => {
|
|
209
|
+
const code = `function Card({ count, label }) { return null; }`;
|
|
210
|
+
const out = transformTsx(code);
|
|
211
|
+
assert.ok(out, 'should transform');
|
|
212
|
+
// Should include the prev_props map and comparison logic
|
|
213
|
+
assert.ok(out!.includes('__trickle_react_prev_props'), 'should initialize prev_props map');
|
|
214
|
+
assert.ok(out!.includes('changedProps'), 'should include changedProps detection');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('includes prev props comparison logic in __trickle_rc', () => {
|
|
218
|
+
const code = `function UserCard({ name, age }) { return null; }`;
|
|
219
|
+
const out = transformTsx(code);
|
|
220
|
+
assert.ok(out, 'should transform');
|
|
221
|
+
assert.ok(out!.includes('prevProps'), 'should reference prevProps');
|
|
222
|
+
assert.ok(out!.includes('globalThis.__trickle_react_prev_props.set'), 'should store current props as prev');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('stores previous props keyed by component file+line', () => {
|
|
226
|
+
const code = `function Button({ disabled, onClick }) { return null; }`;
|
|
227
|
+
const out = transformTsx(code);
|
|
228
|
+
assert.ok(out, 'should transform');
|
|
229
|
+
// The key used for prev_props storage should be the same as the render key
|
|
230
|
+
assert.ok(out!.includes('globalThis.__trickle_react_prev_props.get(key)'), 'should retrieve prev props by key');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('does not emit prev_props tracking in .ts files', () => {
|
|
234
|
+
const code = `function helper({ x, y }) { return x + y; }`;
|
|
235
|
+
const out = transformTs(code);
|
|
236
|
+
if (out) {
|
|
237
|
+
assert.ok(!out.includes('__trickle_react_prev_props'), 'should NOT track prev props in .ts files');
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// ── React hook observability ──────────────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
describe('React hook observability', () => {
|
|
245
|
+
it('wraps useEffect callback with __trickle_hw', () => {
|
|
246
|
+
const code = `function App() {\n useEffect(() => {\n console.log('hi');\n }, []);\n return null;\n}`;
|
|
247
|
+
const out = transformTsx(code);
|
|
248
|
+
assert.ok(out, 'should transform');
|
|
249
|
+
assert.ok(out!.includes('__trickle_hw'), 'should inject hook wrapper');
|
|
250
|
+
assert.ok(out!.includes('"useEffect"'), 'should include hook name');
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('wraps useMemo callback with __trickle_hw', () => {
|
|
254
|
+
const code = `function App() {\n const val = useMemo(() => {\n return expensive();\n }, [dep]);\n return null;\n}`;
|
|
255
|
+
const out = transformTsx(code);
|
|
256
|
+
assert.ok(out, 'should transform');
|
|
257
|
+
assert.ok(out!.includes('__trickle_hw'), 'should inject hook wrapper');
|
|
258
|
+
assert.ok(out!.includes('"useMemo"'), 'should include hook name');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('wraps useCallback callback with __trickle_hw', () => {
|
|
262
|
+
const code = `function App() {\n const fn = useCallback(() => {\n doSomething();\n }, [dep]);\n return null;\n}`;
|
|
263
|
+
const out = transformTsx(code);
|
|
264
|
+
assert.ok(out, 'should transform');
|
|
265
|
+
assert.ok(out!.includes('__trickle_hw'), 'should inject hook wrapper');
|
|
266
|
+
assert.ok(out!.includes('"useCallback"'), 'should include hook name');
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('wraps all three hook types in the same component', () => {
|
|
270
|
+
const code = [
|
|
271
|
+
`function Dashboard() {`,
|
|
272
|
+
` useEffect(() => { fetch('/api'); }, []);`,
|
|
273
|
+
` const data = useMemo(() => { return transform(raw); }, [raw]);`,
|
|
274
|
+
` const handler = useCallback(() => { handleClick(); }, []);`,
|
|
275
|
+
` return null;`,
|
|
276
|
+
`}`,
|
|
277
|
+
].join('\n');
|
|
278
|
+
const out = transformTsx(code);
|
|
279
|
+
assert.ok(out, 'should transform');
|
|
280
|
+
const hwCount = (out!.match(/__trickle_hw/g) || []).length;
|
|
281
|
+
// preamble definition + 3 call sites = 4 occurrences
|
|
282
|
+
assert.ok(hwCount >= 4, `should have at least 4 __trickle_hw occurrences (preamble + 3 wraps), got ${hwCount}`);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('includes react_hook kind in emitted record code', () => {
|
|
286
|
+
const code = `function App() {\n useEffect(() => {\n console.log('hi');\n }, []);\n return null;\n}`;
|
|
287
|
+
const out = transformTsx(code);
|
|
288
|
+
assert.ok(out, 'should transform');
|
|
289
|
+
assert.ok(out!.includes("'react_hook'"), 'emitted record should have kind react_hook');
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('does NOT inject hook tracking in .ts files', () => {
|
|
293
|
+
const code = `function helper() {\n useEffect(() => { doStuff(); }, []);\n return null;\n}`;
|
|
294
|
+
const out = transformTs(code);
|
|
295
|
+
if (out) {
|
|
296
|
+
assert.ok(!out.includes('__trickle_hw'), 'should NOT inject hook tracker in .ts files');
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('wraps useEffect with single identifier param callback', () => {
|
|
301
|
+
const code = `function App() {\n useEffect(function() {\n console.log('hi');\n }, []);\n return null;\n}`;
|
|
302
|
+
const out = transformTsx(code);
|
|
303
|
+
assert.ok(out, 'should transform');
|
|
304
|
+
assert.ok(out!.includes('__trickle_hw'), 'should inject hook wrapper for function() {} form');
|
|
305
|
+
});
|
|
306
|
+
});
|
package/src/vite-plugin.ts
CHANGED
|
@@ -607,13 +607,87 @@ function transformEsmSource(
|
|
|
607
607
|
|
|
608
608
|
|
|
609
609
|
|
|
610
|
+
// React hook tracking — wrap the callback arg of useEffect/useMemo/useCallback
|
|
611
|
+
// to count how many times each hook fires (effect ran, memo recomputed, callback invoked).
|
|
612
|
+
// Each hook produces TWO insertions: wrapStart (before callback) and wrapEnd (after callback `}`).
|
|
613
|
+
interface HookInsertion { wrapStart: number; wrapEnd: number; hookName: string; lineNo: number }
|
|
614
|
+
const hookInsertions: HookInsertion[] = [];
|
|
615
|
+
|
|
616
|
+
if (isReactFile) {
|
|
617
|
+
// Match useEffect(, useMemo(, useCallback( — also handles React.useEffect(, etc.
|
|
618
|
+
const hookCallRegex = /\b(useEffect|useMemo|useCallback)\s*\(/g;
|
|
619
|
+
let hookMatch;
|
|
620
|
+
while ((hookMatch = hookCallRegex.exec(source)) !== null) {
|
|
621
|
+
const hookName = hookMatch[1];
|
|
622
|
+
const afterParen = hookMatch.index + hookMatch[0].length;
|
|
623
|
+
|
|
624
|
+
// Skip past optional 'async '
|
|
625
|
+
let pos = afterParen;
|
|
626
|
+
while (pos < source.length && (source[pos] === ' ' || source[pos] === '\t' || source[pos] === '\n')) pos++;
|
|
627
|
+
if (source.slice(pos, pos + 6) === 'async ') {
|
|
628
|
+
pos += 6;
|
|
629
|
+
while (pos < source.length && (source[pos] === ' ' || source[pos] === '\t')) pos++;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Expect a callback: arrow fn `(` or `identifier =>` or `function`
|
|
633
|
+
if (source[pos] !== '(' && !/^[a-zA-Z_$]/.test(source[pos]) && source.slice(pos, pos + 8) !== 'function') continue;
|
|
634
|
+
|
|
635
|
+
// Find the opening `{` of the callback body depending on callback form:
|
|
636
|
+
// 1. Arrow with parens: (x, y) => { — call findFunctionBodyBrace from inside the (
|
|
637
|
+
// 2. Named/anon function: function() { — find the ( first
|
|
638
|
+
// 3. Single identifier: props => { — skip identifier, find =>, find {
|
|
639
|
+
let callbackBodyBrace = -1;
|
|
640
|
+
if (source[pos] === '(') {
|
|
641
|
+
// Arrow function with param list: () => { ... } or (x) => { ... }
|
|
642
|
+
callbackBodyBrace = findFunctionBodyBrace(source, pos + 1);
|
|
643
|
+
} else if (source.slice(pos, pos + 8) === 'function') {
|
|
644
|
+
// function() {} or function name() {}
|
|
645
|
+
let funcPos = pos + 8;
|
|
646
|
+
while (funcPos < source.length && /\s/.test(source[funcPos])) funcPos++;
|
|
647
|
+
if (/[a-zA-Z_$]/.test(source[funcPos])) {
|
|
648
|
+
while (funcPos < source.length && /[a-zA-Z0-9_$]/.test(source[funcPos])) funcPos++;
|
|
649
|
+
}
|
|
650
|
+
while (funcPos < source.length && source[funcPos] !== '(') funcPos++;
|
|
651
|
+
if (funcPos < source.length) {
|
|
652
|
+
callbackBodyBrace = findFunctionBodyBrace(source, funcPos + 1);
|
|
653
|
+
}
|
|
654
|
+
} else {
|
|
655
|
+
// Single identifier param: props => { ... }
|
|
656
|
+
let idEnd = pos;
|
|
657
|
+
while (idEnd < source.length && /[a-zA-Z0-9_$]/.test(source[idEnd])) idEnd++;
|
|
658
|
+
let arrowPos = idEnd;
|
|
659
|
+
while (arrowPos < source.length && (source[arrowPos] === ' ' || source[arrowPos] === '\t')) arrowPos++;
|
|
660
|
+
if (source.slice(arrowPos, arrowPos + 2) === '=>') {
|
|
661
|
+
arrowPos += 2;
|
|
662
|
+
while (arrowPos < source.length && (source[arrowPos] === ' ' || source[arrowPos] === '\t' || source[arrowPos] === '\n')) arrowPos++;
|
|
663
|
+
if (source[arrowPos] === '{') callbackBodyBrace = arrowPos;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
if (callbackBodyBrace === -1) continue;
|
|
667
|
+
|
|
668
|
+
// Verify nothing suspicious between pos and the `{` (no semicolons, no other hook calls)
|
|
669
|
+
const between = source.slice(pos, callbackBodyBrace);
|
|
670
|
+
if (between.includes(';') || /\buseEffect\b|\buseMemo\b|\buseCallback\b/.test(between)) continue;
|
|
671
|
+
|
|
672
|
+
const closeBrace = findClosingBrace(source, callbackBodyBrace);
|
|
673
|
+
if (closeBrace === -1) continue;
|
|
674
|
+
|
|
675
|
+
let lineNo = 1;
|
|
676
|
+
for (let i = 0; i < hookMatch.index; i++) {
|
|
677
|
+
if (source[i] === '\n') lineNo++;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
hookInsertions.push({ wrapStart: afterParen, wrapEnd: closeBrace + 1, hookName, lineNo });
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
610
684
|
// Find variable declarations for tracing
|
|
611
685
|
const varInsertions = traceVars ? findVarDeclarations(source) : [];
|
|
612
686
|
|
|
613
687
|
// Find destructured variable declarations for tracing
|
|
614
688
|
const destructInsertions = traceVars ? findDestructuredDeclarations(source) : [];
|
|
615
689
|
|
|
616
|
-
if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && bodyInsertions.length === 0) return source;
|
|
690
|
+
if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && bodyInsertions.length === 0 && hookInsertions.length === 0) return source;
|
|
617
691
|
|
|
618
692
|
// Fix line numbers: Vite transforms (TypeScript stripping) may change line numbers.
|
|
619
693
|
// Map transformed line numbers to original source line numbers.
|
|
@@ -638,7 +712,7 @@ function transformEsmSource(
|
|
|
638
712
|
const importLines: string[] = [
|
|
639
713
|
`import { wrapFunction as __trickle_wrapFn, configure as __trickle_configure } from 'trickle-observe';`,
|
|
640
714
|
];
|
|
641
|
-
if (varInsertions.length > 0 || destructInsertions.length > 0 || bodyInsertions.length > 0) {
|
|
715
|
+
if (varInsertions.length > 0 || destructInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0) {
|
|
642
716
|
importLines.push(
|
|
643
717
|
`import { mkdirSync as __trickle_mkdirSync, appendFileSync as __trickle_appendFileSync } from 'node:fs';`,
|
|
644
718
|
`import { join as __trickle_join } from 'node:path';`,
|
|
@@ -725,6 +799,7 @@ function transformEsmSource(
|
|
|
725
799
|
if (bodyInsertions.length > 0) {
|
|
726
800
|
prefixLines.push(
|
|
727
801
|
`if (!globalThis.__trickle_react_renders) { globalThis.__trickle_react_renders = new Map(); }`,
|
|
802
|
+
`if (!globalThis.__trickle_react_prev_props) { globalThis.__trickle_react_prev_props = new Map(); }`,
|
|
728
803
|
`function __trickle_rc(name, line, props) {`,
|
|
729
804
|
` try {`,
|
|
730
805
|
` const key = ${JSON.stringify(filename)} + ':' + line;`,
|
|
@@ -744,12 +819,27 @@ function transformEsmSource(
|
|
|
744
819
|
` if (t === 'string') propSample[k] = v.length > 40 ? v.slice(0, 40) + '...' : v;`,
|
|
745
820
|
` else if (t === 'number' || t === 'boolean') propSample[k] = v;`,
|
|
746
821
|
` else if (v === null || v === undefined) propSample[k] = v;`,
|
|
747
|
-
` else if (Array.isArray(v)) propSample[k] = '[' +
|
|
822
|
+
` else if (Array.isArray(v)) propSample[k] = '[arr:' + v.length + ']';`,
|
|
748
823
|
` else if (t === 'function') propSample[k] = '[fn]';`,
|
|
749
824
|
` else propSample[k] = '[object]';`,
|
|
750
825
|
` }`,
|
|
751
826
|
` rec.props = propSample;`,
|
|
752
827
|
` rec.propKeys = propKeys;`,
|
|
828
|
+
` // Detect which props changed vs previous render`,
|
|
829
|
+
` const prevProps = globalThis.__trickle_react_prev_props.get(key);`,
|
|
830
|
+
` if (prevProps && count > 1) {`,
|
|
831
|
+
` const changedProps = [];`,
|
|
832
|
+
` const allKeys = new Set([...Object.keys(prevProps), ...Object.keys(propSample)]);`,
|
|
833
|
+
` for (const k of allKeys) {`,
|
|
834
|
+
` const prev = prevProps[k];`,
|
|
835
|
+
` const curr = propSample[k];`,
|
|
836
|
+
` if (String(prev) !== String(curr)) {`,
|
|
837
|
+
` changedProps.push({ key: k, from: prev, to: curr });`,
|
|
838
|
+
` }`,
|
|
839
|
+
` }`,
|
|
840
|
+
` if (changedProps.length > 0) rec.changedProps = changedProps;`,
|
|
841
|
+
` }`,
|
|
842
|
+
` globalThis.__trickle_react_prev_props.set(key, propSample);`,
|
|
753
843
|
` } catch(e2) {}`,
|
|
754
844
|
` }`,
|
|
755
845
|
` __trickle_appendFileSync(f, JSON.stringify(rec) + '\\n');`,
|
|
@@ -758,6 +848,27 @@ function transformEsmSource(
|
|
|
758
848
|
);
|
|
759
849
|
}
|
|
760
850
|
|
|
851
|
+
// Add React hook tracker if needed
|
|
852
|
+
if (hookInsertions.length > 0) {
|
|
853
|
+
prefixLines.push(
|
|
854
|
+
`if (!globalThis.__trickle_hook_counts) { globalThis.__trickle_hook_counts = new Map(); }`,
|
|
855
|
+
`function __trickle_hw(hookName, line, cb) {`,
|
|
856
|
+
` return function(...args) {`,
|
|
857
|
+
` try {`,
|
|
858
|
+
` const key = ${JSON.stringify(filename)} + ':' + line + ':' + hookName;`,
|
|
859
|
+
` const n = (globalThis.__trickle_hook_counts.get(key) || 0) + 1;`,
|
|
860
|
+
` globalThis.__trickle_hook_counts.set(key, n);`,
|
|
861
|
+
` const dir = process.env.TRICKLE_LOCAL_DIR || __trickle_join(process.cwd(), '.trickle');`,
|
|
862
|
+
` try { __trickle_mkdirSync(dir, { recursive: true }); } catch(e2) {}`,
|
|
863
|
+
` __trickle_appendFileSync(__trickle_join(dir, 'variables.jsonl'),`,
|
|
864
|
+
` JSON.stringify({ kind: 'react_hook', hookName, file: ${JSON.stringify(filename)}, line, invokeCount: n, timestamp: Date.now() / 1000 }) + '\\n');`,
|
|
865
|
+
` } catch(e) {}`,
|
|
866
|
+
` return cb(...args);`,
|
|
867
|
+
` };`,
|
|
868
|
+
`}`,
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
|
|
761
872
|
prefixLines.push('');
|
|
762
873
|
const prefix = prefixLines.join('\n');
|
|
763
874
|
|
|
@@ -795,6 +906,12 @@ function transformEsmSource(
|
|
|
795
906
|
});
|
|
796
907
|
}
|
|
797
908
|
|
|
909
|
+
// Hook insertions: each hook needs TWO insertions (wrapper start before callback, `)` after)
|
|
910
|
+
for (const { wrapStart, wrapEnd, hookName, lineNo } of hookInsertions) {
|
|
911
|
+
allInsertions.push({ position: wrapStart, code: `__trickle_hw(${JSON.stringify(hookName)},${lineNo},` });
|
|
912
|
+
allInsertions.push({ position: wrapEnd, code: `)` });
|
|
913
|
+
}
|
|
914
|
+
|
|
798
915
|
// Sort by position descending (insert from end to preserve earlier positions)
|
|
799
916
|
allInsertions.sort((a, b) => b.position - a.position);
|
|
800
917
|
|