trickle-observe 0.2.58 → 0.2.60

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.
@@ -689,11 +689,57 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
689
689
  hookInsertions.push({ wrapStart: afterParen, wrapEnd: closeBrace + 1, hookName, lineNo });
690
690
  }
691
691
  }
692
+ const stateInsertions = [];
693
+ if (isReactFile) {
694
+ const useStateRegex = /const\s+\[([a-zA-Z_$][a-zA-Z0-9_$]*)\s*,\s*([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\]\s*=\s*(?:React\.)?useState\s*(?:<[^(]*>)?\s*\(/gm;
695
+ let sm;
696
+ while ((sm = useStateRegex.exec(source)) !== null) {
697
+ const stateName = sm[1];
698
+ const setterName = sm[2];
699
+ // Find the position of setterName within the match (after the comma)
700
+ const matchStr = sm[0];
701
+ const commaIdx = matchStr.indexOf(',');
702
+ const setterInMatch = matchStr.indexOf(setterName, commaIdx);
703
+ if (setterInMatch === -1)
704
+ continue;
705
+ const renamePos = sm.index + setterInMatch;
706
+ // Skip the useState(...) argument list to find the end of the statement
707
+ let pos = sm.index + sm[0].length;
708
+ let depth = 1;
709
+ while (pos < source.length && depth > 0) {
710
+ const ch = source[pos];
711
+ if (ch === '(')
712
+ depth++;
713
+ else if (ch === ')')
714
+ depth--;
715
+ else if (ch === '"' || ch === "'" || ch === '`') {
716
+ const q = ch;
717
+ pos++;
718
+ while (pos < source.length && source[pos] !== q) {
719
+ if (source[pos] === '\\')
720
+ pos++;
721
+ pos++;
722
+ }
723
+ }
724
+ pos++;
725
+ }
726
+ // Skip to end of line (past semicolon or newline)
727
+ while (pos < source.length && source[pos] !== ';' && source[pos] !== '\n')
728
+ pos++;
729
+ const afterLine = pos + 1;
730
+ let lineNo = 1;
731
+ for (let i = 0; i < sm.index; i++) {
732
+ if (source[i] === '\n')
733
+ lineNo++;
734
+ }
735
+ stateInsertions.push({ renamePos, afterLine, stateName, setterName, lineNo });
736
+ }
737
+ }
692
738
  // Find variable declarations for tracing
693
739
  const varInsertions = traceVars ? findVarDeclarations(source) : [];
694
740
  // Find destructured variable declarations for tracing
695
741
  const destructInsertions = traceVars ? findDestructuredDeclarations(source) : [];
696
- if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && bodyInsertions.length === 0 && hookInsertions.length === 0)
742
+ if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && bodyInsertions.length === 0 && hookInsertions.length === 0 && stateInsertions.length === 0)
697
743
  return source;
698
744
  // Fix line numbers: Vite transforms (TypeScript stripping) may change line numbers.
699
745
  // Map transformed line numbers to original source line numbers.
@@ -718,7 +764,7 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
718
764
  const importLines = [
719
765
  `import { wrapFunction as __trickle_wrapFn, configure as __trickle_configure } from 'trickle-observe';`,
720
766
  ];
721
- if (varInsertions.length > 0 || destructInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0) {
767
+ if (varInsertions.length > 0 || destructInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0 || stateInsertions.length > 0) {
722
768
  importLines.push(`import { mkdirSync as __trickle_mkdirSync, appendFileSync as __trickle_appendFileSync } from 'node:fs';`, `import { join as __trickle_join } from 'node:path';`);
723
769
  }
724
770
  const prefixLines = [
@@ -747,12 +793,16 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
747
793
  }
748
794
  // Add React component render tracker if needed
749
795
  if (bodyInsertions.length > 0) {
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) {}`, `}`);
796
+ 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
797
  }
752
798
  // Add React hook tracker if needed
753
799
  if (hookInsertions.length > 0) {
754
800
  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
801
  }
802
+ // Add useState setter tracker if needed
803
+ if (stateInsertions.length > 0) {
804
+ prefixLines.push(`if (!globalThis.__trickle_state_counts) { globalThis.__trickle_state_counts = new Map(); }`, `function __trickle_ss(stateName, line, origSetter) {`, ` return function(newVal) {`, ` try {`, ` const key = ${JSON.stringify(filename)} + ':' + line + ':' + stateName;`, ` const n = (globalThis.__trickle_state_counts.get(key) || 0) + 1;`, ` globalThis.__trickle_state_counts.set(key, n);`, ` const t = typeof newVal;`, ` let sample;`, ` if (t === 'function') sample = '[fn updater]';`, ` else if (t === 'string') sample = newVal.length > 40 ? newVal.slice(0,40)+'...' : newVal;`, ` else if (t === 'number' || t === 'boolean') sample = newVal;`, ` else if (newVal === null || newVal === undefined) sample = newVal;`, ` else if (Array.isArray(newVal)) sample = '[arr:'+newVal.length+']';`, ` else sample = '[object]';`, ` 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_state', file: ${JSON.stringify(filename)}, line, stateName, updateCount: n, value: sample, timestamp: Date.now()/1000 }) + '\\n');`, ` } catch(e) {}`, ` return origSetter(newVal);`, ` };`, `}`);
805
+ }
756
806
  prefixLines.push('');
757
807
  const prefix = prefixLines.join('\n');
758
808
  const allInsertions = [];
@@ -787,6 +837,16 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
787
837
  allInsertions.push({ position: wrapStart, code: `__trickle_hw(${JSON.stringify(hookName)},${lineNo},` });
788
838
  allInsertions.push({ position: wrapEnd, code: `)` });
789
839
  }
840
+ // useState insertions: TWO insertions per useState
841
+ // 1. Prefix setter name with '__trickle_s_' (rename in destructuring)
842
+ // 2. After statement end, declare tracked wrapper: const setter = __trickle_ss(...)
843
+ for (const { renamePos, afterLine, stateName, setterName, lineNo } of stateInsertions) {
844
+ allInsertions.push({ position: renamePos, code: `__trickle_s_` });
845
+ allInsertions.push({
846
+ position: afterLine,
847
+ code: `const ${setterName}=__trickle_ss(${JSON.stringify(stateName)},${lineNo},__trickle_s_${setterName});\n`,
848
+ });
849
+ }
790
850
  // Sort by position descending (insert from end to preserve earlier positions)
791
851
  allInsertions.sort((a, b) => b.position - a.position);
792
852
  let result = source;
@@ -683,11 +683,57 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
683
683
  hookInsertions.push({ wrapStart: afterParen, wrapEnd: closeBrace + 1, hookName, lineNo });
684
684
  }
685
685
  }
686
+ const stateInsertions = [];
687
+ if (isReactFile) {
688
+ const useStateRegex = /const\s+\[([a-zA-Z_$][a-zA-Z0-9_$]*)\s*,\s*([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\]\s*=\s*(?:React\.)?useState\s*(?:<[^(]*>)?\s*\(/gm;
689
+ let sm;
690
+ while ((sm = useStateRegex.exec(source)) !== null) {
691
+ const stateName = sm[1];
692
+ const setterName = sm[2];
693
+ // Find the position of setterName within the match (after the comma)
694
+ const matchStr = sm[0];
695
+ const commaIdx = matchStr.indexOf(',');
696
+ const setterInMatch = matchStr.indexOf(setterName, commaIdx);
697
+ if (setterInMatch === -1)
698
+ continue;
699
+ const renamePos = sm.index + setterInMatch;
700
+ // Skip the useState(...) argument list to find the end of the statement
701
+ let pos = sm.index + sm[0].length;
702
+ let depth = 1;
703
+ while (pos < source.length && depth > 0) {
704
+ const ch = source[pos];
705
+ if (ch === '(')
706
+ depth++;
707
+ else if (ch === ')')
708
+ depth--;
709
+ else if (ch === '"' || ch === "'" || ch === '`') {
710
+ const q = ch;
711
+ pos++;
712
+ while (pos < source.length && source[pos] !== q) {
713
+ if (source[pos] === '\\')
714
+ pos++;
715
+ pos++;
716
+ }
717
+ }
718
+ pos++;
719
+ }
720
+ // Skip to end of line (past semicolon or newline)
721
+ while (pos < source.length && source[pos] !== ';' && source[pos] !== '\n')
722
+ pos++;
723
+ const afterLine = pos + 1;
724
+ let lineNo = 1;
725
+ for (let i = 0; i < sm.index; i++) {
726
+ if (source[i] === '\n')
727
+ lineNo++;
728
+ }
729
+ stateInsertions.push({ renamePos, afterLine, stateName, setterName, lineNo });
730
+ }
731
+ }
686
732
  // Find variable declarations for tracing
687
733
  const varInsertions = traceVars ? findVarDeclarations(source) : [];
688
734
  // Find destructured variable declarations for tracing
689
735
  const destructInsertions = traceVars ? findDestructuredDeclarations(source) : [];
690
- if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && bodyInsertions.length === 0 && hookInsertions.length === 0)
736
+ if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && bodyInsertions.length === 0 && hookInsertions.length === 0 && stateInsertions.length === 0)
691
737
  return source;
692
738
  // Fix line numbers: Vite transforms (TypeScript stripping) may change line numbers.
693
739
  // Map transformed line numbers to original source line numbers.
@@ -712,7 +758,7 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
712
758
  const importLines = [
713
759
  `import { wrapFunction as __trickle_wrapFn, configure as __trickle_configure } from 'trickle-observe';`,
714
760
  ];
715
- if (varInsertions.length > 0 || destructInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0) {
761
+ if (varInsertions.length > 0 || destructInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0 || stateInsertions.length > 0) {
716
762
  importLines.push(`import { mkdirSync as __trickle_mkdirSync, appendFileSync as __trickle_appendFileSync } from 'node:fs';`, `import { join as __trickle_join } from 'node:path';`);
717
763
  }
718
764
  const prefixLines = [
@@ -741,12 +787,16 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
741
787
  }
742
788
  // Add React component render tracker if needed
743
789
  if (bodyInsertions.length > 0) {
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) {}`, `}`);
790
+ 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
791
  }
746
792
  // Add React hook tracker if needed
747
793
  if (hookInsertions.length > 0) {
748
794
  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
795
  }
796
+ // Add useState setter tracker if needed
797
+ if (stateInsertions.length > 0) {
798
+ prefixLines.push(`if (!globalThis.__trickle_state_counts) { globalThis.__trickle_state_counts = new Map(); }`, `function __trickle_ss(stateName, line, origSetter) {`, ` return function(newVal) {`, ` try {`, ` const key = ${JSON.stringify(filename)} + ':' + line + ':' + stateName;`, ` const n = (globalThis.__trickle_state_counts.get(key) || 0) + 1;`, ` globalThis.__trickle_state_counts.set(key, n);`, ` const t = typeof newVal;`, ` let sample;`, ` if (t === 'function') sample = '[fn updater]';`, ` else if (t === 'string') sample = newVal.length > 40 ? newVal.slice(0,40)+'...' : newVal;`, ` else if (t === 'number' || t === 'boolean') sample = newVal;`, ` else if (newVal === null || newVal === undefined) sample = newVal;`, ` else if (Array.isArray(newVal)) sample = '[arr:'+newVal.length+']';`, ` else sample = '[object]';`, ` 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_state', file: ${JSON.stringify(filename)}, line, stateName, updateCount: n, value: sample, timestamp: Date.now()/1000 }) + '\\n');`, ` } catch(e) {}`, ` return origSetter(newVal);`, ` };`, `}`);
799
+ }
750
800
  prefixLines.push('');
751
801
  const prefix = prefixLines.join('\n');
752
802
  const allInsertions = [];
@@ -781,6 +831,16 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
781
831
  allInsertions.push({ position: wrapStart, code: `__trickle_hw(${JSON.stringify(hookName)},${lineNo},` });
782
832
  allInsertions.push({ position: wrapEnd, code: `)` });
783
833
  }
834
+ // useState insertions: TWO insertions per useState
835
+ // 1. Prefix setter name with '__trickle_s_' (rename in destructuring)
836
+ // 2. After statement end, declare tracked wrapper: const setter = __trickle_ss(...)
837
+ for (const { renamePos, afterLine, stateName, setterName, lineNo } of stateInsertions) {
838
+ allInsertions.push({ position: renamePos, code: `__trickle_s_` });
839
+ allInsertions.push({
840
+ position: afterLine,
841
+ code: `const ${setterName}=__trickle_ss(${JSON.stringify(stateName)},${lineNo},__trickle_s_${setterName});\n`,
842
+ });
843
+ }
784
844
  // Sort by position descending (insert from end to preserve earlier positions)
785
845
  allInsertions.sort((a, b) => b.position - a.position);
786
846
  let result = source;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trickle-observe",
3
- "version": "0.2.58",
3
+ "version": "0.2.60",
4
4
  "description": "Runtime type observability for JavaScript applications",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -202,6 +202,101 @@ describe('Correct function body brace detection', () => {
202
202
  });
203
203
  });
204
204
 
205
+ // ── useState change tracking ──────────────────────────────────────────────────
206
+
207
+ describe('useState change tracking', () => {
208
+ it('renames setter and injects __trickle_ss wrapper for simple useState', () => {
209
+ const code = `function App() {\n const [count, setCount] = useState(0);\n return null;\n}`;
210
+ const out = transformTsx(code);
211
+ assert.ok(out, 'should transform');
212
+ assert.ok(out!.includes('__trickle_ss'), 'should inject state setter wrapper');
213
+ assert.ok(out!.includes('__trickle_s_setCount'), 'should rename original setter');
214
+ assert.ok(out!.includes('const setCount=__trickle_ss'), 'should declare tracked setter');
215
+ });
216
+
217
+ it('tracks state name in the wrapper call', () => {
218
+ const code = `function App() {\n const [isOpen, setIsOpen] = useState(false);\n return null;\n}`;
219
+ const out = transformTsx(code);
220
+ assert.ok(out, 'should transform');
221
+ assert.ok(out!.includes('"isOpen"'), 'should include state variable name');
222
+ });
223
+
224
+ it('handles TypeScript generic useState<T>', () => {
225
+ const code = `function App() {\n const [name, setName] = useState<string>('');\n return null;\n}`;
226
+ const out = transformTsx(code);
227
+ assert.ok(out, 'should transform');
228
+ assert.ok(out!.includes('__trickle_ss'), 'should inject state setter wrapper for generic useState');
229
+ assert.ok(out!.includes('"name"'), 'should include state name');
230
+ });
231
+
232
+ it('tracks multiple useState calls in one component', () => {
233
+ const code = [
234
+ `function Dashboard() {`,
235
+ ` const [count, setCount] = useState(0);`,
236
+ ` const [name, setName] = useState('');`,
237
+ ` const [active, setActive] = useState(false);`,
238
+ ` return null;`,
239
+ `}`,
240
+ ].join('\n');
241
+ const out = transformTsx(code);
242
+ assert.ok(out, 'should transform');
243
+ const ssCount = (out!.match(/const \w+=__trickle_ss/g) || []).length;
244
+ assert.equal(ssCount, 3, 'should wrap all 3 useState setters');
245
+ });
246
+
247
+ it('emits react_state kind in preamble code', () => {
248
+ const code = `function App() {\n const [x, setX] = useState(0);\n return null;\n}`;
249
+ const out = transformTsx(code);
250
+ assert.ok(out, 'should transform');
251
+ assert.ok(out!.includes("'react_state'"), 'emitted record should have kind react_state');
252
+ });
253
+
254
+ it('does NOT inject useState tracking in .ts files', () => {
255
+ const code = `function helper() {\n const [x, setX] = useState(0);\n return null;\n}`;
256
+ const out = transformTs(code);
257
+ if (out) {
258
+ assert.ok(!out.includes('__trickle_ss'), 'should NOT inject state tracking in .ts files');
259
+ }
260
+ });
261
+ });
262
+
263
+ // ── Re-render cause detection ─────────────────────────────────────────────────
264
+
265
+ describe('Re-render cause detection', () => {
266
+ it('emits changedProps tracking code in transformed output', () => {
267
+ const code = `function Card({ count, label }) { return null; }`;
268
+ const out = transformTsx(code);
269
+ assert.ok(out, 'should transform');
270
+ // Should include the prev_props map and comparison logic
271
+ assert.ok(out!.includes('__trickle_react_prev_props'), 'should initialize prev_props map');
272
+ assert.ok(out!.includes('changedProps'), 'should include changedProps detection');
273
+ });
274
+
275
+ it('includes prev props comparison logic in __trickle_rc', () => {
276
+ const code = `function UserCard({ name, age }) { return null; }`;
277
+ const out = transformTsx(code);
278
+ assert.ok(out, 'should transform');
279
+ assert.ok(out!.includes('prevProps'), 'should reference prevProps');
280
+ assert.ok(out!.includes('globalThis.__trickle_react_prev_props.set'), 'should store current props as prev');
281
+ });
282
+
283
+ it('stores previous props keyed by component file+line', () => {
284
+ const code = `function Button({ disabled, onClick }) { return null; }`;
285
+ const out = transformTsx(code);
286
+ assert.ok(out, 'should transform');
287
+ // The key used for prev_props storage should be the same as the render key
288
+ assert.ok(out!.includes('globalThis.__trickle_react_prev_props.get(key)'), 'should retrieve prev props by key');
289
+ });
290
+
291
+ it('does not emit prev_props tracking in .ts files', () => {
292
+ const code = `function helper({ x, y }) { return x + y; }`;
293
+ const out = transformTs(code);
294
+ if (out) {
295
+ assert.ok(!out.includes('__trickle_react_prev_props'), 'should NOT track prev props in .ts files');
296
+ }
297
+ });
298
+ });
299
+
205
300
  // ── React hook observability ──────────────────────────────────────────────────
206
301
 
207
302
  describe('React hook observability', () => {
@@ -681,13 +681,67 @@ function transformEsmSource(
681
681
  }
682
682
  }
683
683
 
684
+ // React useState tracking — rename setter to __trickle_s_X and declare tracked wrapper.
685
+ // Detects: const [stateVar, setter] = useState(...) or useState<T>(...)
686
+ interface StateInsertion {
687
+ renamePos: number; // position in source to insert '__trickle_s_' before setter name
688
+ afterLine: number; // position after end of useState statement to insert declaration
689
+ stateName: string;
690
+ setterName: string;
691
+ lineNo: number;
692
+ }
693
+ const stateInsertions: StateInsertion[] = [];
694
+
695
+ if (isReactFile) {
696
+ const useStateRegex = /const\s+\[([a-zA-Z_$][a-zA-Z0-9_$]*)\s*,\s*([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\]\s*=\s*(?:React\.)?useState\s*(?:<[^(]*>)?\s*\(/gm;
697
+ let sm;
698
+ while ((sm = useStateRegex.exec(source)) !== null) {
699
+ const stateName = sm[1];
700
+ const setterName = sm[2];
701
+
702
+ // Find the position of setterName within the match (after the comma)
703
+ const matchStr = sm[0];
704
+ const commaIdx = matchStr.indexOf(',');
705
+ const setterInMatch = matchStr.indexOf(setterName, commaIdx);
706
+ if (setterInMatch === -1) continue;
707
+ const renamePos = sm.index + setterInMatch;
708
+
709
+ // Skip the useState(...) argument list to find the end of the statement
710
+ let pos = sm.index + sm[0].length;
711
+ let depth = 1;
712
+ while (pos < source.length && depth > 0) {
713
+ const ch = source[pos];
714
+ if (ch === '(') depth++;
715
+ else if (ch === ')') depth--;
716
+ else if (ch === '"' || ch === "'" || ch === '`') {
717
+ const q = ch; pos++;
718
+ while (pos < source.length && source[pos] !== q) {
719
+ if (source[pos] === '\\') pos++;
720
+ pos++;
721
+ }
722
+ }
723
+ pos++;
724
+ }
725
+ // Skip to end of line (past semicolon or newline)
726
+ while (pos < source.length && source[pos] !== ';' && source[pos] !== '\n') pos++;
727
+ const afterLine = pos + 1;
728
+
729
+ let lineNo = 1;
730
+ for (let i = 0; i < sm.index; i++) {
731
+ if (source[i] === '\n') lineNo++;
732
+ }
733
+
734
+ stateInsertions.push({ renamePos, afterLine, stateName, setterName, lineNo });
735
+ }
736
+ }
737
+
684
738
  // Find variable declarations for tracing
685
739
  const varInsertions = traceVars ? findVarDeclarations(source) : [];
686
740
 
687
741
  // Find destructured variable declarations for tracing
688
742
  const destructInsertions = traceVars ? findDestructuredDeclarations(source) : [];
689
743
 
690
- if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && bodyInsertions.length === 0 && hookInsertions.length === 0) return source;
744
+ if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && bodyInsertions.length === 0 && hookInsertions.length === 0 && stateInsertions.length === 0) return source;
691
745
 
692
746
  // Fix line numbers: Vite transforms (TypeScript stripping) may change line numbers.
693
747
  // Map transformed line numbers to original source line numbers.
@@ -712,7 +766,7 @@ function transformEsmSource(
712
766
  const importLines: string[] = [
713
767
  `import { wrapFunction as __trickle_wrapFn, configure as __trickle_configure } from 'trickle-observe';`,
714
768
  ];
715
- if (varInsertions.length > 0 || destructInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0) {
769
+ if (varInsertions.length > 0 || destructInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0 || stateInsertions.length > 0) {
716
770
  importLines.push(
717
771
  `import { mkdirSync as __trickle_mkdirSync, appendFileSync as __trickle_appendFileSync } from 'node:fs';`,
718
772
  `import { join as __trickle_join } from 'node:path';`,
@@ -799,6 +853,7 @@ function transformEsmSource(
799
853
  if (bodyInsertions.length > 0) {
800
854
  prefixLines.push(
801
855
  `if (!globalThis.__trickle_react_renders) { globalThis.__trickle_react_renders = new Map(); }`,
856
+ `if (!globalThis.__trickle_react_prev_props) { globalThis.__trickle_react_prev_props = new Map(); }`,
802
857
  `function __trickle_rc(name, line, props) {`,
803
858
  ` try {`,
804
859
  ` const key = ${JSON.stringify(filename)} + ':' + line;`,
@@ -818,12 +873,27 @@ function transformEsmSource(
818
873
  ` if (t === 'string') propSample[k] = v.length > 40 ? v.slice(0, 40) + '...' : v;`,
819
874
  ` else if (t === 'number' || t === 'boolean') propSample[k] = v;`,
820
875
  ` else if (v === null || v === undefined) propSample[k] = v;`,
821
- ` else if (Array.isArray(v)) propSample[k] = '[' + t + '[' + v.length + ']]';`,
876
+ ` else if (Array.isArray(v)) propSample[k] = '[arr:' + v.length + ']';`,
822
877
  ` else if (t === 'function') propSample[k] = '[fn]';`,
823
878
  ` else propSample[k] = '[object]';`,
824
879
  ` }`,
825
880
  ` rec.props = propSample;`,
826
881
  ` rec.propKeys = propKeys;`,
882
+ ` // Detect which props changed vs previous render`,
883
+ ` const prevProps = globalThis.__trickle_react_prev_props.get(key);`,
884
+ ` if (prevProps && count > 1) {`,
885
+ ` const changedProps = [];`,
886
+ ` const allKeys = new Set([...Object.keys(prevProps), ...Object.keys(propSample)]);`,
887
+ ` for (const k of allKeys) {`,
888
+ ` const prev = prevProps[k];`,
889
+ ` const curr = propSample[k];`,
890
+ ` if (String(prev) !== String(curr)) {`,
891
+ ` changedProps.push({ key: k, from: prev, to: curr });`,
892
+ ` }`,
893
+ ` }`,
894
+ ` if (changedProps.length > 0) rec.changedProps = changedProps;`,
895
+ ` }`,
896
+ ` globalThis.__trickle_react_prev_props.set(key, propSample);`,
827
897
  ` } catch(e2) {}`,
828
898
  ` }`,
829
899
  ` __trickle_appendFileSync(f, JSON.stringify(rec) + '\\n');`,
@@ -853,6 +923,35 @@ function transformEsmSource(
853
923
  );
854
924
  }
855
925
 
926
+ // Add useState setter tracker if needed
927
+ if (stateInsertions.length > 0) {
928
+ prefixLines.push(
929
+ `if (!globalThis.__trickle_state_counts) { globalThis.__trickle_state_counts = new Map(); }`,
930
+ `function __trickle_ss(stateName, line, origSetter) {`,
931
+ ` return function(newVal) {`,
932
+ ` try {`,
933
+ ` const key = ${JSON.stringify(filename)} + ':' + line + ':' + stateName;`,
934
+ ` const n = (globalThis.__trickle_state_counts.get(key) || 0) + 1;`,
935
+ ` globalThis.__trickle_state_counts.set(key, n);`,
936
+ ` const t = typeof newVal;`,
937
+ ` let sample;`,
938
+ ` if (t === 'function') sample = '[fn updater]';`,
939
+ ` else if (t === 'string') sample = newVal.length > 40 ? newVal.slice(0,40)+'...' : newVal;`,
940
+ ` else if (t === 'number' || t === 'boolean') sample = newVal;`,
941
+ ` else if (newVal === null || newVal === undefined) sample = newVal;`,
942
+ ` else if (Array.isArray(newVal)) sample = '[arr:'+newVal.length+']';`,
943
+ ` else sample = '[object]';`,
944
+ ` const dir = process.env.TRICKLE_LOCAL_DIR || __trickle_join(process.cwd(), '.trickle');`,
945
+ ` try { __trickle_mkdirSync(dir, { recursive: true }); } catch(e2) {}`,
946
+ ` __trickle_appendFileSync(__trickle_join(dir, 'variables.jsonl'),`,
947
+ ` JSON.stringify({ kind: 'react_state', file: ${JSON.stringify(filename)}, line, stateName, updateCount: n, value: sample, timestamp: Date.now()/1000 }) + '\\n');`,
948
+ ` } catch(e) {}`,
949
+ ` return origSetter(newVal);`,
950
+ ` };`,
951
+ `}`,
952
+ );
953
+ }
954
+
856
955
  prefixLines.push('');
857
956
  const prefix = prefixLines.join('\n');
858
957
 
@@ -896,6 +995,17 @@ function transformEsmSource(
896
995
  allInsertions.push({ position: wrapEnd, code: `)` });
897
996
  }
898
997
 
998
+ // useState insertions: TWO insertions per useState
999
+ // 1. Prefix setter name with '__trickle_s_' (rename in destructuring)
1000
+ // 2. After statement end, declare tracked wrapper: const setter = __trickle_ss(...)
1001
+ for (const { renamePos, afterLine, stateName, setterName, lineNo } of stateInsertions) {
1002
+ allInsertions.push({ position: renamePos, code: `__trickle_s_` });
1003
+ allInsertions.push({
1004
+ position: afterLine,
1005
+ code: `const ${setterName}=__trickle_ss(${JSON.stringify(stateName)},${lineNo},__trickle_s_${setterName});\n`,
1006
+ });
1007
+ }
1008
+
899
1009
  // Sort by position descending (insert from end to preserve earlier positions)
900
1010
  allInsertions.sort((a, b) => b.position - a.position);
901
1011