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.
@@ -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
- bodyInsertions.push({ position: openBrace + 1, name, lineNo });
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');`, ` __trickle_appendFileSync(f, JSON.stringify({ kind: 'react_render', file: ${JSON.stringify(filename)}, line: line, component: name, renderCount: count, timestamp: Date.now() / 1000 }) + '\\n');`, ` } catch(e) {}`, `}`);
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
+ });
@@ -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
- bodyInsertions.push({ position: openBrace + 1, name, lineNo });
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');`, ` __trickle_appendFileSync(f, JSON.stringify({ kind: 'react_render', file: ${JSON.stringify(filename)}, line: line, component: name, renderCount: count, timestamp: Date.now() / 1000 }) + '\\n');`, ` } catch(e) {}`, `}`);
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.55",
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
+ });
@@ -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
- const bodyInsertions: Array<{ position: number; name: string; lineNo: number }> = [];
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
- bodyInsertions.push({ position: openBrace + 1, name, lineNo });
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
- ` __trickle_appendFileSync(f, JSON.stringify({ kind: 'react_render', file: ${JSON.stringify(filename)}, line: line, component: name, renderCount: count, timestamp: Date.now() / 1000 }) + '\\n');`,
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
 
package/tsconfig.json CHANGED
@@ -4,5 +4,6 @@
4
4
  "outDir": "./dist",
5
5
  "rootDir": "./src"
6
6
  },
7
- "include": ["src/**/*"]
7
+ "include": ["src/**/*"],
8
+ "exclude": ["src/**/*.test.ts"]
8
9
  }