trickle-observe 0.2.55 → 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 +135 -7
- package/dist/vite-plugin.test.d.ts +1 -0
- package/dist/vite-plugin.test.js +160 -0
- package/dist-esm/vite-plugin.js +135 -7
- package/package.json +2 -1
- package/src/vite-plugin.test.ts +269 -0
- package/src/vite-plugin.ts +163 -9
- package/tsconfig.json +2 -1
package/dist/vite-plugin.js
CHANGED
|
@@ -500,6 +500,7 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
|
|
|
500
500
|
const funcRegex = /^[ \t]*(?:export\s+)?(?:async\s+)?function\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/gm;
|
|
501
501
|
const funcInsertions = [];
|
|
502
502
|
// Body insertions: insert at start of function body (for React render tracking)
|
|
503
|
+
// propsExpr: JS expression to evaluate as the props object at render time
|
|
503
504
|
const bodyInsertions = [];
|
|
504
505
|
let match;
|
|
505
506
|
while ((match = funcRegex.exec(source)) !== null) {
|
|
@@ -526,13 +527,14 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
|
|
|
526
527
|
continue;
|
|
527
528
|
funcInsertions.push({ position: closeBrace + 1, name, paramNames });
|
|
528
529
|
// React component render tracking: uppercase function name in .tsx/.jsx
|
|
530
|
+
// function declarations have `arguments`, so arguments[0] is the raw props object
|
|
529
531
|
if (isReactFile && /^[A-Z]/.test(name)) {
|
|
530
532
|
let lineNo = 1;
|
|
531
533
|
for (let i = 0; i < match.index; i++) {
|
|
532
534
|
if (source[i] === '\n')
|
|
533
535
|
lineNo++;
|
|
534
536
|
}
|
|
535
|
-
bodyInsertions.push({ position: openBrace + 1, name, lineNo });
|
|
537
|
+
bodyInsertions.push({ position: openBrace + 1, name, lineNo, propsExpr: 'arguments[0]' });
|
|
536
538
|
}
|
|
537
539
|
}
|
|
538
540
|
// Also match arrow functions assigned to const/let/var
|
|
@@ -567,14 +569,131 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
|
|
|
567
569
|
if (source[i] === '\n')
|
|
568
570
|
lineNo++;
|
|
569
571
|
}
|
|
570
|
-
|
|
572
|
+
// Determine props expression for arrow functions (no `arguments` object)
|
|
573
|
+
let propsExpr = 'undefined';
|
|
574
|
+
if (arrowParamMatch) {
|
|
575
|
+
const rawParams = (arrowParamMatch[1] || '').trim();
|
|
576
|
+
if (!rawParams) {
|
|
577
|
+
// No params: `() => {}`
|
|
578
|
+
propsExpr = 'undefined';
|
|
579
|
+
}
|
|
580
|
+
else if (rawParams.startsWith('{')) {
|
|
581
|
+
// Destructured: ({ name, age }) => {} — reconstruct object from field names
|
|
582
|
+
// Strip TS type annotation (e.g. `{a, b}: Props` → `{a, b}`)
|
|
583
|
+
// Find the matching `}` to isolate just the destructuring pattern
|
|
584
|
+
let depth2 = 0;
|
|
585
|
+
let endBrace = -1;
|
|
586
|
+
for (let i = 0; i < rawParams.length; i++) {
|
|
587
|
+
if (rawParams[i] === '{')
|
|
588
|
+
depth2++;
|
|
589
|
+
else if (rawParams[i] === '}') {
|
|
590
|
+
depth2--;
|
|
591
|
+
if (depth2 === 0) {
|
|
592
|
+
endBrace = i;
|
|
593
|
+
break;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
const destructPattern = endBrace !== -1 ? rawParams.slice(0, endBrace + 1) : rawParams;
|
|
598
|
+
const fields = extractDestructuredNames(destructPattern);
|
|
599
|
+
if (fields.length > 0) {
|
|
600
|
+
propsExpr = `{ ${fields.join(', ')} }`;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
else if (arrowParamMatch[2]) {
|
|
604
|
+
// Single simple param (no parens): `props => {}`
|
|
605
|
+
propsExpr = arrowParamMatch[2];
|
|
606
|
+
}
|
|
607
|
+
else if (paramNames.length === 1) {
|
|
608
|
+
// Single simple param: `(props) => {}`
|
|
609
|
+
propsExpr = paramNames[0];
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
bodyInsertions.push({ position: openBrace + 1, name, lineNo, propsExpr });
|
|
613
|
+
}
|
|
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 });
|
|
571
690
|
}
|
|
572
691
|
}
|
|
573
692
|
// Find variable declarations for tracing
|
|
574
693
|
const varInsertions = traceVars ? findVarDeclarations(source) : [];
|
|
575
694
|
// Find destructured variable declarations for tracing
|
|
576
695
|
const destructInsertions = traceVars ? findDestructuredDeclarations(source) : [];
|
|
577
|
-
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)
|
|
578
697
|
return source;
|
|
579
698
|
// Fix line numbers: Vite transforms (TypeScript stripping) may change line numbers.
|
|
580
699
|
// Map transformed line numbers to original source line numbers.
|
|
@@ -599,7 +718,7 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
|
|
|
599
718
|
const importLines = [
|
|
600
719
|
`import { wrapFunction as __trickle_wrapFn, configure as __trickle_configure } from 'trickle-observe';`,
|
|
601
720
|
];
|
|
602
|
-
if (varInsertions.length > 0 || destructInsertions.length > 0 || bodyInsertions.length > 0) {
|
|
721
|
+
if (varInsertions.length > 0 || destructInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0) {
|
|
603
722
|
importLines.push(`import { mkdirSync as __trickle_mkdirSync, appendFileSync as __trickle_appendFileSync } from 'node:fs';`, `import { join as __trickle_join } from 'node:path';`);
|
|
604
723
|
}
|
|
605
724
|
const prefixLines = [
|
|
@@ -628,7 +747,11 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
|
|
|
628
747
|
}
|
|
629
748
|
// Add React component render tracker if needed
|
|
630
749
|
if (bodyInsertions.length > 0) {
|
|
631
|
-
prefixLines.push(`if (!globalThis.__trickle_react_renders) { globalThis.__trickle_react_renders = new Map(); }`, `function __trickle_rc(name, line) {`, ` 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');`, `
|
|
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) {}`, `}`);
|
|
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);`, ` };`, `}`);
|
|
632
755
|
}
|
|
633
756
|
prefixLines.push('');
|
|
634
757
|
const prefix = prefixLines.join('\n');
|
|
@@ -653,12 +776,17 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
|
|
|
653
776
|
code: `\n;try{${calls}}catch(__e){}\n`,
|
|
654
777
|
});
|
|
655
778
|
}
|
|
656
|
-
for (const { position, name, lineNo } of bodyInsertions) {
|
|
779
|
+
for (const { position, name, lineNo, propsExpr } of bodyInsertions) {
|
|
657
780
|
allInsertions.push({
|
|
658
781
|
position,
|
|
659
|
-
code: `\ntry{__trickle_rc(${JSON.stringify(name)},${lineNo})}catch(__e){}\n`,
|
|
782
|
+
code: `\ntry{__trickle_rc(${JSON.stringify(name)},${lineNo},${propsExpr})}catch(__e){}\n`,
|
|
660
783
|
});
|
|
661
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
|
+
}
|
|
662
790
|
// Sort by position descending (insert from end to preserve earlier positions)
|
|
663
791
|
allInsertions.sort((a, b) => b.position - a.position);
|
|
664
792
|
let result = source;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
/**
|
|
7
|
+
* Unit tests for the Vite plugin transform (React component tracking).
|
|
8
|
+
*
|
|
9
|
+
* Run with: node --experimental-strip-types --test src/vite-plugin.test.ts
|
|
10
|
+
* Or after build: node --test dist/vite-plugin.test.js
|
|
11
|
+
*/
|
|
12
|
+
const node_test_1 = require("node:test");
|
|
13
|
+
const strict_1 = __importDefault(require("node:assert/strict"));
|
|
14
|
+
const vite_plugin_js_1 = require("../dist/vite-plugin.js");
|
|
15
|
+
// Helper: transform code as if it came from a .tsx file
|
|
16
|
+
function transformTsx(code) {
|
|
17
|
+
const plugin = (0, vite_plugin_js_1.tricklePlugin)({ debug: false, traceVars: false });
|
|
18
|
+
const result = plugin.transform(code, '/test/App.tsx');
|
|
19
|
+
return result ? result.code : null;
|
|
20
|
+
}
|
|
21
|
+
function transformTs(code) {
|
|
22
|
+
const plugin = (0, vite_plugin_js_1.tricklePlugin)({ debug: false, traceVars: false });
|
|
23
|
+
const result = plugin.transform(code, '/test/util.ts');
|
|
24
|
+
return result ? result.code : null;
|
|
25
|
+
}
|
|
26
|
+
// ── React file detection ─────────────────────────────────────────────────────
|
|
27
|
+
(0, node_test_1.describe)('React file detection', () => {
|
|
28
|
+
(0, node_test_1.it)('tracks uppercase components in .tsx files', () => {
|
|
29
|
+
const code = `function UserCard(props) { return null; }`;
|
|
30
|
+
const out = transformTsx(code);
|
|
31
|
+
strict_1.default.ok(out, 'should transform');
|
|
32
|
+
strict_1.default.ok(out.includes('__trickle_rc'), 'should inject render tracker');
|
|
33
|
+
});
|
|
34
|
+
(0, node_test_1.it)('does not inject render tracker for .ts files', () => {
|
|
35
|
+
const code = `function UserCard(props) { return null; }`;
|
|
36
|
+
const out = transformTs(code);
|
|
37
|
+
// May still transform for function wrapping, but not for render tracking
|
|
38
|
+
if (out) {
|
|
39
|
+
strict_1.default.ok(!out.includes('__trickle_rc'), 'should NOT inject render tracker in .ts files');
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
(0, node_test_1.it)('does not track lowercase functions as components', () => {
|
|
43
|
+
const code = `function helper(x) { return x + 1; }`;
|
|
44
|
+
const out = transformTsx(code);
|
|
45
|
+
if (out) {
|
|
46
|
+
strict_1.default.ok(!out.includes('__trickle_rc'), 'lowercase function should not be tracked');
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
// ── Props capture: function declarations ─────────────────────────────────────
|
|
51
|
+
(0, node_test_1.describe)('Props capture — function declarations', () => {
|
|
52
|
+
(0, node_test_1.it)('uses arguments[0] for simple param: function Component(props)', () => {
|
|
53
|
+
const code = `function MyComponent(props) { return null; }`;
|
|
54
|
+
const out = transformTsx(code);
|
|
55
|
+
strict_1.default.ok(out, 'should transform');
|
|
56
|
+
strict_1.default.ok(out.includes('arguments[0]'), 'should pass arguments[0] as props');
|
|
57
|
+
});
|
|
58
|
+
(0, node_test_1.it)('uses arguments[0] for destructured param: function Component({ name })', () => {
|
|
59
|
+
const code = `function UserCard({ name, age }) { return null; }`;
|
|
60
|
+
const out = transformTsx(code);
|
|
61
|
+
strict_1.default.ok(out, 'should transform');
|
|
62
|
+
strict_1.default.ok(out.includes('arguments[0]'), 'should pass arguments[0] for destructured params');
|
|
63
|
+
});
|
|
64
|
+
(0, node_test_1.it)('injects __trickle_rc call at start of function body', () => {
|
|
65
|
+
const code = `function MyComponent(props) {\n const x = 1;\n return null;\n}`;
|
|
66
|
+
const out = transformTsx(code);
|
|
67
|
+
strict_1.default.ok(out, 'should transform');
|
|
68
|
+
// __trickle_rc should appear before body statements
|
|
69
|
+
const rcIdx = out.indexOf('__trickle_rc');
|
|
70
|
+
const bodyIdx = out.indexOf('const x = 1');
|
|
71
|
+
strict_1.default.ok(rcIdx !== -1, '__trickle_rc should be present');
|
|
72
|
+
strict_1.default.ok(bodyIdx !== -1, 'body code should be present');
|
|
73
|
+
strict_1.default.ok(rcIdx < bodyIdx, '__trickle_rc should come before body statements');
|
|
74
|
+
});
|
|
75
|
+
(0, node_test_1.it)('includes correct component name and line in __trickle_rc call', () => {
|
|
76
|
+
const code = `function UserCard(props) { return null; }`;
|
|
77
|
+
const out = transformTsx(code);
|
|
78
|
+
strict_1.default.ok(out, 'should transform');
|
|
79
|
+
strict_1.default.ok(out.includes('"UserCard"'), 'should include component name');
|
|
80
|
+
strict_1.default.ok(out.includes('__trickle_rc("UserCard"'), 'should call with component name');
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
// ── Props capture: arrow function components ──────────────────────────────────
|
|
84
|
+
(0, node_test_1.describe)('Props capture — arrow function components', () => {
|
|
85
|
+
(0, node_test_1.it)('uses single param name for simple arrow: const C = (props) => {}', () => {
|
|
86
|
+
const code = `const Dashboard = (props) => { return null; };`;
|
|
87
|
+
const out = transformTsx(code);
|
|
88
|
+
strict_1.default.ok(out, 'should transform');
|
|
89
|
+
strict_1.default.ok(out.includes('__trickle_rc'), 'should inject render tracker');
|
|
90
|
+
// props should be the param variable, not arguments[0]
|
|
91
|
+
strict_1.default.ok(out.includes('__trickle_rc("Dashboard"'), 'should use component name');
|
|
92
|
+
// should NOT use arguments[0] for arrow functions
|
|
93
|
+
const rcCall = out.match(/__trickle_rc\("Dashboard",[^)]+\)/);
|
|
94
|
+
strict_1.default.ok(rcCall, 'should have __trickle_rc call');
|
|
95
|
+
strict_1.default.ok(!rcCall[0].includes('arguments[0]'), 'arrow functions should not use arguments[0]');
|
|
96
|
+
});
|
|
97
|
+
(0, node_test_1.it)('reconstructs object for destructured arrow: const C = ({ a, b }) => {}', () => {
|
|
98
|
+
const code = `const Counter = ({ count, label }) => { return null; };`;
|
|
99
|
+
const out = transformTsx(code);
|
|
100
|
+
strict_1.default.ok(out, 'should transform');
|
|
101
|
+
strict_1.default.ok(out.includes('__trickle_rc'), 'should inject render tracker');
|
|
102
|
+
// Should reconstruct { count, label }
|
|
103
|
+
const rcCall = out.match(/__trickle_rc\("Counter",[^,]+,([^)]+)\)/);
|
|
104
|
+
if (rcCall) {
|
|
105
|
+
strict_1.default.ok(rcCall[1].includes('count') && rcCall[1].includes('label'), 'should reconstruct props object from destructured fields');
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
(0, node_test_1.it)('passes undefined for no-param arrow: const C = () => {}', () => {
|
|
109
|
+
const code = `const NoProps = () => { return null; };`;
|
|
110
|
+
const out = transformTsx(code);
|
|
111
|
+
if (out && out.includes('__trickle_rc')) {
|
|
112
|
+
strict_1.default.ok(out.includes('undefined'), 'should pass undefined for no-param component');
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
// ── render count tracking ─────────────────────────────────────────────────────
|
|
117
|
+
(0, node_test_1.describe)('Render count tracking', () => {
|
|
118
|
+
(0, node_test_1.it)('includes react_render kind in emitted record code', () => {
|
|
119
|
+
const code = `function Card(props) { return null; }`;
|
|
120
|
+
const out = transformTsx(code);
|
|
121
|
+
strict_1.default.ok(out, 'should transform');
|
|
122
|
+
strict_1.default.ok(out.includes("'react_render'"), 'emitted record should have kind react_render');
|
|
123
|
+
});
|
|
124
|
+
(0, node_test_1.it)('includes props data in emitted record', () => {
|
|
125
|
+
const code = `function Card(props) { return null; }`;
|
|
126
|
+
const out = transformTsx(code);
|
|
127
|
+
strict_1.default.ok(out, 'should transform');
|
|
128
|
+
strict_1.default.ok(out.includes('rec.props'), 'should capture props onto the record');
|
|
129
|
+
strict_1.default.ok(out.includes('propKeys'), 'should include propKeys');
|
|
130
|
+
});
|
|
131
|
+
(0, node_test_1.it)('tracks multiple components in one file', () => {
|
|
132
|
+
const code = [
|
|
133
|
+
`function Header(props) { return null; }`,
|
|
134
|
+
`function Footer(props) { return null; }`,
|
|
135
|
+
`function helper(x) { return x; }`,
|
|
136
|
+
].join('\n');
|
|
137
|
+
const out = transformTsx(code);
|
|
138
|
+
strict_1.default.ok(out, 'should transform');
|
|
139
|
+
strict_1.default.ok(out.includes('"Header"'), 'should track Header');
|
|
140
|
+
strict_1.default.ok(out.includes('"Footer"'), 'should track Footer');
|
|
141
|
+
// helper should not be tracked as a component
|
|
142
|
+
const rcCalls = out.match(/__trickle_rc\("helper"/g);
|
|
143
|
+
strict_1.default.ok(!rcCalls, 'lowercase helper should not be tracked');
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
// ── findFunctionBodyBrace — destructured params don't confuse brace finding ───
|
|
147
|
+
(0, node_test_1.describe)('Correct function body brace detection', () => {
|
|
148
|
+
(0, node_test_1.it)('finds body brace even with destructured object params', () => {
|
|
149
|
+
const code = `function Form({ onSubmit, title }) {\n const x = 1;\n return null;\n}`;
|
|
150
|
+
const out = transformTsx(code);
|
|
151
|
+
strict_1.default.ok(out, 'should transform');
|
|
152
|
+
// __trickle_rc should be INSIDE the function body (before 'const x = 1')
|
|
153
|
+
const rcIdx = out.indexOf('__trickle_rc');
|
|
154
|
+
const bodyIdx = out.indexOf('const x = 1');
|
|
155
|
+
strict_1.default.ok(rcIdx < bodyIdx, 'render tracker must be inside the function body, before first statement');
|
|
156
|
+
// The wrap insertion should be AFTER the closing brace of the function
|
|
157
|
+
const wrapIdx = out.indexOf('__trickle_wrap');
|
|
158
|
+
strict_1.default.ok(wrapIdx > bodyIdx, 'function wrap should be after the function body');
|
|
159
|
+
});
|
|
160
|
+
});
|
package/dist-esm/vite-plugin.js
CHANGED
|
@@ -494,6 +494,7 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
|
|
|
494
494
|
const funcRegex = /^[ \t]*(?:export\s+)?(?:async\s+)?function\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/gm;
|
|
495
495
|
const funcInsertions = [];
|
|
496
496
|
// Body insertions: insert at start of function body (for React render tracking)
|
|
497
|
+
// propsExpr: JS expression to evaluate as the props object at render time
|
|
497
498
|
const bodyInsertions = [];
|
|
498
499
|
let match;
|
|
499
500
|
while ((match = funcRegex.exec(source)) !== null) {
|
|
@@ -520,13 +521,14 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
|
|
|
520
521
|
continue;
|
|
521
522
|
funcInsertions.push({ position: closeBrace + 1, name, paramNames });
|
|
522
523
|
// React component render tracking: uppercase function name in .tsx/.jsx
|
|
524
|
+
// function declarations have `arguments`, so arguments[0] is the raw props object
|
|
523
525
|
if (isReactFile && /^[A-Z]/.test(name)) {
|
|
524
526
|
let lineNo = 1;
|
|
525
527
|
for (let i = 0; i < match.index; i++) {
|
|
526
528
|
if (source[i] === '\n')
|
|
527
529
|
lineNo++;
|
|
528
530
|
}
|
|
529
|
-
bodyInsertions.push({ position: openBrace + 1, name, lineNo });
|
|
531
|
+
bodyInsertions.push({ position: openBrace + 1, name, lineNo, propsExpr: 'arguments[0]' });
|
|
530
532
|
}
|
|
531
533
|
}
|
|
532
534
|
// Also match arrow functions assigned to const/let/var
|
|
@@ -561,14 +563,131 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
|
|
|
561
563
|
if (source[i] === '\n')
|
|
562
564
|
lineNo++;
|
|
563
565
|
}
|
|
564
|
-
|
|
566
|
+
// Determine props expression for arrow functions (no `arguments` object)
|
|
567
|
+
let propsExpr = 'undefined';
|
|
568
|
+
if (arrowParamMatch) {
|
|
569
|
+
const rawParams = (arrowParamMatch[1] || '').trim();
|
|
570
|
+
if (!rawParams) {
|
|
571
|
+
// No params: `() => {}`
|
|
572
|
+
propsExpr = 'undefined';
|
|
573
|
+
}
|
|
574
|
+
else if (rawParams.startsWith('{')) {
|
|
575
|
+
// Destructured: ({ name, age }) => {} — reconstruct object from field names
|
|
576
|
+
// Strip TS type annotation (e.g. `{a, b}: Props` → `{a, b}`)
|
|
577
|
+
// Find the matching `}` to isolate just the destructuring pattern
|
|
578
|
+
let depth2 = 0;
|
|
579
|
+
let endBrace = -1;
|
|
580
|
+
for (let i = 0; i < rawParams.length; i++) {
|
|
581
|
+
if (rawParams[i] === '{')
|
|
582
|
+
depth2++;
|
|
583
|
+
else if (rawParams[i] === '}') {
|
|
584
|
+
depth2--;
|
|
585
|
+
if (depth2 === 0) {
|
|
586
|
+
endBrace = i;
|
|
587
|
+
break;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
const destructPattern = endBrace !== -1 ? rawParams.slice(0, endBrace + 1) : rawParams;
|
|
592
|
+
const fields = extractDestructuredNames(destructPattern);
|
|
593
|
+
if (fields.length > 0) {
|
|
594
|
+
propsExpr = `{ ${fields.join(', ')} }`;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
else if (arrowParamMatch[2]) {
|
|
598
|
+
// Single simple param (no parens): `props => {}`
|
|
599
|
+
propsExpr = arrowParamMatch[2];
|
|
600
|
+
}
|
|
601
|
+
else if (paramNames.length === 1) {
|
|
602
|
+
// Single simple param: `(props) => {}`
|
|
603
|
+
propsExpr = paramNames[0];
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
bodyInsertions.push({ position: openBrace + 1, name, lineNo, propsExpr });
|
|
607
|
+
}
|
|
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 });
|
|
565
684
|
}
|
|
566
685
|
}
|
|
567
686
|
// Find variable declarations for tracing
|
|
568
687
|
const varInsertions = traceVars ? findVarDeclarations(source) : [];
|
|
569
688
|
// Find destructured variable declarations for tracing
|
|
570
689
|
const destructInsertions = traceVars ? findDestructuredDeclarations(source) : [];
|
|
571
|
-
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)
|
|
572
691
|
return source;
|
|
573
692
|
// Fix line numbers: Vite transforms (TypeScript stripping) may change line numbers.
|
|
574
693
|
// Map transformed line numbers to original source line numbers.
|
|
@@ -593,7 +712,7 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
|
|
|
593
712
|
const importLines = [
|
|
594
713
|
`import { wrapFunction as __trickle_wrapFn, configure as __trickle_configure } from 'trickle-observe';`,
|
|
595
714
|
];
|
|
596
|
-
if (varInsertions.length > 0 || destructInsertions.length > 0 || bodyInsertions.length > 0) {
|
|
715
|
+
if (varInsertions.length > 0 || destructInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0) {
|
|
597
716
|
importLines.push(`import { mkdirSync as __trickle_mkdirSync, appendFileSync as __trickle_appendFileSync } from 'node:fs';`, `import { join as __trickle_join } from 'node:path';`);
|
|
598
717
|
}
|
|
599
718
|
const prefixLines = [
|
|
@@ -622,7 +741,11 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
|
|
|
622
741
|
}
|
|
623
742
|
// Add React component render tracker if needed
|
|
624
743
|
if (bodyInsertions.length > 0) {
|
|
625
|
-
prefixLines.push(`if (!globalThis.__trickle_react_renders) { globalThis.__trickle_react_renders = new Map(); }`, `function __trickle_rc(name, line) {`, ` 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');`, `
|
|
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) {}`, `}`);
|
|
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);`, ` };`, `}`);
|
|
626
749
|
}
|
|
627
750
|
prefixLines.push('');
|
|
628
751
|
const prefix = prefixLines.join('\n');
|
|
@@ -647,12 +770,17 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
|
|
|
647
770
|
code: `\n;try{${calls}}catch(__e){}\n`,
|
|
648
771
|
});
|
|
649
772
|
}
|
|
650
|
-
for (const { position, name, lineNo } of bodyInsertions) {
|
|
773
|
+
for (const { position, name, lineNo, propsExpr } of bodyInsertions) {
|
|
651
774
|
allInsertions.push({
|
|
652
775
|
position,
|
|
653
|
-
code: `\ntry{__trickle_rc(${JSON.stringify(name)},${lineNo})}catch(__e){}\n`,
|
|
776
|
+
code: `\ntry{__trickle_rc(${JSON.stringify(name)},${lineNo},${propsExpr})}catch(__e){}\n`,
|
|
654
777
|
});
|
|
655
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
|
+
}
|
|
656
784
|
// Sort by position descending (insert from end to preserve earlier positions)
|
|
657
785
|
allInsertions.sort((a, b) => b.position - a.position);
|
|
658
786
|
let result = source;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trickle-observe",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.58",
|
|
4
4
|
"description": "Runtime type observability for JavaScript applications",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
},
|
|
21
21
|
"scripts": {
|
|
22
22
|
"build": "tsc && tsc -p tsconfig.esm.json",
|
|
23
|
+
"test": "npm run build && node --experimental-strip-types --test src/vite-plugin.test.ts",
|
|
23
24
|
"prepublishOnly": "npm run build"
|
|
24
25
|
},
|
|
25
26
|
"optionalDependencies": {
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the Vite plugin transform (React component tracking).
|
|
3
|
+
*
|
|
4
|
+
* Run with: node --experimental-strip-types --test src/vite-plugin.test.ts
|
|
5
|
+
* Or after build: node --test dist/vite-plugin.test.js
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it } from 'node:test';
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import { tricklePlugin } from '../dist/vite-plugin.js';
|
|
10
|
+
|
|
11
|
+
// Helper: transform code as if it came from a .tsx file
|
|
12
|
+
function transformTsx(code: string): string | null {
|
|
13
|
+
const plugin = tricklePlugin({ debug: false, traceVars: false });
|
|
14
|
+
const result = plugin.transform(code, '/test/App.tsx');
|
|
15
|
+
return result ? result.code : null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function transformTs(code: string): string | null {
|
|
19
|
+
const plugin = tricklePlugin({ debug: false, traceVars: false });
|
|
20
|
+
const result = plugin.transform(code, '/test/util.ts');
|
|
21
|
+
return result ? result.code : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ── React file detection ─────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
describe('React file detection', () => {
|
|
27
|
+
it('tracks uppercase components in .tsx files', () => {
|
|
28
|
+
const code = `function UserCard(props) { return null; }`;
|
|
29
|
+
const out = transformTsx(code);
|
|
30
|
+
assert.ok(out, 'should transform');
|
|
31
|
+
assert.ok(out!.includes('__trickle_rc'), 'should inject render tracker');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('does not inject render tracker for .ts files', () => {
|
|
35
|
+
const code = `function UserCard(props) { return null; }`;
|
|
36
|
+
const out = transformTs(code);
|
|
37
|
+
// May still transform for function wrapping, but not for render tracking
|
|
38
|
+
if (out) {
|
|
39
|
+
assert.ok(!out.includes('__trickle_rc'), 'should NOT inject render tracker in .ts files');
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('does not track lowercase functions as components', () => {
|
|
44
|
+
const code = `function helper(x) { return x + 1; }`;
|
|
45
|
+
const out = transformTsx(code);
|
|
46
|
+
if (out) {
|
|
47
|
+
assert.ok(!out.includes('__trickle_rc'), 'lowercase function should not be tracked');
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// ── Props capture: function declarations ─────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
describe('Props capture — function declarations', () => {
|
|
55
|
+
it('uses arguments[0] for simple param: function Component(props)', () => {
|
|
56
|
+
const code = `function MyComponent(props) { return null; }`;
|
|
57
|
+
const out = transformTsx(code);
|
|
58
|
+
assert.ok(out, 'should transform');
|
|
59
|
+
assert.ok(out!.includes('arguments[0]'), 'should pass arguments[0] as props');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('uses arguments[0] for destructured param: function Component({ name })', () => {
|
|
63
|
+
const code = `function UserCard({ name, age }) { return null; }`;
|
|
64
|
+
const out = transformTsx(code);
|
|
65
|
+
assert.ok(out, 'should transform');
|
|
66
|
+
assert.ok(out!.includes('arguments[0]'), 'should pass arguments[0] for destructured params');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('injects __trickle_rc call at start of function body', () => {
|
|
70
|
+
const code = `function MyComponent(props) {\n const x = 1;\n return null;\n}`;
|
|
71
|
+
const out = transformTsx(code);
|
|
72
|
+
assert.ok(out, 'should transform');
|
|
73
|
+
// __trickle_rc should appear before body statements
|
|
74
|
+
const rcIdx = out!.indexOf('__trickle_rc');
|
|
75
|
+
const bodyIdx = out!.indexOf('const x = 1');
|
|
76
|
+
assert.ok(rcIdx !== -1, '__trickle_rc should be present');
|
|
77
|
+
assert.ok(bodyIdx !== -1, 'body code should be present');
|
|
78
|
+
assert.ok(rcIdx < bodyIdx, '__trickle_rc should come before body statements');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('includes correct component name and line in __trickle_rc call', () => {
|
|
82
|
+
const code = `function UserCard(props) { return null; }`;
|
|
83
|
+
const out = transformTsx(code);
|
|
84
|
+
assert.ok(out, 'should transform');
|
|
85
|
+
assert.ok(out!.includes('"UserCard"'), 'should include component name');
|
|
86
|
+
assert.ok(out!.includes('__trickle_rc("UserCard"'), 'should call with component name');
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// ── Props capture: arrow function components ──────────────────────────────────
|
|
91
|
+
|
|
92
|
+
describe('Props capture — arrow function components', () => {
|
|
93
|
+
it('uses single param name for simple arrow: const C = (props) => {}', () => {
|
|
94
|
+
const code = `const Dashboard = (props) => { return null; };`;
|
|
95
|
+
const out = transformTsx(code);
|
|
96
|
+
assert.ok(out, 'should transform');
|
|
97
|
+
assert.ok(out!.includes('__trickle_rc'), 'should inject render tracker');
|
|
98
|
+
// props should be the param variable, not arguments[0]
|
|
99
|
+
assert.ok(out!.includes('__trickle_rc("Dashboard"'), 'should use component name');
|
|
100
|
+
// should NOT use arguments[0] for arrow functions
|
|
101
|
+
const rcCall = out!.match(/__trickle_rc\("Dashboard",[^)]+\)/);
|
|
102
|
+
assert.ok(rcCall, 'should have __trickle_rc call');
|
|
103
|
+
assert.ok(!rcCall![0].includes('arguments[0]'), 'arrow functions should not use arguments[0]');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('reconstructs object for destructured arrow: const C = ({ a, b }) => {}', () => {
|
|
107
|
+
const code = `const Counter = ({ count, label }) => { return null; };`;
|
|
108
|
+
const out = transformTsx(code);
|
|
109
|
+
assert.ok(out, 'should transform');
|
|
110
|
+
assert.ok(out!.includes('__trickle_rc'), 'should inject render tracker');
|
|
111
|
+
// Should reconstruct { count, label }
|
|
112
|
+
const rcCall = out!.match(/__trickle_rc\("Counter",[^,]+,([^)]+)\)/);
|
|
113
|
+
if (rcCall) {
|
|
114
|
+
assert.ok(
|
|
115
|
+
rcCall[1].includes('count') && rcCall[1].includes('label'),
|
|
116
|
+
'should reconstruct props object from destructured fields',
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('handles TypeScript type annotations in destructured props: const C = ({ a }: Props) => {}', () => {
|
|
122
|
+
const code = `const Form = ({ onSubmit, title }: FormProps) => { return null; };`;
|
|
123
|
+
const out = transformTsx(code);
|
|
124
|
+
assert.ok(out, 'should transform');
|
|
125
|
+
assert.ok(out!.includes('__trickle_rc'), 'should inject render tracker');
|
|
126
|
+
// Should capture onSubmit and title, not include ': FormProps' in prop names
|
|
127
|
+
const rcCall = out!.match(/__trickle_rc\("Form",[^,]+,([^)]+)\)/);
|
|
128
|
+
if (rcCall) {
|
|
129
|
+
assert.ok(rcCall[1].includes('onSubmit'), 'should include onSubmit in props');
|
|
130
|
+
assert.ok(rcCall[1].includes('title'), 'should include title in props');
|
|
131
|
+
assert.ok(!rcCall[1].includes('FormProps'), 'should NOT include type annotation');
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('handles rest spread in destructured props: const C = ({ a, ...rest }) => {}', () => {
|
|
136
|
+
const code = `const Card = ({ children, ...props }: CardProps) => { return null; };`;
|
|
137
|
+
const out = transformTsx(code);
|
|
138
|
+
assert.ok(out, 'should transform');
|
|
139
|
+
if (out!.includes('__trickle_rc')) {
|
|
140
|
+
assert.ok(out!.includes('children'), 'should include children');
|
|
141
|
+
assert.ok(out!.includes('props'), 'should include rest spread as props');
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('passes undefined for no-param arrow: const C = () => {}', () => {
|
|
146
|
+
const code = `const NoProps = () => { return null; };`;
|
|
147
|
+
const out = transformTsx(code);
|
|
148
|
+
if (out && out.includes('__trickle_rc')) {
|
|
149
|
+
assert.ok(out.includes('undefined'), 'should pass undefined for no-param component');
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// ── render count tracking ─────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
describe('Render count tracking', () => {
|
|
157
|
+
it('includes react_render kind in emitted record code', () => {
|
|
158
|
+
const code = `function Card(props) { return null; }`;
|
|
159
|
+
const out = transformTsx(code);
|
|
160
|
+
assert.ok(out, 'should transform');
|
|
161
|
+
assert.ok(out!.includes("'react_render'"), 'emitted record should have kind react_render');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('includes props data in emitted record', () => {
|
|
165
|
+
const code = `function Card(props) { return null; }`;
|
|
166
|
+
const out = transformTsx(code);
|
|
167
|
+
assert.ok(out, 'should transform');
|
|
168
|
+
assert.ok(out!.includes('rec.props'), 'should capture props onto the record');
|
|
169
|
+
assert.ok(out!.includes('propKeys'), 'should include propKeys');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('tracks multiple components in one file', () => {
|
|
173
|
+
const code = [
|
|
174
|
+
`function Header(props) { return null; }`,
|
|
175
|
+
`function Footer(props) { return null; }`,
|
|
176
|
+
`function helper(x) { return x; }`,
|
|
177
|
+
].join('\n');
|
|
178
|
+
const out = transformTsx(code);
|
|
179
|
+
assert.ok(out, 'should transform');
|
|
180
|
+
assert.ok(out!.includes('"Header"'), 'should track Header');
|
|
181
|
+
assert.ok(out!.includes('"Footer"'), 'should track Footer');
|
|
182
|
+
// helper should not be tracked as a component
|
|
183
|
+
const rcCalls = out!.match(/__trickle_rc\("helper"/g);
|
|
184
|
+
assert.ok(!rcCalls, 'lowercase helper should not be tracked');
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// ── findFunctionBodyBrace — destructured params don't confuse brace finding ───
|
|
189
|
+
|
|
190
|
+
describe('Correct function body brace detection', () => {
|
|
191
|
+
it('finds body brace even with destructured object params', () => {
|
|
192
|
+
const code = `function Form({ onSubmit, title }) {\n const x = 1;\n return null;\n}`;
|
|
193
|
+
const out = transformTsx(code);
|
|
194
|
+
assert.ok(out, 'should transform');
|
|
195
|
+
// __trickle_rc should be INSIDE the function body (before 'const x = 1')
|
|
196
|
+
const rcIdx = out!.indexOf('__trickle_rc');
|
|
197
|
+
const bodyIdx = out!.indexOf('const x = 1');
|
|
198
|
+
assert.ok(rcIdx < bodyIdx, 'render tracker must be inside the function body, before first statement');
|
|
199
|
+
// The wrap insertion (Form=__trickle_wrap(...)) should be AFTER the function body
|
|
200
|
+
const wrapIdx = out!.indexOf('Form=__trickle_wrap');
|
|
201
|
+
assert.ok(wrapIdx > bodyIdx, 'function wrap should be after the function body');
|
|
202
|
+
});
|
|
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
|
@@ -497,7 +497,8 @@ function transformEsmSource(
|
|
|
497
497
|
const funcRegex = /^[ \t]*(?:export\s+)?(?:async\s+)?function\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/gm;
|
|
498
498
|
const funcInsertions: Array<{ position: number; name: string; paramNames: string[] }> = [];
|
|
499
499
|
// Body insertions: insert at start of function body (for React render tracking)
|
|
500
|
-
|
|
500
|
+
// propsExpr: JS expression to evaluate as the props object at render time
|
|
501
|
+
const bodyInsertions: Array<{ position: number; name: string; lineNo: number; propsExpr: string }> = [];
|
|
501
502
|
let match;
|
|
502
503
|
|
|
503
504
|
while ((match = funcRegex.exec(source)) !== null) {
|
|
@@ -525,12 +526,13 @@ function transformEsmSource(
|
|
|
525
526
|
funcInsertions.push({ position: closeBrace + 1, name, paramNames });
|
|
526
527
|
|
|
527
528
|
// React component render tracking: uppercase function name in .tsx/.jsx
|
|
529
|
+
// function declarations have `arguments`, so arguments[0] is the raw props object
|
|
528
530
|
if (isReactFile && /^[A-Z]/.test(name)) {
|
|
529
531
|
let lineNo = 1;
|
|
530
532
|
for (let i = 0; i < match.index; i++) {
|
|
531
533
|
if (source[i] === '\n') lineNo++;
|
|
532
534
|
}
|
|
533
|
-
bodyInsertions.push({ position: openBrace + 1, name, lineNo });
|
|
535
|
+
bodyInsertions.push({ position: openBrace + 1, name, lineNo, propsExpr: 'arguments[0]' });
|
|
534
536
|
}
|
|
535
537
|
}
|
|
536
538
|
|
|
@@ -567,19 +569,125 @@ function transformEsmSource(
|
|
|
567
569
|
for (let i = 0; i < match.index; i++) {
|
|
568
570
|
if (source[i] === '\n') lineNo++;
|
|
569
571
|
}
|
|
570
|
-
|
|
572
|
+
|
|
573
|
+
// Determine props expression for arrow functions (no `arguments` object)
|
|
574
|
+
let propsExpr = 'undefined';
|
|
575
|
+
if (arrowParamMatch) {
|
|
576
|
+
const rawParams = (arrowParamMatch[1] || '').trim();
|
|
577
|
+
if (!rawParams) {
|
|
578
|
+
// No params: `() => {}`
|
|
579
|
+
propsExpr = 'undefined';
|
|
580
|
+
} else if (rawParams.startsWith('{')) {
|
|
581
|
+
// Destructured: ({ name, age }) => {} — reconstruct object from field names
|
|
582
|
+
// Strip TS type annotation (e.g. `{a, b}: Props` → `{a, b}`)
|
|
583
|
+
// Find the matching `}` to isolate just the destructuring pattern
|
|
584
|
+
let depth2 = 0;
|
|
585
|
+
let endBrace = -1;
|
|
586
|
+
for (let i = 0; i < rawParams.length; i++) {
|
|
587
|
+
if (rawParams[i] === '{') depth2++;
|
|
588
|
+
else if (rawParams[i] === '}') { depth2--; if (depth2 === 0) { endBrace = i; break; } }
|
|
589
|
+
}
|
|
590
|
+
const destructPattern = endBrace !== -1 ? rawParams.slice(0, endBrace + 1) : rawParams;
|
|
591
|
+
const fields = extractDestructuredNames(destructPattern);
|
|
592
|
+
if (fields.length > 0) {
|
|
593
|
+
propsExpr = `{ ${fields.join(', ')} }`;
|
|
594
|
+
}
|
|
595
|
+
} else if (arrowParamMatch[2]) {
|
|
596
|
+
// Single simple param (no parens): `props => {}`
|
|
597
|
+
propsExpr = arrowParamMatch[2];
|
|
598
|
+
} else if (paramNames.length === 1) {
|
|
599
|
+
// Single simple param: `(props) => {}`
|
|
600
|
+
propsExpr = paramNames[0];
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
bodyInsertions.push({ position: openBrace + 1, name, lineNo, propsExpr });
|
|
571
605
|
}
|
|
572
606
|
}
|
|
573
607
|
|
|
574
608
|
|
|
575
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
|
+
|
|
576
684
|
// Find variable declarations for tracing
|
|
577
685
|
const varInsertions = traceVars ? findVarDeclarations(source) : [];
|
|
578
686
|
|
|
579
687
|
// Find destructured variable declarations for tracing
|
|
580
688
|
const destructInsertions = traceVars ? findDestructuredDeclarations(source) : [];
|
|
581
689
|
|
|
582
|
-
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;
|
|
583
691
|
|
|
584
692
|
// Fix line numbers: Vite transforms (TypeScript stripping) may change line numbers.
|
|
585
693
|
// Map transformed line numbers to original source line numbers.
|
|
@@ -604,7 +712,7 @@ function transformEsmSource(
|
|
|
604
712
|
const importLines: string[] = [
|
|
605
713
|
`import { wrapFunction as __trickle_wrapFn, configure as __trickle_configure } from 'trickle-observe';`,
|
|
606
714
|
];
|
|
607
|
-
if (varInsertions.length > 0 || destructInsertions.length > 0 || bodyInsertions.length > 0) {
|
|
715
|
+
if (varInsertions.length > 0 || destructInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0) {
|
|
608
716
|
importLines.push(
|
|
609
717
|
`import { mkdirSync as __trickle_mkdirSync, appendFileSync as __trickle_appendFileSync } from 'node:fs';`,
|
|
610
718
|
`import { join as __trickle_join } from 'node:path';`,
|
|
@@ -691,7 +799,7 @@ function transformEsmSource(
|
|
|
691
799
|
if (bodyInsertions.length > 0) {
|
|
692
800
|
prefixLines.push(
|
|
693
801
|
`if (!globalThis.__trickle_react_renders) { globalThis.__trickle_react_renders = new Map(); }`,
|
|
694
|
-
`function __trickle_rc(name, line) {`,
|
|
802
|
+
`function __trickle_rc(name, line, props) {`,
|
|
695
803
|
` try {`,
|
|
696
804
|
` const key = ${JSON.stringify(filename)} + ':' + line;`,
|
|
697
805
|
` const count = (globalThis.__trickle_react_renders.get(key) || 0) + 1;`,
|
|
@@ -699,12 +807,52 @@ function transformEsmSource(
|
|
|
699
807
|
` const dir = process.env.TRICKLE_LOCAL_DIR || __trickle_join(process.cwd(), '.trickle');`,
|
|
700
808
|
` try { __trickle_mkdirSync(dir, { recursive: true }); } catch(e) {}`,
|
|
701
809
|
` const f = __trickle_join(dir, 'variables.jsonl');`,
|
|
702
|
-
`
|
|
810
|
+
` const rec = { kind: 'react_render', file: ${JSON.stringify(filename)}, line: line, component: name, renderCount: count, timestamp: Date.now() / 1000 };`,
|
|
811
|
+
` if (props !== undefined && props !== null && typeof props === 'object') {`,
|
|
812
|
+
` try {`,
|
|
813
|
+
` const propKeys = Object.keys(props).filter(k => k !== 'children');`,
|
|
814
|
+
` const propSample = {};`,
|
|
815
|
+
` for (const k of propKeys.slice(0, 10)) {`,
|
|
816
|
+
` const v = props[k];`,
|
|
817
|
+
` const t = typeof v;`,
|
|
818
|
+
` if (t === 'string') propSample[k] = v.length > 40 ? v.slice(0, 40) + '...' : v;`,
|
|
819
|
+
` else if (t === 'number' || t === 'boolean') propSample[k] = v;`,
|
|
820
|
+
` else if (v === null || v === undefined) propSample[k] = v;`,
|
|
821
|
+
` else if (Array.isArray(v)) propSample[k] = '[' + t + '[' + v.length + ']]';`,
|
|
822
|
+
` else if (t === 'function') propSample[k] = '[fn]';`,
|
|
823
|
+
` else propSample[k] = '[object]';`,
|
|
824
|
+
` }`,
|
|
825
|
+
` rec.props = propSample;`,
|
|
826
|
+
` rec.propKeys = propKeys;`,
|
|
827
|
+
` } catch(e2) {}`,
|
|
828
|
+
` }`,
|
|
829
|
+
` __trickle_appendFileSync(f, JSON.stringify(rec) + '\\n');`,
|
|
703
830
|
` } catch(e) {}`,
|
|
704
831
|
`}`,
|
|
705
832
|
);
|
|
706
833
|
}
|
|
707
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
|
+
|
|
708
856
|
prefixLines.push('');
|
|
709
857
|
const prefix = prefixLines.join('\n');
|
|
710
858
|
|
|
@@ -735,13 +883,19 @@ function transformEsmSource(
|
|
|
735
883
|
});
|
|
736
884
|
}
|
|
737
885
|
|
|
738
|
-
for (const { position, name, lineNo } of bodyInsertions) {
|
|
886
|
+
for (const { position, name, lineNo, propsExpr } of bodyInsertions) {
|
|
739
887
|
allInsertions.push({
|
|
740
888
|
position,
|
|
741
|
-
code: `\ntry{__trickle_rc(${JSON.stringify(name)},${lineNo})}catch(__e){}\n`,
|
|
889
|
+
code: `\ntry{__trickle_rc(${JSON.stringify(name)},${lineNo},${propsExpr})}catch(__e){}\n`,
|
|
742
890
|
});
|
|
743
891
|
}
|
|
744
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
|
+
|
|
745
899
|
// Sort by position descending (insert from end to preserve earlier positions)
|
|
746
900
|
allInsertions.sort((a, b) => b.position - a.position);
|
|
747
901
|
|