trickle-observe 0.2.56 → 0.2.58
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 +88 -2
- package/dist-esm/vite-plugin.js +88 -2
- package/package.json +1 -1
- package/src/vite-plugin.test.ts +66 -0
- package/src/vite-plugin.ts +103 -2
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 = [
|
|
@@ -672,6 +749,10 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
|
|
|
672
749
|
if (bodyInsertions.length > 0) {
|
|
673
750
|
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] = '[' + t + '[' + v.length + ']]';`, ` else if (t === 'function') propSample[k] = '[fn]';`, ` else propSample[k] = '[object]';`, ` }`, ` rec.props = propSample;`, ` rec.propKeys = propKeys;`, ` } catch(e2) {}`, ` }`, ` __trickle_appendFileSync(f, JSON.stringify(rec) + '\\n');`, ` } catch(e) {}`, `}`);
|
|
674
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);`, ` };`, `}`);
|
|
755
|
+
}
|
|
675
756
|
prefixLines.push('');
|
|
676
757
|
const prefix = prefixLines.join('\n');
|
|
677
758
|
const allInsertions = [];
|
|
@@ -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 = [
|
|
@@ -666,6 +743,10 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
|
|
|
666
743
|
if (bodyInsertions.length > 0) {
|
|
667
744
|
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] = '[' + t + '[' + v.length + ']]';`, ` else if (t === 'function') propSample[k] = '[fn]';`, ` else propSample[k] = '[object]';`, ` }`, ` rec.props = propSample;`, ` rec.propKeys = propKeys;`, ` } catch(e2) {}`, ` }`, ` __trickle_appendFileSync(f, JSON.stringify(rec) + '\\n');`, ` } catch(e) {}`, `}`);
|
|
668
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);`, ` };`, `}`);
|
|
749
|
+
}
|
|
669
750
|
prefixLines.push('');
|
|
670
751
|
const prefix = prefixLines.join('\n');
|
|
671
752
|
const allInsertions = [];
|
|
@@ -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,69 @@ 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
|
+
// ── React hook observability ──────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
describe('React hook observability', () => {
|
|
208
|
+
it('wraps useEffect callback with __trickle_hw', () => {
|
|
209
|
+
const code = `function App() {\n useEffect(() => {\n console.log('hi');\n }, []);\n return null;\n}`;
|
|
210
|
+
const out = transformTsx(code);
|
|
211
|
+
assert.ok(out, 'should transform');
|
|
212
|
+
assert.ok(out!.includes('__trickle_hw'), 'should inject hook wrapper');
|
|
213
|
+
assert.ok(out!.includes('"useEffect"'), 'should include hook name');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('wraps useMemo callback with __trickle_hw', () => {
|
|
217
|
+
const code = `function App() {\n const val = useMemo(() => {\n return expensive();\n }, [dep]);\n return null;\n}`;
|
|
218
|
+
const out = transformTsx(code);
|
|
219
|
+
assert.ok(out, 'should transform');
|
|
220
|
+
assert.ok(out!.includes('__trickle_hw'), 'should inject hook wrapper');
|
|
221
|
+
assert.ok(out!.includes('"useMemo"'), 'should include hook name');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('wraps useCallback callback with __trickle_hw', () => {
|
|
225
|
+
const code = `function App() {\n const fn = useCallback(() => {\n doSomething();\n }, [dep]);\n return null;\n}`;
|
|
226
|
+
const out = transformTsx(code);
|
|
227
|
+
assert.ok(out, 'should transform');
|
|
228
|
+
assert.ok(out!.includes('__trickle_hw'), 'should inject hook wrapper');
|
|
229
|
+
assert.ok(out!.includes('"useCallback"'), 'should include hook name');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('wraps all three hook types in the same component', () => {
|
|
233
|
+
const code = [
|
|
234
|
+
`function Dashboard() {`,
|
|
235
|
+
` useEffect(() => { fetch('/api'); }, []);`,
|
|
236
|
+
` const data = useMemo(() => { return transform(raw); }, [raw]);`,
|
|
237
|
+
` const handler = useCallback(() => { handleClick(); }, []);`,
|
|
238
|
+
` return null;`,
|
|
239
|
+
`}`,
|
|
240
|
+
].join('\n');
|
|
241
|
+
const out = transformTsx(code);
|
|
242
|
+
assert.ok(out, 'should transform');
|
|
243
|
+
const hwCount = (out!.match(/__trickle_hw/g) || []).length;
|
|
244
|
+
// preamble definition + 3 call sites = 4 occurrences
|
|
245
|
+
assert.ok(hwCount >= 4, `should have at least 4 __trickle_hw occurrences (preamble + 3 wraps), got ${hwCount}`);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('includes react_hook kind in emitted record code', () => {
|
|
249
|
+
const code = `function App() {\n useEffect(() => {\n console.log('hi');\n }, []);\n return null;\n}`;
|
|
250
|
+
const out = transformTsx(code);
|
|
251
|
+
assert.ok(out, 'should transform');
|
|
252
|
+
assert.ok(out!.includes("'react_hook'"), 'emitted record should have kind react_hook');
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('does NOT inject hook tracking in .ts files', () => {
|
|
256
|
+
const code = `function helper() {\n useEffect(() => { doStuff(); }, []);\n return null;\n}`;
|
|
257
|
+
const out = transformTs(code);
|
|
258
|
+
if (out) {
|
|
259
|
+
assert.ok(!out.includes('__trickle_hw'), 'should NOT inject hook tracker in .ts files');
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('wraps useEffect with single identifier param callback', () => {
|
|
264
|
+
const code = `function App() {\n useEffect(function() {\n console.log('hi');\n }, []);\n return null;\n}`;
|
|
265
|
+
const out = transformTsx(code);
|
|
266
|
+
assert.ok(out, 'should transform');
|
|
267
|
+
assert.ok(out!.includes('__trickle_hw'), 'should inject hook wrapper for function() {} form');
|
|
268
|
+
});
|
|
269
|
+
});
|
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';`,
|
|
@@ -758,6 +832,27 @@ function transformEsmSource(
|
|
|
758
832
|
);
|
|
759
833
|
}
|
|
760
834
|
|
|
835
|
+
// Add React hook tracker if needed
|
|
836
|
+
if (hookInsertions.length > 0) {
|
|
837
|
+
prefixLines.push(
|
|
838
|
+
`if (!globalThis.__trickle_hook_counts) { globalThis.__trickle_hook_counts = new Map(); }`,
|
|
839
|
+
`function __trickle_hw(hookName, line, cb) {`,
|
|
840
|
+
` return function(...args) {`,
|
|
841
|
+
` try {`,
|
|
842
|
+
` const key = ${JSON.stringify(filename)} + ':' + line + ':' + hookName;`,
|
|
843
|
+
` const n = (globalThis.__trickle_hook_counts.get(key) || 0) + 1;`,
|
|
844
|
+
` globalThis.__trickle_hook_counts.set(key, n);`,
|
|
845
|
+
` const dir = process.env.TRICKLE_LOCAL_DIR || __trickle_join(process.cwd(), '.trickle');`,
|
|
846
|
+
` try { __trickle_mkdirSync(dir, { recursive: true }); } catch(e2) {}`,
|
|
847
|
+
` __trickle_appendFileSync(__trickle_join(dir, 'variables.jsonl'),`,
|
|
848
|
+
` JSON.stringify({ kind: 'react_hook', hookName, file: ${JSON.stringify(filename)}, line, invokeCount: n, timestamp: Date.now() / 1000 }) + '\\n');`,
|
|
849
|
+
` } catch(e) {}`,
|
|
850
|
+
` return cb(...args);`,
|
|
851
|
+
` };`,
|
|
852
|
+
`}`,
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
|
|
761
856
|
prefixLines.push('');
|
|
762
857
|
const prefix = prefixLines.join('\n');
|
|
763
858
|
|
|
@@ -795,6 +890,12 @@ function transformEsmSource(
|
|
|
795
890
|
});
|
|
796
891
|
}
|
|
797
892
|
|
|
893
|
+
// Hook insertions: each hook needs TWO insertions (wrapper start before callback, `)` after)
|
|
894
|
+
for (const { wrapStart, wrapEnd, hookName, lineNo } of hookInsertions) {
|
|
895
|
+
allInsertions.push({ position: wrapStart, code: `__trickle_hw(${JSON.stringify(hookName)},${lineNo},` });
|
|
896
|
+
allInsertions.push({ position: wrapEnd, code: `)` });
|
|
897
|
+
}
|
|
898
|
+
|
|
798
899
|
// Sort by position descending (insert from end to preserve earlier positions)
|
|
799
900
|
allInsertions.sort((a, b) => b.position - a.position);
|
|
800
901
|
|