trickle-observe 0.2.56 → 0.2.59

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