trickle-observe 0.2.56 → 0.2.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -612,11 +612,88 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
612
612
  bodyInsertions.push({ position: openBrace + 1, name, lineNo, propsExpr });
613
613
  }
614
614
  }
615
+ const hookInsertions = [];
616
+ if (isReactFile) {
617
+ // Match useEffect(, useMemo(, useCallback( — also handles React.useEffect(, etc.
618
+ const hookCallRegex = /\b(useEffect|useMemo|useCallback)\s*\(/g;
619
+ let hookMatch;
620
+ while ((hookMatch = hookCallRegex.exec(source)) !== null) {
621
+ const hookName = hookMatch[1];
622
+ const afterParen = hookMatch.index + hookMatch[0].length;
623
+ // Skip past optional 'async '
624
+ let pos = afterParen;
625
+ while (pos < source.length && (source[pos] === ' ' || source[pos] === '\t' || source[pos] === '\n'))
626
+ pos++;
627
+ if (source.slice(pos, pos + 6) === 'async ') {
628
+ pos += 6;
629
+ while (pos < source.length && (source[pos] === ' ' || source[pos] === '\t'))
630
+ pos++;
631
+ }
632
+ // Expect a callback: arrow fn `(` or `identifier =>` or `function`
633
+ if (source[pos] !== '(' && !/^[a-zA-Z_$]/.test(source[pos]) && source.slice(pos, pos + 8) !== 'function')
634
+ continue;
635
+ // Find the opening `{` of the callback body depending on callback form:
636
+ // 1. Arrow with parens: (x, y) => { — call findFunctionBodyBrace from inside the (
637
+ // 2. Named/anon function: function() { — find the ( first
638
+ // 3. Single identifier: props => { — skip identifier, find =>, find {
639
+ let callbackBodyBrace = -1;
640
+ if (source[pos] === '(') {
641
+ // Arrow function with param list: () => { ... } or (x) => { ... }
642
+ callbackBodyBrace = findFunctionBodyBrace(source, pos + 1);
643
+ }
644
+ else if (source.slice(pos, pos + 8) === 'function') {
645
+ // function() {} or function name() {}
646
+ let funcPos = pos + 8;
647
+ while (funcPos < source.length && /\s/.test(source[funcPos]))
648
+ funcPos++;
649
+ if (/[a-zA-Z_$]/.test(source[funcPos])) {
650
+ while (funcPos < source.length && /[a-zA-Z0-9_$]/.test(source[funcPos]))
651
+ funcPos++;
652
+ }
653
+ while (funcPos < source.length && source[funcPos] !== '(')
654
+ funcPos++;
655
+ if (funcPos < source.length) {
656
+ callbackBodyBrace = findFunctionBodyBrace(source, funcPos + 1);
657
+ }
658
+ }
659
+ else {
660
+ // Single identifier param: props => { ... }
661
+ let idEnd = pos;
662
+ while (idEnd < source.length && /[a-zA-Z0-9_$]/.test(source[idEnd]))
663
+ idEnd++;
664
+ let arrowPos = idEnd;
665
+ while (arrowPos < source.length && (source[arrowPos] === ' ' || source[arrowPos] === '\t'))
666
+ arrowPos++;
667
+ if (source.slice(arrowPos, arrowPos + 2) === '=>') {
668
+ arrowPos += 2;
669
+ while (arrowPos < source.length && (source[arrowPos] === ' ' || source[arrowPos] === '\t' || source[arrowPos] === '\n'))
670
+ arrowPos++;
671
+ if (source[arrowPos] === '{')
672
+ callbackBodyBrace = arrowPos;
673
+ }
674
+ }
675
+ if (callbackBodyBrace === -1)
676
+ continue;
677
+ // Verify nothing suspicious between pos and the `{` (no semicolons, no other hook calls)
678
+ const between = source.slice(pos, callbackBodyBrace);
679
+ if (between.includes(';') || /\buseEffect\b|\buseMemo\b|\buseCallback\b/.test(between))
680
+ continue;
681
+ const closeBrace = findClosingBrace(source, callbackBodyBrace);
682
+ if (closeBrace === -1)
683
+ continue;
684
+ let lineNo = 1;
685
+ for (let i = 0; i < hookMatch.index; i++) {
686
+ if (source[i] === '\n')
687
+ lineNo++;
688
+ }
689
+ hookInsertions.push({ wrapStart: afterParen, wrapEnd: closeBrace + 1, hookName, lineNo });
690
+ }
691
+ }
615
692
  // Find variable declarations for tracing
616
693
  const varInsertions = traceVars ? findVarDeclarations(source) : [];
617
694
  // Find destructured variable declarations for tracing
618
695
  const destructInsertions = traceVars ? findDestructuredDeclarations(source) : [];
619
- if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && bodyInsertions.length === 0)
696
+ if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && bodyInsertions.length === 0 && hookInsertions.length === 0)
620
697
  return source;
621
698
  // Fix line numbers: Vite transforms (TypeScript stripping) may change line numbers.
622
699
  // Map transformed line numbers to original source line numbers.
@@ -641,7 +718,7 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
641
718
  const importLines = [
642
719
  `import { wrapFunction as __trickle_wrapFn, configure as __trickle_configure } from 'trickle-observe';`,
643
720
  ];
644
- if (varInsertions.length > 0 || destructInsertions.length > 0 || bodyInsertions.length > 0) {
721
+ if (varInsertions.length > 0 || destructInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0) {
645
722
  importLines.push(`import { mkdirSync as __trickle_mkdirSync, appendFileSync as __trickle_appendFileSync } from 'node:fs';`, `import { join as __trickle_join } from 'node:path';`);
646
723
  }
647
724
  const prefixLines = [
@@ -672,6 +749,10 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
672
749
  if (bodyInsertions.length > 0) {
673
750
  prefixLines.push(`if (!globalThis.__trickle_react_renders) { globalThis.__trickle_react_renders = new Map(); }`, `function __trickle_rc(name, line, props) {`, ` try {`, ` const key = ${JSON.stringify(filename)} + ':' + line;`, ` const count = (globalThis.__trickle_react_renders.get(key) || 0) + 1;`, ` globalThis.__trickle_react_renders.set(key, count);`, ` const dir = process.env.TRICKLE_LOCAL_DIR || __trickle_join(process.cwd(), '.trickle');`, ` try { __trickle_mkdirSync(dir, { recursive: true }); } catch(e) {}`, ` const f = __trickle_join(dir, 'variables.jsonl');`, ` const rec = { kind: 'react_render', file: ${JSON.stringify(filename)}, line: line, component: name, renderCount: count, timestamp: Date.now() / 1000 };`, ` if (props !== undefined && props !== null && typeof props === 'object') {`, ` try {`, ` const propKeys = Object.keys(props).filter(k => k !== 'children');`, ` const propSample = {};`, ` for (const k of propKeys.slice(0, 10)) {`, ` const v = props[k];`, ` const t = typeof v;`, ` if (t === 'string') propSample[k] = v.length > 40 ? v.slice(0, 40) + '...' : v;`, ` else if (t === 'number' || t === 'boolean') propSample[k] = v;`, ` else if (v === null || v === undefined) propSample[k] = v;`, ` else if (Array.isArray(v)) propSample[k] = '[' + t + '[' + v.length + ']]';`, ` else if (t === 'function') propSample[k] = '[fn]';`, ` else propSample[k] = '[object]';`, ` }`, ` rec.props = propSample;`, ` rec.propKeys = propKeys;`, ` } catch(e2) {}`, ` }`, ` __trickle_appendFileSync(f, JSON.stringify(rec) + '\\n');`, ` } catch(e) {}`, `}`);
674
751
  }
752
+ // Add React hook tracker if needed
753
+ if (hookInsertions.length > 0) {
754
+ prefixLines.push(`if (!globalThis.__trickle_hook_counts) { globalThis.__trickle_hook_counts = new Map(); }`, `function __trickle_hw(hookName, line, cb) {`, ` return function(...args) {`, ` try {`, ` const key = ${JSON.stringify(filename)} + ':' + line + ':' + hookName;`, ` const n = (globalThis.__trickle_hook_counts.get(key) || 0) + 1;`, ` globalThis.__trickle_hook_counts.set(key, n);`, ` const dir = process.env.TRICKLE_LOCAL_DIR || __trickle_join(process.cwd(), '.trickle');`, ` try { __trickle_mkdirSync(dir, { recursive: true }); } catch(e2) {}`, ` __trickle_appendFileSync(__trickle_join(dir, 'variables.jsonl'),`, ` JSON.stringify({ kind: 'react_hook', hookName, file: ${JSON.stringify(filename)}, line, invokeCount: n, timestamp: Date.now() / 1000 }) + '\\n');`, ` } catch(e) {}`, ` return cb(...args);`, ` };`, `}`);
755
+ }
675
756
  prefixLines.push('');
676
757
  const prefix = prefixLines.join('\n');
677
758
  const allInsertions = [];
@@ -701,6 +782,11 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
701
782
  code: `\ntry{__trickle_rc(${JSON.stringify(name)},${lineNo},${propsExpr})}catch(__e){}\n`,
702
783
  });
703
784
  }
785
+ // Hook insertions: each hook needs TWO insertions (wrapper start before callback, `)` after)
786
+ for (const { wrapStart, wrapEnd, hookName, lineNo } of hookInsertions) {
787
+ allInsertions.push({ position: wrapStart, code: `__trickle_hw(${JSON.stringify(hookName)},${lineNo},` });
788
+ allInsertions.push({ position: wrapEnd, code: `)` });
789
+ }
704
790
  // Sort by position descending (insert from end to preserve earlier positions)
705
791
  allInsertions.sort((a, b) => b.position - a.position);
706
792
  let result = source;
@@ -606,11 +606,88 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
606
606
  bodyInsertions.push({ position: openBrace + 1, name, lineNo, propsExpr });
607
607
  }
608
608
  }
609
+ const hookInsertions = [];
610
+ if (isReactFile) {
611
+ // Match useEffect(, useMemo(, useCallback( — also handles React.useEffect(, etc.
612
+ const hookCallRegex = /\b(useEffect|useMemo|useCallback)\s*\(/g;
613
+ let hookMatch;
614
+ while ((hookMatch = hookCallRegex.exec(source)) !== null) {
615
+ const hookName = hookMatch[1];
616
+ const afterParen = hookMatch.index + hookMatch[0].length;
617
+ // Skip past optional 'async '
618
+ let pos = afterParen;
619
+ while (pos < source.length && (source[pos] === ' ' || source[pos] === '\t' || source[pos] === '\n'))
620
+ pos++;
621
+ if (source.slice(pos, pos + 6) === 'async ') {
622
+ pos += 6;
623
+ while (pos < source.length && (source[pos] === ' ' || source[pos] === '\t'))
624
+ pos++;
625
+ }
626
+ // Expect a callback: arrow fn `(` or `identifier =>` or `function`
627
+ if (source[pos] !== '(' && !/^[a-zA-Z_$]/.test(source[pos]) && source.slice(pos, pos + 8) !== 'function')
628
+ continue;
629
+ // Find the opening `{` of the callback body depending on callback form:
630
+ // 1. Arrow with parens: (x, y) => { — call findFunctionBodyBrace from inside the (
631
+ // 2. Named/anon function: function() { — find the ( first
632
+ // 3. Single identifier: props => { — skip identifier, find =>, find {
633
+ let callbackBodyBrace = -1;
634
+ if (source[pos] === '(') {
635
+ // Arrow function with param list: () => { ... } or (x) => { ... }
636
+ callbackBodyBrace = findFunctionBodyBrace(source, pos + 1);
637
+ }
638
+ else if (source.slice(pos, pos + 8) === 'function') {
639
+ // function() {} or function name() {}
640
+ let funcPos = pos + 8;
641
+ while (funcPos < source.length && /\s/.test(source[funcPos]))
642
+ funcPos++;
643
+ if (/[a-zA-Z_$]/.test(source[funcPos])) {
644
+ while (funcPos < source.length && /[a-zA-Z0-9_$]/.test(source[funcPos]))
645
+ funcPos++;
646
+ }
647
+ while (funcPos < source.length && source[funcPos] !== '(')
648
+ funcPos++;
649
+ if (funcPos < source.length) {
650
+ callbackBodyBrace = findFunctionBodyBrace(source, funcPos + 1);
651
+ }
652
+ }
653
+ else {
654
+ // Single identifier param: props => { ... }
655
+ let idEnd = pos;
656
+ while (idEnd < source.length && /[a-zA-Z0-9_$]/.test(source[idEnd]))
657
+ idEnd++;
658
+ let arrowPos = idEnd;
659
+ while (arrowPos < source.length && (source[arrowPos] === ' ' || source[arrowPos] === '\t'))
660
+ arrowPos++;
661
+ if (source.slice(arrowPos, arrowPos + 2) === '=>') {
662
+ arrowPos += 2;
663
+ while (arrowPos < source.length && (source[arrowPos] === ' ' || source[arrowPos] === '\t' || source[arrowPos] === '\n'))
664
+ arrowPos++;
665
+ if (source[arrowPos] === '{')
666
+ callbackBodyBrace = arrowPos;
667
+ }
668
+ }
669
+ if (callbackBodyBrace === -1)
670
+ continue;
671
+ // Verify nothing suspicious between pos and the `{` (no semicolons, no other hook calls)
672
+ const between = source.slice(pos, callbackBodyBrace);
673
+ if (between.includes(';') || /\buseEffect\b|\buseMemo\b|\buseCallback\b/.test(between))
674
+ continue;
675
+ const closeBrace = findClosingBrace(source, callbackBodyBrace);
676
+ if (closeBrace === -1)
677
+ continue;
678
+ let lineNo = 1;
679
+ for (let i = 0; i < hookMatch.index; i++) {
680
+ if (source[i] === '\n')
681
+ lineNo++;
682
+ }
683
+ hookInsertions.push({ wrapStart: afterParen, wrapEnd: closeBrace + 1, hookName, lineNo });
684
+ }
685
+ }
609
686
  // Find variable declarations for tracing
610
687
  const varInsertions = traceVars ? findVarDeclarations(source) : [];
611
688
  // Find destructured variable declarations for tracing
612
689
  const destructInsertions = traceVars ? findDestructuredDeclarations(source) : [];
613
- if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && bodyInsertions.length === 0)
690
+ if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && bodyInsertions.length === 0 && hookInsertions.length === 0)
614
691
  return source;
615
692
  // Fix line numbers: Vite transforms (TypeScript stripping) may change line numbers.
616
693
  // Map transformed line numbers to original source line numbers.
@@ -635,7 +712,7 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
635
712
  const importLines = [
636
713
  `import { wrapFunction as __trickle_wrapFn, configure as __trickle_configure } from 'trickle-observe';`,
637
714
  ];
638
- if (varInsertions.length > 0 || destructInsertions.length > 0 || bodyInsertions.length > 0) {
715
+ if (varInsertions.length > 0 || destructInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0) {
639
716
  importLines.push(`import { mkdirSync as __trickle_mkdirSync, appendFileSync as __trickle_appendFileSync } from 'node:fs';`, `import { join as __trickle_join } from 'node:path';`);
640
717
  }
641
718
  const prefixLines = [
@@ -666,6 +743,10 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
666
743
  if (bodyInsertions.length > 0) {
667
744
  prefixLines.push(`if (!globalThis.__trickle_react_renders) { globalThis.__trickle_react_renders = new Map(); }`, `function __trickle_rc(name, line, props) {`, ` try {`, ` const key = ${JSON.stringify(filename)} + ':' + line;`, ` const count = (globalThis.__trickle_react_renders.get(key) || 0) + 1;`, ` globalThis.__trickle_react_renders.set(key, count);`, ` const dir = process.env.TRICKLE_LOCAL_DIR || __trickle_join(process.cwd(), '.trickle');`, ` try { __trickle_mkdirSync(dir, { recursive: true }); } catch(e) {}`, ` const f = __trickle_join(dir, 'variables.jsonl');`, ` const rec = { kind: 'react_render', file: ${JSON.stringify(filename)}, line: line, component: name, renderCount: count, timestamp: Date.now() / 1000 };`, ` if (props !== undefined && props !== null && typeof props === 'object') {`, ` try {`, ` const propKeys = Object.keys(props).filter(k => k !== 'children');`, ` const propSample = {};`, ` for (const k of propKeys.slice(0, 10)) {`, ` const v = props[k];`, ` const t = typeof v;`, ` if (t === 'string') propSample[k] = v.length > 40 ? v.slice(0, 40) + '...' : v;`, ` else if (t === 'number' || t === 'boolean') propSample[k] = v;`, ` else if (v === null || v === undefined) propSample[k] = v;`, ` else if (Array.isArray(v)) propSample[k] = '[' + t + '[' + v.length + ']]';`, ` else if (t === 'function') propSample[k] = '[fn]';`, ` else propSample[k] = '[object]';`, ` }`, ` rec.props = propSample;`, ` rec.propKeys = propKeys;`, ` } catch(e2) {}`, ` }`, ` __trickle_appendFileSync(f, JSON.stringify(rec) + '\\n');`, ` } catch(e) {}`, `}`);
668
745
  }
746
+ // Add React hook tracker if needed
747
+ if (hookInsertions.length > 0) {
748
+ prefixLines.push(`if (!globalThis.__trickle_hook_counts) { globalThis.__trickle_hook_counts = new Map(); }`, `function __trickle_hw(hookName, line, cb) {`, ` return function(...args) {`, ` try {`, ` const key = ${JSON.stringify(filename)} + ':' + line + ':' + hookName;`, ` const n = (globalThis.__trickle_hook_counts.get(key) || 0) + 1;`, ` globalThis.__trickle_hook_counts.set(key, n);`, ` const dir = process.env.TRICKLE_LOCAL_DIR || __trickle_join(process.cwd(), '.trickle');`, ` try { __trickle_mkdirSync(dir, { recursive: true }); } catch(e2) {}`, ` __trickle_appendFileSync(__trickle_join(dir, 'variables.jsonl'),`, ` JSON.stringify({ kind: 'react_hook', hookName, file: ${JSON.stringify(filename)}, line, invokeCount: n, timestamp: Date.now() / 1000 }) + '\\n');`, ` } catch(e) {}`, ` return cb(...args);`, ` };`, `}`);
749
+ }
669
750
  prefixLines.push('');
670
751
  const prefix = prefixLines.join('\n');
671
752
  const allInsertions = [];
@@ -695,6 +776,11 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
695
776
  code: `\ntry{__trickle_rc(${JSON.stringify(name)},${lineNo},${propsExpr})}catch(__e){}\n`,
696
777
  });
697
778
  }
779
+ // Hook insertions: each hook needs TWO insertions (wrapper start before callback, `)` after)
780
+ for (const { wrapStart, wrapEnd, hookName, lineNo } of hookInsertions) {
781
+ allInsertions.push({ position: wrapStart, code: `__trickle_hw(${JSON.stringify(hookName)},${lineNo},` });
782
+ allInsertions.push({ position: wrapEnd, code: `)` });
783
+ }
698
784
  // Sort by position descending (insert from end to preserve earlier positions)
699
785
  allInsertions.sort((a, b) => b.position - a.position);
700
786
  let result = source;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trickle-observe",
3
- "version": "0.2.56",
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",
@@ -201,3 +201,69 @@ describe('Correct function body brace detection', () => {
201
201
  assert.ok(wrapIdx > bodyIdx, 'function wrap should be after the function body');
202
202
  });
203
203
  });
204
+
205
+ // ── React hook observability ──────────────────────────────────────────────────
206
+
207
+ describe('React hook observability', () => {
208
+ it('wraps useEffect callback with __trickle_hw', () => {
209
+ const code = `function App() {\n useEffect(() => {\n console.log('hi');\n }, []);\n return null;\n}`;
210
+ const out = transformTsx(code);
211
+ assert.ok(out, 'should transform');
212
+ assert.ok(out!.includes('__trickle_hw'), 'should inject hook wrapper');
213
+ assert.ok(out!.includes('"useEffect"'), 'should include hook name');
214
+ });
215
+
216
+ it('wraps useMemo callback with __trickle_hw', () => {
217
+ const code = `function App() {\n const val = useMemo(() => {\n return expensive();\n }, [dep]);\n return null;\n}`;
218
+ const out = transformTsx(code);
219
+ assert.ok(out, 'should transform');
220
+ assert.ok(out!.includes('__trickle_hw'), 'should inject hook wrapper');
221
+ assert.ok(out!.includes('"useMemo"'), 'should include hook name');
222
+ });
223
+
224
+ it('wraps useCallback callback with __trickle_hw', () => {
225
+ const code = `function App() {\n const fn = useCallback(() => {\n doSomething();\n }, [dep]);\n return null;\n}`;
226
+ const out = transformTsx(code);
227
+ assert.ok(out, 'should transform');
228
+ assert.ok(out!.includes('__trickle_hw'), 'should inject hook wrapper');
229
+ assert.ok(out!.includes('"useCallback"'), 'should include hook name');
230
+ });
231
+
232
+ it('wraps all three hook types in the same component', () => {
233
+ const code = [
234
+ `function Dashboard() {`,
235
+ ` useEffect(() => { fetch('/api'); }, []);`,
236
+ ` const data = useMemo(() => { return transform(raw); }, [raw]);`,
237
+ ` const handler = useCallback(() => { handleClick(); }, []);`,
238
+ ` return null;`,
239
+ `}`,
240
+ ].join('\n');
241
+ const out = transformTsx(code);
242
+ assert.ok(out, 'should transform');
243
+ const hwCount = (out!.match(/__trickle_hw/g) || []).length;
244
+ // preamble definition + 3 call sites = 4 occurrences
245
+ assert.ok(hwCount >= 4, `should have at least 4 __trickle_hw occurrences (preamble + 3 wraps), got ${hwCount}`);
246
+ });
247
+
248
+ it('includes react_hook kind in emitted record code', () => {
249
+ const code = `function App() {\n useEffect(() => {\n console.log('hi');\n }, []);\n return null;\n}`;
250
+ const out = transformTsx(code);
251
+ assert.ok(out, 'should transform');
252
+ assert.ok(out!.includes("'react_hook'"), 'emitted record should have kind react_hook');
253
+ });
254
+
255
+ it('does NOT inject hook tracking in .ts files', () => {
256
+ const code = `function helper() {\n useEffect(() => { doStuff(); }, []);\n return null;\n}`;
257
+ const out = transformTs(code);
258
+ if (out) {
259
+ assert.ok(!out.includes('__trickle_hw'), 'should NOT inject hook tracker in .ts files');
260
+ }
261
+ });
262
+
263
+ it('wraps useEffect with single identifier param callback', () => {
264
+ const code = `function App() {\n useEffect(function() {\n console.log('hi');\n }, []);\n return null;\n}`;
265
+ const out = transformTsx(code);
266
+ assert.ok(out, 'should transform');
267
+ assert.ok(out!.includes('__trickle_hw'), 'should inject hook wrapper for function() {} form');
268
+ });
269
+ });
@@ -607,13 +607,87 @@ function transformEsmSource(
607
607
 
608
608
 
609
609
 
610
+ // React hook tracking — wrap the callback arg of useEffect/useMemo/useCallback
611
+ // to count how many times each hook fires (effect ran, memo recomputed, callback invoked).
612
+ // Each hook produces TWO insertions: wrapStart (before callback) and wrapEnd (after callback `}`).
613
+ interface HookInsertion { wrapStart: number; wrapEnd: number; hookName: string; lineNo: number }
614
+ const hookInsertions: HookInsertion[] = [];
615
+
616
+ if (isReactFile) {
617
+ // Match useEffect(, useMemo(, useCallback( — also handles React.useEffect(, etc.
618
+ const hookCallRegex = /\b(useEffect|useMemo|useCallback)\s*\(/g;
619
+ let hookMatch;
620
+ while ((hookMatch = hookCallRegex.exec(source)) !== null) {
621
+ const hookName = hookMatch[1];
622
+ const afterParen = hookMatch.index + hookMatch[0].length;
623
+
624
+ // Skip past optional 'async '
625
+ let pos = afterParen;
626
+ while (pos < source.length && (source[pos] === ' ' || source[pos] === '\t' || source[pos] === '\n')) pos++;
627
+ if (source.slice(pos, pos + 6) === 'async ') {
628
+ pos += 6;
629
+ while (pos < source.length && (source[pos] === ' ' || source[pos] === '\t')) pos++;
630
+ }
631
+
632
+ // Expect a callback: arrow fn `(` or `identifier =>` or `function`
633
+ if (source[pos] !== '(' && !/^[a-zA-Z_$]/.test(source[pos]) && source.slice(pos, pos + 8) !== 'function') continue;
634
+
635
+ // Find the opening `{` of the callback body depending on callback form:
636
+ // 1. Arrow with parens: (x, y) => { — call findFunctionBodyBrace from inside the (
637
+ // 2. Named/anon function: function() { — find the ( first
638
+ // 3. Single identifier: props => { — skip identifier, find =>, find {
639
+ let callbackBodyBrace = -1;
640
+ if (source[pos] === '(') {
641
+ // Arrow function with param list: () => { ... } or (x) => { ... }
642
+ callbackBodyBrace = findFunctionBodyBrace(source, pos + 1);
643
+ } else if (source.slice(pos, pos + 8) === 'function') {
644
+ // function() {} or function name() {}
645
+ let funcPos = pos + 8;
646
+ while (funcPos < source.length && /\s/.test(source[funcPos])) funcPos++;
647
+ if (/[a-zA-Z_$]/.test(source[funcPos])) {
648
+ while (funcPos < source.length && /[a-zA-Z0-9_$]/.test(source[funcPos])) funcPos++;
649
+ }
650
+ while (funcPos < source.length && source[funcPos] !== '(') funcPos++;
651
+ if (funcPos < source.length) {
652
+ callbackBodyBrace = findFunctionBodyBrace(source, funcPos + 1);
653
+ }
654
+ } else {
655
+ // Single identifier param: props => { ... }
656
+ let idEnd = pos;
657
+ while (idEnd < source.length && /[a-zA-Z0-9_$]/.test(source[idEnd])) idEnd++;
658
+ let arrowPos = idEnd;
659
+ while (arrowPos < source.length && (source[arrowPos] === ' ' || source[arrowPos] === '\t')) arrowPos++;
660
+ if (source.slice(arrowPos, arrowPos + 2) === '=>') {
661
+ arrowPos += 2;
662
+ while (arrowPos < source.length && (source[arrowPos] === ' ' || source[arrowPos] === '\t' || source[arrowPos] === '\n')) arrowPos++;
663
+ if (source[arrowPos] === '{') callbackBodyBrace = arrowPos;
664
+ }
665
+ }
666
+ if (callbackBodyBrace === -1) continue;
667
+
668
+ // Verify nothing suspicious between pos and the `{` (no semicolons, no other hook calls)
669
+ const between = source.slice(pos, callbackBodyBrace);
670
+ if (between.includes(';') || /\buseEffect\b|\buseMemo\b|\buseCallback\b/.test(between)) continue;
671
+
672
+ const closeBrace = findClosingBrace(source, callbackBodyBrace);
673
+ if (closeBrace === -1) continue;
674
+
675
+ let lineNo = 1;
676
+ for (let i = 0; i < hookMatch.index; i++) {
677
+ if (source[i] === '\n') lineNo++;
678
+ }
679
+
680
+ hookInsertions.push({ wrapStart: afterParen, wrapEnd: closeBrace + 1, hookName, lineNo });
681
+ }
682
+ }
683
+
610
684
  // Find variable declarations for tracing
611
685
  const varInsertions = traceVars ? findVarDeclarations(source) : [];
612
686
 
613
687
  // Find destructured variable declarations for tracing
614
688
  const destructInsertions = traceVars ? findDestructuredDeclarations(source) : [];
615
689
 
616
- if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && bodyInsertions.length === 0) return source;
690
+ if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && bodyInsertions.length === 0 && hookInsertions.length === 0) return source;
617
691
 
618
692
  // Fix line numbers: Vite transforms (TypeScript stripping) may change line numbers.
619
693
  // Map transformed line numbers to original source line numbers.
@@ -638,7 +712,7 @@ function transformEsmSource(
638
712
  const importLines: string[] = [
639
713
  `import { wrapFunction as __trickle_wrapFn, configure as __trickle_configure } from 'trickle-observe';`,
640
714
  ];
641
- if (varInsertions.length > 0 || destructInsertions.length > 0 || bodyInsertions.length > 0) {
715
+ if (varInsertions.length > 0 || destructInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0) {
642
716
  importLines.push(
643
717
  `import { mkdirSync as __trickle_mkdirSync, appendFileSync as __trickle_appendFileSync } from 'node:fs';`,
644
718
  `import { join as __trickle_join } from 'node:path';`,
@@ -758,6 +832,27 @@ function transformEsmSource(
758
832
  );
759
833
  }
760
834
 
835
+ // Add React hook tracker if needed
836
+ if (hookInsertions.length > 0) {
837
+ prefixLines.push(
838
+ `if (!globalThis.__trickle_hook_counts) { globalThis.__trickle_hook_counts = new Map(); }`,
839
+ `function __trickle_hw(hookName, line, cb) {`,
840
+ ` return function(...args) {`,
841
+ ` try {`,
842
+ ` const key = ${JSON.stringify(filename)} + ':' + line + ':' + hookName;`,
843
+ ` const n = (globalThis.__trickle_hook_counts.get(key) || 0) + 1;`,
844
+ ` globalThis.__trickle_hook_counts.set(key, n);`,
845
+ ` const dir = process.env.TRICKLE_LOCAL_DIR || __trickle_join(process.cwd(), '.trickle');`,
846
+ ` try { __trickle_mkdirSync(dir, { recursive: true }); } catch(e2) {}`,
847
+ ` __trickle_appendFileSync(__trickle_join(dir, 'variables.jsonl'),`,
848
+ ` JSON.stringify({ kind: 'react_hook', hookName, file: ${JSON.stringify(filename)}, line, invokeCount: n, timestamp: Date.now() / 1000 }) + '\\n');`,
849
+ ` } catch(e) {}`,
850
+ ` return cb(...args);`,
851
+ ` };`,
852
+ `}`,
853
+ );
854
+ }
855
+
761
856
  prefixLines.push('');
762
857
  const prefix = prefixLines.join('\n');
763
858
 
@@ -795,6 +890,12 @@ function transformEsmSource(
795
890
  });
796
891
  }
797
892
 
893
+ // Hook insertions: each hook needs TWO insertions (wrapper start before callback, `)` after)
894
+ for (const { wrapStart, wrapEnd, hookName, lineNo } of hookInsertions) {
895
+ allInsertions.push({ position: wrapStart, code: `__trickle_hw(${JSON.stringify(hookName)},${lineNo},` });
896
+ allInsertions.push({ position: wrapEnd, code: `)` });
897
+ }
898
+
798
899
  // Sort by position descending (insert from end to preserve earlier positions)
799
900
  allInsertions.sort((a, b) => b.position - a.position);
800
901