trickle-observe 0.2.53 → 0.2.55

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.
@@ -90,6 +90,54 @@ function tricklePlugin(options = {}) {
90
90
  },
91
91
  };
92
92
  }
93
+ /**
94
+ * Find the opening brace of a function body, skipping the parameter list.
95
+ * Starting from the character right after the opening `(` of the parameter list,
96
+ * scans forward matching parens to find the closing `)`, then finds the `{` after it.
97
+ * Returns -1 if not found.
98
+ */
99
+ function findFunctionBodyBrace(source, afterOpenParen) {
100
+ let depth = 1;
101
+ let pos = afterOpenParen;
102
+ // Skip the parameter list (matching parens)
103
+ while (pos < source.length && depth > 0) {
104
+ const ch = source[pos];
105
+ if (ch === '(')
106
+ depth++;
107
+ else if (ch === ')') {
108
+ depth--;
109
+ if (depth === 0)
110
+ break;
111
+ }
112
+ else if (ch === '"' || ch === "'" || ch === '`') {
113
+ const quote = ch;
114
+ pos++;
115
+ while (pos < source.length && source[pos] !== quote) {
116
+ if (source[pos] === '\\')
117
+ pos++;
118
+ pos++;
119
+ }
120
+ }
121
+ pos++;
122
+ }
123
+ // Now find the `{` after the closing `)`
124
+ while (pos < source.length) {
125
+ const ch = source[pos];
126
+ if (ch === '{')
127
+ return pos;
128
+ if (ch !== ' ' && ch !== '\t' && ch !== '\n' && ch !== '\r' && ch !== ':') {
129
+ // Hit something unexpected (like '=>' for arrows, or type annotation chars)
130
+ if (ch === '=' && pos + 1 < source.length && source[pos + 1] === '>') {
131
+ // Arrow — find { after =>
132
+ pos += 2;
133
+ continue;
134
+ }
135
+ // Type annotation — keep going (`: ReturnType`)
136
+ }
137
+ pos++;
138
+ }
139
+ return -1;
140
+ }
93
141
  /**
94
142
  * Find the closing brace position for a function body starting at `openBrace`.
95
143
  */
@@ -446,19 +494,24 @@ function escapeRegexStr(str) {
446
494
  return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
447
495
  }
448
496
  function transformEsmSource(source, filename, moduleName, backendUrl, debug, traceVars, originalSource) {
497
+ // Detect React files for component render tracking
498
+ const isReactFile = /\.(tsx|jsx)$/.test(filename);
449
499
  // Match top-level and nested function declarations (including async, export)
450
500
  const funcRegex = /^[ \t]*(?:export\s+)?(?:async\s+)?function\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/gm;
451
501
  const funcInsertions = [];
502
+ // Body insertions: insert at start of function body (for React render tracking)
503
+ const bodyInsertions = [];
452
504
  let match;
453
505
  while ((match = funcRegex.exec(source)) !== null) {
454
506
  const name = match[1];
455
507
  if (name === 'require' || name === 'exports' || name === 'module')
456
508
  continue;
457
509
  const afterMatch = match.index + match[0].length;
458
- const openBrace = source.indexOf('{', afterMatch);
510
+ // Use findFunctionBodyBrace to correctly skip destructured params like ({ a, b }) =>
511
+ const openBrace = findFunctionBodyBrace(source, afterMatch);
459
512
  if (openBrace === -1)
460
513
  continue;
461
- // Extract parameter names
514
+ // Extract parameter names (between the opening ( and the body {)
462
515
  const paramStr = source.slice(afterMatch, openBrace).replace(/[()]/g, '').trim();
463
516
  const paramNames = paramStr
464
517
  ? paramStr.split(',').map(p => {
@@ -472,6 +525,15 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
472
525
  if (closeBrace === -1)
473
526
  continue;
474
527
  funcInsertions.push({ position: closeBrace + 1, name, paramNames });
528
+ // React component render tracking: uppercase function name in .tsx/.jsx
529
+ if (isReactFile && /^[A-Z]/.test(name)) {
530
+ let lineNo = 1;
531
+ for (let i = 0; i < match.index; i++) {
532
+ if (source[i] === '\n')
533
+ lineNo++;
534
+ }
535
+ bodyInsertions.push({ position: openBrace + 1, name, lineNo });
536
+ }
475
537
  }
476
538
  // Also match arrow functions assigned to const/let/var
477
539
  const arrowRegex = /^[ \t]*(?:export\s+)?(?:const|let|var)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=\s*(?:async\s+)?(?:\([^)]*\)|[a-zA-Z_$][a-zA-Z0-9_$]*)\s*(?::\s*[^=]+?)?\s*=>\s*\{/gm;
@@ -498,12 +560,21 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
498
560
  if (closeBrace === -1)
499
561
  continue;
500
562
  funcInsertions.push({ position: closeBrace + 1, name, paramNames });
563
+ // React component render tracking: uppercase arrow function in .tsx/.jsx
564
+ if (isReactFile && /^[A-Z]/.test(name)) {
565
+ let lineNo = 1;
566
+ for (let i = 0; i < match.index; i++) {
567
+ if (source[i] === '\n')
568
+ lineNo++;
569
+ }
570
+ bodyInsertions.push({ position: openBrace + 1, name, lineNo });
571
+ }
501
572
  }
502
573
  // Find variable declarations for tracing
503
574
  const varInsertions = traceVars ? findVarDeclarations(source) : [];
504
575
  // Find destructured variable declarations for tracing
505
576
  const destructInsertions = traceVars ? findDestructuredDeclarations(source) : [];
506
- if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0)
577
+ if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && bodyInsertions.length === 0)
507
578
  return source;
508
579
  // Fix line numbers: Vite transforms (TypeScript stripping) may change line numbers.
509
580
  // Map transformed line numbers to original source line numbers.
@@ -528,7 +599,7 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
528
599
  const importLines = [
529
600
  `import { wrapFunction as __trickle_wrapFn, configure as __trickle_configure } from 'trickle-observe';`,
530
601
  ];
531
- if (varInsertions.length > 0 || destructInsertions.length > 0) {
602
+ if (varInsertions.length > 0 || destructInsertions.length > 0 || bodyInsertions.length > 0) {
532
603
  importLines.push(`import { mkdirSync as __trickle_mkdirSync, appendFileSync as __trickle_appendFileSync } from 'node:fs';`, `import { join as __trickle_join } from 'node:path';`);
533
604
  }
534
605
  const prefixLines = [
@@ -555,6 +626,10 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
555
626
  if (varInsertions.length > 0 || destructInsertions.length > 0) {
556
627
  prefixLines.push(`if (!globalThis.__trickle_var_tracer) {`, ` const _cache = new Set();`, ` let _varsFile = null;`, ` function _inferType(v, d) {`, ` if (d <= 0) return { kind: 'primitive', name: 'unknown' };`, ` if (v === null) return { kind: 'primitive', name: 'null' };`, ` if (v === undefined) return { kind: 'primitive', name: 'undefined' };`, ` const t = typeof v;`, ` if (t === 'string' || t === 'number' || t === 'boolean' || t === 'bigint' || t === 'symbol') return { kind: 'primitive', name: t };`, ` if (t === 'function') return { kind: 'function' };`, ` if (Array.isArray(v)) { return v.length === 0 ? { kind: 'array', element: { kind: 'primitive', name: 'unknown' } } : { kind: 'array', element: _inferType(v[0], d-1) }; }`, ` if (t === 'object') {`, ` if (v instanceof Date) return { kind: 'object', properties: { __date: { kind: 'primitive', name: 'string' } } };`, ` if (v instanceof RegExp) return { kind: 'object', properties: { __regexp: { kind: 'primitive', name: 'string' } } };`, ` if (v instanceof Error) return { kind: 'object', properties: { __error: { kind: 'primitive', name: 'string' } } };`, ` if (v instanceof Promise) return { kind: 'promise', resolved: { kind: 'primitive', name: 'unknown' } };`, ` const props = {}; const keys = Object.keys(v).slice(0, 20);`, ` for (const k of keys) { try { props[k] = _inferType(v[k], d-1); } catch(e) { props[k] = { kind: 'primitive', name: 'unknown' }; } }`, ` return { kind: 'object', properties: props };`, ` }`, ` return { kind: 'primitive', name: 'unknown' };`, ` }`, ` function _sanitize(v, d) {`, ` if (d <= 0) return '[truncated]'; if (v === null || v === undefined) return v; const t = typeof v;`, ` if (t === 'string') return v.length > 100 ? v.substring(0, 100) + '...' : v;`, ` if (t === 'number' || t === 'boolean') return v; if (t === 'bigint') return String(v);`, ` if (t === 'function') return '[Function: ' + (v.name || 'anonymous') + ']';`, ` if (Array.isArray(v)) return v.slice(0, 3).map(i => _sanitize(i, d-1));`, ` if (t === 'object') { if (v instanceof Date) return v.toISOString(); if (v instanceof RegExp) return String(v); if (v instanceof Error) return { error: v.message }; if (v instanceof Promise) return '[Promise]';`, ` const r = {}; const keys = Object.keys(v).slice(0, 10); for (const k of keys) { try { r[k] = _sanitize(v[k], d-1); } catch(e) { r[k] = '[unreadable]'; } } return r; }`, ` return String(v);`, ` }`, ` globalThis.__trickle_var_tracer = function(v, n, l, mod, file) {`, ` try {`, ` if (!_varsFile) {`, ` const dir = process.env.TRICKLE_LOCAL_DIR || __trickle_join(process.cwd(), '.trickle');`, ` try { __trickle_mkdirSync(dir, { recursive: true }); } catch(e) {}`, ` _varsFile = __trickle_join(dir, 'variables.jsonl');`, ` }`, ` const type = _inferType(v, 3);`, ` const th = JSON.stringify(type).substring(0, 32);`, ` const ck = file + ':' + l + ':' + n + ':' + th;`, ` if (_cache.has(ck)) return;`, ` _cache.add(ck);`, ` __trickle_appendFileSync(_varsFile, JSON.stringify({ kind: 'variable', varName: n, line: l, module: mod, file: file, type: type, typeHash: th, sample: _sanitize(v, 2) }) + '\\n');`, ` } catch(e) {}`, ` };`, `}`, `function __trickle_tv(v, n, l) { try { globalThis.__trickle_var_tracer(v, n, l, ${JSON.stringify(moduleName)}, ${JSON.stringify(filename)}); } catch(e) {} }`);
557
628
  }
629
+ // Add React component render tracker if needed
630
+ if (bodyInsertions.length > 0) {
631
+ prefixLines.push(`if (!globalThis.__trickle_react_renders) { globalThis.__trickle_react_renders = new Map(); }`, `function __trickle_rc(name, line) {`, ` try {`, ` const key = ${JSON.stringify(filename)} + ':' + line;`, ` const count = (globalThis.__trickle_react_renders.get(key) || 0) + 1;`, ` globalThis.__trickle_react_renders.set(key, count);`, ` const dir = process.env.TRICKLE_LOCAL_DIR || __trickle_join(process.cwd(), '.trickle');`, ` try { __trickle_mkdirSync(dir, { recursive: true }); } catch(e) {}`, ` const f = __trickle_join(dir, 'variables.jsonl');`, ` __trickle_appendFileSync(f, JSON.stringify({ kind: 'react_render', file: ${JSON.stringify(filename)}, line: line, component: name, renderCount: count, timestamp: Date.now() / 1000 }) + '\\n');`, ` } catch(e) {}`, `}`);
632
+ }
558
633
  prefixLines.push('');
559
634
  const prefix = prefixLines.join('\n');
560
635
  const allInsertions = [];
@@ -578,6 +653,12 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
578
653
  code: `\n;try{${calls}}catch(__e){}\n`,
579
654
  });
580
655
  }
656
+ for (const { position, name, lineNo } of bodyInsertions) {
657
+ allInsertions.push({
658
+ position,
659
+ code: `\ntry{__trickle_rc(${JSON.stringify(name)},${lineNo})}catch(__e){}\n`,
660
+ });
661
+ }
581
662
  // Sort by position descending (insert from end to preserve earlier positions)
582
663
  allInsertions.sort((a, b) => b.position - a.position);
583
664
  let result = source;
@@ -84,6 +84,54 @@ export function tricklePlugin(options = {}) {
84
84
  },
85
85
  };
86
86
  }
87
+ /**
88
+ * Find the opening brace of a function body, skipping the parameter list.
89
+ * Starting from the character right after the opening `(` of the parameter list,
90
+ * scans forward matching parens to find the closing `)`, then finds the `{` after it.
91
+ * Returns -1 if not found.
92
+ */
93
+ function findFunctionBodyBrace(source, afterOpenParen) {
94
+ let depth = 1;
95
+ let pos = afterOpenParen;
96
+ // Skip the parameter list (matching parens)
97
+ while (pos < source.length && depth > 0) {
98
+ const ch = source[pos];
99
+ if (ch === '(')
100
+ depth++;
101
+ else if (ch === ')') {
102
+ depth--;
103
+ if (depth === 0)
104
+ break;
105
+ }
106
+ else if (ch === '"' || ch === "'" || ch === '`') {
107
+ const quote = ch;
108
+ pos++;
109
+ while (pos < source.length && source[pos] !== quote) {
110
+ if (source[pos] === '\\')
111
+ pos++;
112
+ pos++;
113
+ }
114
+ }
115
+ pos++;
116
+ }
117
+ // Now find the `{` after the closing `)`
118
+ while (pos < source.length) {
119
+ const ch = source[pos];
120
+ if (ch === '{')
121
+ return pos;
122
+ if (ch !== ' ' && ch !== '\t' && ch !== '\n' && ch !== '\r' && ch !== ':') {
123
+ // Hit something unexpected (like '=>' for arrows, or type annotation chars)
124
+ if (ch === '=' && pos + 1 < source.length && source[pos + 1] === '>') {
125
+ // Arrow — find { after =>
126
+ pos += 2;
127
+ continue;
128
+ }
129
+ // Type annotation — keep going (`: ReturnType`)
130
+ }
131
+ pos++;
132
+ }
133
+ return -1;
134
+ }
87
135
  /**
88
136
  * Find the closing brace position for a function body starting at `openBrace`.
89
137
  */
@@ -440,19 +488,24 @@ function escapeRegexStr(str) {
440
488
  return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
441
489
  }
442
490
  function transformEsmSource(source, filename, moduleName, backendUrl, debug, traceVars, originalSource) {
491
+ // Detect React files for component render tracking
492
+ const isReactFile = /\.(tsx|jsx)$/.test(filename);
443
493
  // Match top-level and nested function declarations (including async, export)
444
494
  const funcRegex = /^[ \t]*(?:export\s+)?(?:async\s+)?function\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/gm;
445
495
  const funcInsertions = [];
496
+ // Body insertions: insert at start of function body (for React render tracking)
497
+ const bodyInsertions = [];
446
498
  let match;
447
499
  while ((match = funcRegex.exec(source)) !== null) {
448
500
  const name = match[1];
449
501
  if (name === 'require' || name === 'exports' || name === 'module')
450
502
  continue;
451
503
  const afterMatch = match.index + match[0].length;
452
- const openBrace = source.indexOf('{', afterMatch);
504
+ // Use findFunctionBodyBrace to correctly skip destructured params like ({ a, b }) =>
505
+ const openBrace = findFunctionBodyBrace(source, afterMatch);
453
506
  if (openBrace === -1)
454
507
  continue;
455
- // Extract parameter names
508
+ // Extract parameter names (between the opening ( and the body {)
456
509
  const paramStr = source.slice(afterMatch, openBrace).replace(/[()]/g, '').trim();
457
510
  const paramNames = paramStr
458
511
  ? paramStr.split(',').map(p => {
@@ -466,6 +519,15 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
466
519
  if (closeBrace === -1)
467
520
  continue;
468
521
  funcInsertions.push({ position: closeBrace + 1, name, paramNames });
522
+ // React component render tracking: uppercase function name in .tsx/.jsx
523
+ if (isReactFile && /^[A-Z]/.test(name)) {
524
+ let lineNo = 1;
525
+ for (let i = 0; i < match.index; i++) {
526
+ if (source[i] === '\n')
527
+ lineNo++;
528
+ }
529
+ bodyInsertions.push({ position: openBrace + 1, name, lineNo });
530
+ }
469
531
  }
470
532
  // Also match arrow functions assigned to const/let/var
471
533
  const arrowRegex = /^[ \t]*(?:export\s+)?(?:const|let|var)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=\s*(?:async\s+)?(?:\([^)]*\)|[a-zA-Z_$][a-zA-Z0-9_$]*)\s*(?::\s*[^=]+?)?\s*=>\s*\{/gm;
@@ -492,12 +554,21 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
492
554
  if (closeBrace === -1)
493
555
  continue;
494
556
  funcInsertions.push({ position: closeBrace + 1, name, paramNames });
557
+ // React component render tracking: uppercase arrow function in .tsx/.jsx
558
+ if (isReactFile && /^[A-Z]/.test(name)) {
559
+ let lineNo = 1;
560
+ for (let i = 0; i < match.index; i++) {
561
+ if (source[i] === '\n')
562
+ lineNo++;
563
+ }
564
+ bodyInsertions.push({ position: openBrace + 1, name, lineNo });
565
+ }
495
566
  }
496
567
  // Find variable declarations for tracing
497
568
  const varInsertions = traceVars ? findVarDeclarations(source) : [];
498
569
  // Find destructured variable declarations for tracing
499
570
  const destructInsertions = traceVars ? findDestructuredDeclarations(source) : [];
500
- if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0)
571
+ if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && bodyInsertions.length === 0)
501
572
  return source;
502
573
  // Fix line numbers: Vite transforms (TypeScript stripping) may change line numbers.
503
574
  // Map transformed line numbers to original source line numbers.
@@ -522,7 +593,7 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
522
593
  const importLines = [
523
594
  `import { wrapFunction as __trickle_wrapFn, configure as __trickle_configure } from 'trickle-observe';`,
524
595
  ];
525
- if (varInsertions.length > 0 || destructInsertions.length > 0) {
596
+ if (varInsertions.length > 0 || destructInsertions.length > 0 || bodyInsertions.length > 0) {
526
597
  importLines.push(`import { mkdirSync as __trickle_mkdirSync, appendFileSync as __trickle_appendFileSync } from 'node:fs';`, `import { join as __trickle_join } from 'node:path';`);
527
598
  }
528
599
  const prefixLines = [
@@ -549,6 +620,10 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
549
620
  if (varInsertions.length > 0 || destructInsertions.length > 0) {
550
621
  prefixLines.push(`if (!globalThis.__trickle_var_tracer) {`, ` const _cache = new Set();`, ` let _varsFile = null;`, ` function _inferType(v, d) {`, ` if (d <= 0) return { kind: 'primitive', name: 'unknown' };`, ` if (v === null) return { kind: 'primitive', name: 'null' };`, ` if (v === undefined) return { kind: 'primitive', name: 'undefined' };`, ` const t = typeof v;`, ` if (t === 'string' || t === 'number' || t === 'boolean' || t === 'bigint' || t === 'symbol') return { kind: 'primitive', name: t };`, ` if (t === 'function') return { kind: 'function' };`, ` if (Array.isArray(v)) { return v.length === 0 ? { kind: 'array', element: { kind: 'primitive', name: 'unknown' } } : { kind: 'array', element: _inferType(v[0], d-1) }; }`, ` if (t === 'object') {`, ` if (v instanceof Date) return { kind: 'object', properties: { __date: { kind: 'primitive', name: 'string' } } };`, ` if (v instanceof RegExp) return { kind: 'object', properties: { __regexp: { kind: 'primitive', name: 'string' } } };`, ` if (v instanceof Error) return { kind: 'object', properties: { __error: { kind: 'primitive', name: 'string' } } };`, ` if (v instanceof Promise) return { kind: 'promise', resolved: { kind: 'primitive', name: 'unknown' } };`, ` const props = {}; const keys = Object.keys(v).slice(0, 20);`, ` for (const k of keys) { try { props[k] = _inferType(v[k], d-1); } catch(e) { props[k] = { kind: 'primitive', name: 'unknown' }; } }`, ` return { kind: 'object', properties: props };`, ` }`, ` return { kind: 'primitive', name: 'unknown' };`, ` }`, ` function _sanitize(v, d) {`, ` if (d <= 0) return '[truncated]'; if (v === null || v === undefined) return v; const t = typeof v;`, ` if (t === 'string') return v.length > 100 ? v.substring(0, 100) + '...' : v;`, ` if (t === 'number' || t === 'boolean') return v; if (t === 'bigint') return String(v);`, ` if (t === 'function') return '[Function: ' + (v.name || 'anonymous') + ']';`, ` if (Array.isArray(v)) return v.slice(0, 3).map(i => _sanitize(i, d-1));`, ` if (t === 'object') { if (v instanceof Date) return v.toISOString(); if (v instanceof RegExp) return String(v); if (v instanceof Error) return { error: v.message }; if (v instanceof Promise) return '[Promise]';`, ` const r = {}; const keys = Object.keys(v).slice(0, 10); for (const k of keys) { try { r[k] = _sanitize(v[k], d-1); } catch(e) { r[k] = '[unreadable]'; } } return r; }`, ` return String(v);`, ` }`, ` globalThis.__trickle_var_tracer = function(v, n, l, mod, file) {`, ` try {`, ` if (!_varsFile) {`, ` const dir = process.env.TRICKLE_LOCAL_DIR || __trickle_join(process.cwd(), '.trickle');`, ` try { __trickle_mkdirSync(dir, { recursive: true }); } catch(e) {}`, ` _varsFile = __trickle_join(dir, 'variables.jsonl');`, ` }`, ` const type = _inferType(v, 3);`, ` const th = JSON.stringify(type).substring(0, 32);`, ` const ck = file + ':' + l + ':' + n + ':' + th;`, ` if (_cache.has(ck)) return;`, ` _cache.add(ck);`, ` __trickle_appendFileSync(_varsFile, JSON.stringify({ kind: 'variable', varName: n, line: l, module: mod, file: file, type: type, typeHash: th, sample: _sanitize(v, 2) }) + '\\n');`, ` } catch(e) {}`, ` };`, `}`, `function __trickle_tv(v, n, l) { try { globalThis.__trickle_var_tracer(v, n, l, ${JSON.stringify(moduleName)}, ${JSON.stringify(filename)}); } catch(e) {} }`);
551
622
  }
623
+ // Add React component render tracker if needed
624
+ if (bodyInsertions.length > 0) {
625
+ prefixLines.push(`if (!globalThis.__trickle_react_renders) { globalThis.__trickle_react_renders = new Map(); }`, `function __trickle_rc(name, line) {`, ` try {`, ` const key = ${JSON.stringify(filename)} + ':' + line;`, ` const count = (globalThis.__trickle_react_renders.get(key) || 0) + 1;`, ` globalThis.__trickle_react_renders.set(key, count);`, ` const dir = process.env.TRICKLE_LOCAL_DIR || __trickle_join(process.cwd(), '.trickle');`, ` try { __trickle_mkdirSync(dir, { recursive: true }); } catch(e) {}`, ` const f = __trickle_join(dir, 'variables.jsonl');`, ` __trickle_appendFileSync(f, JSON.stringify({ kind: 'react_render', file: ${JSON.stringify(filename)}, line: line, component: name, renderCount: count, timestamp: Date.now() / 1000 }) + '\\n');`, ` } catch(e) {}`, `}`);
626
+ }
552
627
  prefixLines.push('');
553
628
  const prefix = prefixLines.join('\n');
554
629
  const allInsertions = [];
@@ -572,6 +647,12 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
572
647
  code: `\n;try{${calls}}catch(__e){}\n`,
573
648
  });
574
649
  }
650
+ for (const { position, name, lineNo } of bodyInsertions) {
651
+ allInsertions.push({
652
+ position,
653
+ code: `\ntry{__trickle_rc(${JSON.stringify(name)},${lineNo})}catch(__e){}\n`,
654
+ });
655
+ }
575
656
  // Sort by position descending (insert from end to preserve earlier positions)
576
657
  allInsertions.sort((a, b) => b.position - a.position);
577
658
  let result = source;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trickle-observe",
3
- "version": "0.2.53",
3
+ "version": "0.2.55",
4
4
  "description": "Runtime type observability for JavaScript applications",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -104,6 +104,48 @@ export function tricklePlugin(options: TricklePluginOptions = {}) {
104
104
  };
105
105
  }
106
106
 
107
+ /**
108
+ * Find the opening brace of a function body, skipping the parameter list.
109
+ * Starting from the character right after the opening `(` of the parameter list,
110
+ * scans forward matching parens to find the closing `)`, then finds the `{` after it.
111
+ * Returns -1 if not found.
112
+ */
113
+ function findFunctionBodyBrace(source: string, afterOpenParen: number): number {
114
+ let depth = 1;
115
+ let pos = afterOpenParen;
116
+ // Skip the parameter list (matching parens)
117
+ while (pos < source.length && depth > 0) {
118
+ const ch = source[pos];
119
+ if (ch === '(') depth++;
120
+ else if (ch === ')') { depth--; if (depth === 0) break; }
121
+ else if (ch === '"' || ch === "'" || ch === '`') {
122
+ const quote = ch;
123
+ pos++;
124
+ while (pos < source.length && source[pos] !== quote) {
125
+ if (source[pos] === '\\') pos++;
126
+ pos++;
127
+ }
128
+ }
129
+ pos++;
130
+ }
131
+ // Now find the `{` after the closing `)`
132
+ while (pos < source.length) {
133
+ const ch = source[pos];
134
+ if (ch === '{') return pos;
135
+ if (ch !== ' ' && ch !== '\t' && ch !== '\n' && ch !== '\r' && ch !== ':') {
136
+ // Hit something unexpected (like '=>' for arrows, or type annotation chars)
137
+ if (ch === '=' && pos + 1 < source.length && source[pos + 1] === '>') {
138
+ // Arrow — find { after =>
139
+ pos += 2;
140
+ continue;
141
+ }
142
+ // Type annotation — keep going (`: ReturnType`)
143
+ }
144
+ pos++;
145
+ }
146
+ return -1;
147
+ }
148
+
107
149
  /**
108
150
  * Find the closing brace position for a function body starting at `openBrace`.
109
151
  */
@@ -448,9 +490,14 @@ function transformEsmSource(
448
490
  traceVars: boolean,
449
491
  originalSource?: string | null,
450
492
  ): string {
493
+ // Detect React files for component render tracking
494
+ const isReactFile = /\.(tsx|jsx)$/.test(filename);
495
+
451
496
  // Match top-level and nested function declarations (including async, export)
452
497
  const funcRegex = /^[ \t]*(?:export\s+)?(?:async\s+)?function\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/gm;
453
498
  const funcInsertions: Array<{ position: number; name: string; paramNames: string[] }> = [];
499
+ // Body insertions: insert at start of function body (for React render tracking)
500
+ const bodyInsertions: Array<{ position: number; name: string; lineNo: number }> = [];
454
501
  let match;
455
502
 
456
503
  while ((match = funcRegex.exec(source)) !== null) {
@@ -458,10 +505,11 @@ function transformEsmSource(
458
505
  if (name === 'require' || name === 'exports' || name === 'module') continue;
459
506
 
460
507
  const afterMatch = match.index + match[0].length;
461
- const openBrace = source.indexOf('{', afterMatch);
508
+ // Use findFunctionBodyBrace to correctly skip destructured params like ({ a, b }) =>
509
+ const openBrace = findFunctionBodyBrace(source, afterMatch);
462
510
  if (openBrace === -1) continue;
463
511
 
464
- // Extract parameter names
512
+ // Extract parameter names (between the opening ( and the body {)
465
513
  const paramStr = source.slice(afterMatch, openBrace).replace(/[()]/g, '').trim();
466
514
  const paramNames = paramStr
467
515
  ? paramStr.split(',').map(p => {
@@ -475,6 +523,15 @@ function transformEsmSource(
475
523
  if (closeBrace === -1) continue;
476
524
 
477
525
  funcInsertions.push({ position: closeBrace + 1, name, paramNames });
526
+
527
+ // React component render tracking: uppercase function name in .tsx/.jsx
528
+ if (isReactFile && /^[A-Z]/.test(name)) {
529
+ let lineNo = 1;
530
+ for (let i = 0; i < match.index; i++) {
531
+ if (source[i] === '\n') lineNo++;
532
+ }
533
+ bodyInsertions.push({ position: openBrace + 1, name, lineNo });
534
+ }
478
535
  }
479
536
 
480
537
  // Also match arrow functions assigned to const/let/var
@@ -503,15 +560,26 @@ function transformEsmSource(
503
560
  if (closeBrace === -1) continue;
504
561
 
505
562
  funcInsertions.push({ position: closeBrace + 1, name, paramNames });
563
+
564
+ // React component render tracking: uppercase arrow function in .tsx/.jsx
565
+ if (isReactFile && /^[A-Z]/.test(name)) {
566
+ let lineNo = 1;
567
+ for (let i = 0; i < match.index; i++) {
568
+ if (source[i] === '\n') lineNo++;
569
+ }
570
+ bodyInsertions.push({ position: openBrace + 1, name, lineNo });
571
+ }
506
572
  }
507
573
 
574
+
575
+
508
576
  // Find variable declarations for tracing
509
577
  const varInsertions = traceVars ? findVarDeclarations(source) : [];
510
578
 
511
579
  // Find destructured variable declarations for tracing
512
580
  const destructInsertions = traceVars ? findDestructuredDeclarations(source) : [];
513
581
 
514
- if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0) return source;
582
+ if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && bodyInsertions.length === 0) return source;
515
583
 
516
584
  // Fix line numbers: Vite transforms (TypeScript stripping) may change line numbers.
517
585
  // Map transformed line numbers to original source line numbers.
@@ -536,7 +604,7 @@ function transformEsmSource(
536
604
  const importLines: string[] = [
537
605
  `import { wrapFunction as __trickle_wrapFn, configure as __trickle_configure } from 'trickle-observe';`,
538
606
  ];
539
- if (varInsertions.length > 0 || destructInsertions.length > 0) {
607
+ if (varInsertions.length > 0 || destructInsertions.length > 0 || bodyInsertions.length > 0) {
540
608
  importLines.push(
541
609
  `import { mkdirSync as __trickle_mkdirSync, appendFileSync as __trickle_appendFileSync } from 'node:fs';`,
542
610
  `import { join as __trickle_join } from 'node:path';`,
@@ -619,6 +687,24 @@ function transformEsmSource(
619
687
  );
620
688
  }
621
689
 
690
+ // Add React component render tracker if needed
691
+ if (bodyInsertions.length > 0) {
692
+ prefixLines.push(
693
+ `if (!globalThis.__trickle_react_renders) { globalThis.__trickle_react_renders = new Map(); }`,
694
+ `function __trickle_rc(name, line) {`,
695
+ ` try {`,
696
+ ` const key = ${JSON.stringify(filename)} + ':' + line;`,
697
+ ` const count = (globalThis.__trickle_react_renders.get(key) || 0) + 1;`,
698
+ ` globalThis.__trickle_react_renders.set(key, count);`,
699
+ ` const dir = process.env.TRICKLE_LOCAL_DIR || __trickle_join(process.cwd(), '.trickle');`,
700
+ ` try { __trickle_mkdirSync(dir, { recursive: true }); } catch(e) {}`,
701
+ ` const f = __trickle_join(dir, 'variables.jsonl');`,
702
+ ` __trickle_appendFileSync(f, JSON.stringify({ kind: 'react_render', file: ${JSON.stringify(filename)}, line: line, component: name, renderCount: count, timestamp: Date.now() / 1000 }) + '\\n');`,
703
+ ` } catch(e) {}`,
704
+ `}`,
705
+ );
706
+ }
707
+
622
708
  prefixLines.push('');
623
709
  const prefix = prefixLines.join('\n');
624
710
 
@@ -649,6 +735,13 @@ function transformEsmSource(
649
735
  });
650
736
  }
651
737
 
738
+ for (const { position, name, lineNo } of bodyInsertions) {
739
+ allInsertions.push({
740
+ position,
741
+ code: `\ntry{__trickle_rc(${JSON.stringify(name)},${lineNo})}catch(__e){}\n`,
742
+ });
743
+ }
744
+
652
745
  // Sort by position descending (insert from end to preserve earlier positions)
653
746
  allInsertions.sort((a, b) => b.position - a.position);
654
747