trickle-observe 0.2.54 → 0.2.56

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,25 @@ 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
+ // propsExpr: JS expression to evaluate as the props object at render time
504
+ const bodyInsertions = [];
452
505
  let match;
453
506
  while ((match = funcRegex.exec(source)) !== null) {
454
507
  const name = match[1];
455
508
  if (name === 'require' || name === 'exports' || name === 'module')
456
509
  continue;
457
510
  const afterMatch = match.index + match[0].length;
458
- const openBrace = source.indexOf('{', afterMatch);
511
+ // Use findFunctionBodyBrace to correctly skip destructured params like ({ a, b }) =>
512
+ const openBrace = findFunctionBodyBrace(source, afterMatch);
459
513
  if (openBrace === -1)
460
514
  continue;
461
- // Extract parameter names
515
+ // Extract parameter names (between the opening ( and the body {)
462
516
  const paramStr = source.slice(afterMatch, openBrace).replace(/[()]/g, '').trim();
463
517
  const paramNames = paramStr
464
518
  ? paramStr.split(',').map(p => {
@@ -472,6 +526,16 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
472
526
  if (closeBrace === -1)
473
527
  continue;
474
528
  funcInsertions.push({ position: closeBrace + 1, name, paramNames });
529
+ // React component render tracking: uppercase function name in .tsx/.jsx
530
+ // function declarations have `arguments`, so arguments[0] is the raw props object
531
+ if (isReactFile && /^[A-Z]/.test(name)) {
532
+ let lineNo = 1;
533
+ for (let i = 0; i < match.index; i++) {
534
+ if (source[i] === '\n')
535
+ lineNo++;
536
+ }
537
+ bodyInsertions.push({ position: openBrace + 1, name, lineNo, propsExpr: 'arguments[0]' });
538
+ }
475
539
  }
476
540
  // Also match arrow functions assigned to const/let/var
477
541
  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 +562,61 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
498
562
  if (closeBrace === -1)
499
563
  continue;
500
564
  funcInsertions.push({ position: closeBrace + 1, name, paramNames });
565
+ // React component render tracking: uppercase arrow function in .tsx/.jsx
566
+ if (isReactFile && /^[A-Z]/.test(name)) {
567
+ let lineNo = 1;
568
+ for (let i = 0; i < match.index; i++) {
569
+ if (source[i] === '\n')
570
+ lineNo++;
571
+ }
572
+ // Determine props expression for arrow functions (no `arguments` object)
573
+ let propsExpr = 'undefined';
574
+ if (arrowParamMatch) {
575
+ const rawParams = (arrowParamMatch[1] || '').trim();
576
+ if (!rawParams) {
577
+ // No params: `() => {}`
578
+ propsExpr = 'undefined';
579
+ }
580
+ else if (rawParams.startsWith('{')) {
581
+ // Destructured: ({ name, age }) => {} — reconstruct object from field names
582
+ // Strip TS type annotation (e.g. `{a, b}: Props` → `{a, b}`)
583
+ // Find the matching `}` to isolate just the destructuring pattern
584
+ let depth2 = 0;
585
+ let endBrace = -1;
586
+ for (let i = 0; i < rawParams.length; i++) {
587
+ if (rawParams[i] === '{')
588
+ depth2++;
589
+ else if (rawParams[i] === '}') {
590
+ depth2--;
591
+ if (depth2 === 0) {
592
+ endBrace = i;
593
+ break;
594
+ }
595
+ }
596
+ }
597
+ const destructPattern = endBrace !== -1 ? rawParams.slice(0, endBrace + 1) : rawParams;
598
+ const fields = extractDestructuredNames(destructPattern);
599
+ if (fields.length > 0) {
600
+ propsExpr = `{ ${fields.join(', ')} }`;
601
+ }
602
+ }
603
+ else if (arrowParamMatch[2]) {
604
+ // Single simple param (no parens): `props => {}`
605
+ propsExpr = arrowParamMatch[2];
606
+ }
607
+ else if (paramNames.length === 1) {
608
+ // Single simple param: `(props) => {}`
609
+ propsExpr = paramNames[0];
610
+ }
611
+ }
612
+ bodyInsertions.push({ position: openBrace + 1, name, lineNo, propsExpr });
613
+ }
501
614
  }
502
615
  // Find variable declarations for tracing
503
616
  const varInsertions = traceVars ? findVarDeclarations(source) : [];
504
617
  // Find destructured variable declarations for tracing
505
618
  const destructInsertions = traceVars ? findDestructuredDeclarations(source) : [];
506
- if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0)
619
+ if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && bodyInsertions.length === 0)
507
620
  return source;
508
621
  // Fix line numbers: Vite transforms (TypeScript stripping) may change line numbers.
509
622
  // Map transformed line numbers to original source line numbers.
@@ -528,7 +641,7 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
528
641
  const importLines = [
529
642
  `import { wrapFunction as __trickle_wrapFn, configure as __trickle_configure } from 'trickle-observe';`,
530
643
  ];
531
- if (varInsertions.length > 0 || destructInsertions.length > 0) {
644
+ if (varInsertions.length > 0 || destructInsertions.length > 0 || bodyInsertions.length > 0) {
532
645
  importLines.push(`import { mkdirSync as __trickle_mkdirSync, appendFileSync as __trickle_appendFileSync } from 'node:fs';`, `import { join as __trickle_join } from 'node:path';`);
533
646
  }
534
647
  const prefixLines = [
@@ -555,6 +668,10 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
555
668
  if (varInsertions.length > 0 || destructInsertions.length > 0) {
556
669
  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
670
  }
671
+ // Add React component render tracker if needed
672
+ 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) {}`, `}`);
674
+ }
558
675
  prefixLines.push('');
559
676
  const prefix = prefixLines.join('\n');
560
677
  const allInsertions = [];
@@ -578,6 +695,12 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
578
695
  code: `\n;try{${calls}}catch(__e){}\n`,
579
696
  });
580
697
  }
698
+ for (const { position, name, lineNo, propsExpr } of bodyInsertions) {
699
+ allInsertions.push({
700
+ position,
701
+ code: `\ntry{__trickle_rc(${JSON.stringify(name)},${lineNo},${propsExpr})}catch(__e){}\n`,
702
+ });
703
+ }
581
704
  // Sort by position descending (insert from end to preserve earlier positions)
582
705
  allInsertions.sort((a, b) => b.position - a.position);
583
706
  let result = source;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,160 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ /**
7
+ * Unit tests for the Vite plugin transform (React component tracking).
8
+ *
9
+ * Run with: node --experimental-strip-types --test src/vite-plugin.test.ts
10
+ * Or after build: node --test dist/vite-plugin.test.js
11
+ */
12
+ const node_test_1 = require("node:test");
13
+ const strict_1 = __importDefault(require("node:assert/strict"));
14
+ const vite_plugin_js_1 = require("../dist/vite-plugin.js");
15
+ // Helper: transform code as if it came from a .tsx file
16
+ function transformTsx(code) {
17
+ const plugin = (0, vite_plugin_js_1.tricklePlugin)({ debug: false, traceVars: false });
18
+ const result = plugin.transform(code, '/test/App.tsx');
19
+ return result ? result.code : null;
20
+ }
21
+ function transformTs(code) {
22
+ const plugin = (0, vite_plugin_js_1.tricklePlugin)({ debug: false, traceVars: false });
23
+ const result = plugin.transform(code, '/test/util.ts');
24
+ return result ? result.code : null;
25
+ }
26
+ // ── React file detection ─────────────────────────────────────────────────────
27
+ (0, node_test_1.describe)('React file detection', () => {
28
+ (0, node_test_1.it)('tracks uppercase components in .tsx files', () => {
29
+ const code = `function UserCard(props) { return null; }`;
30
+ const out = transformTsx(code);
31
+ strict_1.default.ok(out, 'should transform');
32
+ strict_1.default.ok(out.includes('__trickle_rc'), 'should inject render tracker');
33
+ });
34
+ (0, node_test_1.it)('does not inject render tracker for .ts files', () => {
35
+ const code = `function UserCard(props) { return null; }`;
36
+ const out = transformTs(code);
37
+ // May still transform for function wrapping, but not for render tracking
38
+ if (out) {
39
+ strict_1.default.ok(!out.includes('__trickle_rc'), 'should NOT inject render tracker in .ts files');
40
+ }
41
+ });
42
+ (0, node_test_1.it)('does not track lowercase functions as components', () => {
43
+ const code = `function helper(x) { return x + 1; }`;
44
+ const out = transformTsx(code);
45
+ if (out) {
46
+ strict_1.default.ok(!out.includes('__trickle_rc'), 'lowercase function should not be tracked');
47
+ }
48
+ });
49
+ });
50
+ // ── Props capture: function declarations ─────────────────────────────────────
51
+ (0, node_test_1.describe)('Props capture — function declarations', () => {
52
+ (0, node_test_1.it)('uses arguments[0] for simple param: function Component(props)', () => {
53
+ const code = `function MyComponent(props) { return null; }`;
54
+ const out = transformTsx(code);
55
+ strict_1.default.ok(out, 'should transform');
56
+ strict_1.default.ok(out.includes('arguments[0]'), 'should pass arguments[0] as props');
57
+ });
58
+ (0, node_test_1.it)('uses arguments[0] for destructured param: function Component({ name })', () => {
59
+ const code = `function UserCard({ name, age }) { return null; }`;
60
+ const out = transformTsx(code);
61
+ strict_1.default.ok(out, 'should transform');
62
+ strict_1.default.ok(out.includes('arguments[0]'), 'should pass arguments[0] for destructured params');
63
+ });
64
+ (0, node_test_1.it)('injects __trickle_rc call at start of function body', () => {
65
+ const code = `function MyComponent(props) {\n const x = 1;\n return null;\n}`;
66
+ const out = transformTsx(code);
67
+ strict_1.default.ok(out, 'should transform');
68
+ // __trickle_rc should appear before body statements
69
+ const rcIdx = out.indexOf('__trickle_rc');
70
+ const bodyIdx = out.indexOf('const x = 1');
71
+ strict_1.default.ok(rcIdx !== -1, '__trickle_rc should be present');
72
+ strict_1.default.ok(bodyIdx !== -1, 'body code should be present');
73
+ strict_1.default.ok(rcIdx < bodyIdx, '__trickle_rc should come before body statements');
74
+ });
75
+ (0, node_test_1.it)('includes correct component name and line in __trickle_rc call', () => {
76
+ const code = `function UserCard(props) { return null; }`;
77
+ const out = transformTsx(code);
78
+ strict_1.default.ok(out, 'should transform');
79
+ strict_1.default.ok(out.includes('"UserCard"'), 'should include component name');
80
+ strict_1.default.ok(out.includes('__trickle_rc("UserCard"'), 'should call with component name');
81
+ });
82
+ });
83
+ // ── Props capture: arrow function components ──────────────────────────────────
84
+ (0, node_test_1.describe)('Props capture — arrow function components', () => {
85
+ (0, node_test_1.it)('uses single param name for simple arrow: const C = (props) => {}', () => {
86
+ const code = `const Dashboard = (props) => { return null; };`;
87
+ const out = transformTsx(code);
88
+ strict_1.default.ok(out, 'should transform');
89
+ strict_1.default.ok(out.includes('__trickle_rc'), 'should inject render tracker');
90
+ // props should be the param variable, not arguments[0]
91
+ strict_1.default.ok(out.includes('__trickle_rc("Dashboard"'), 'should use component name');
92
+ // should NOT use arguments[0] for arrow functions
93
+ const rcCall = out.match(/__trickle_rc\("Dashboard",[^)]+\)/);
94
+ strict_1.default.ok(rcCall, 'should have __trickle_rc call');
95
+ strict_1.default.ok(!rcCall[0].includes('arguments[0]'), 'arrow functions should not use arguments[0]');
96
+ });
97
+ (0, node_test_1.it)('reconstructs object for destructured arrow: const C = ({ a, b }) => {}', () => {
98
+ const code = `const Counter = ({ count, label }) => { return null; };`;
99
+ const out = transformTsx(code);
100
+ strict_1.default.ok(out, 'should transform');
101
+ strict_1.default.ok(out.includes('__trickle_rc'), 'should inject render tracker');
102
+ // Should reconstruct { count, label }
103
+ const rcCall = out.match(/__trickle_rc\("Counter",[^,]+,([^)]+)\)/);
104
+ if (rcCall) {
105
+ strict_1.default.ok(rcCall[1].includes('count') && rcCall[1].includes('label'), 'should reconstruct props object from destructured fields');
106
+ }
107
+ });
108
+ (0, node_test_1.it)('passes undefined for no-param arrow: const C = () => {}', () => {
109
+ const code = `const NoProps = () => { return null; };`;
110
+ const out = transformTsx(code);
111
+ if (out && out.includes('__trickle_rc')) {
112
+ strict_1.default.ok(out.includes('undefined'), 'should pass undefined for no-param component');
113
+ }
114
+ });
115
+ });
116
+ // ── render count tracking ─────────────────────────────────────────────────────
117
+ (0, node_test_1.describe)('Render count tracking', () => {
118
+ (0, node_test_1.it)('includes react_render kind in emitted record code', () => {
119
+ const code = `function Card(props) { return null; }`;
120
+ const out = transformTsx(code);
121
+ strict_1.default.ok(out, 'should transform');
122
+ strict_1.default.ok(out.includes("'react_render'"), 'emitted record should have kind react_render');
123
+ });
124
+ (0, node_test_1.it)('includes props data in emitted record', () => {
125
+ const code = `function Card(props) { return null; }`;
126
+ const out = transformTsx(code);
127
+ strict_1.default.ok(out, 'should transform');
128
+ strict_1.default.ok(out.includes('rec.props'), 'should capture props onto the record');
129
+ strict_1.default.ok(out.includes('propKeys'), 'should include propKeys');
130
+ });
131
+ (0, node_test_1.it)('tracks multiple components in one file', () => {
132
+ const code = [
133
+ `function Header(props) { return null; }`,
134
+ `function Footer(props) { return null; }`,
135
+ `function helper(x) { return x; }`,
136
+ ].join('\n');
137
+ const out = transformTsx(code);
138
+ strict_1.default.ok(out, 'should transform');
139
+ strict_1.default.ok(out.includes('"Header"'), 'should track Header');
140
+ strict_1.default.ok(out.includes('"Footer"'), 'should track Footer');
141
+ // helper should not be tracked as a component
142
+ const rcCalls = out.match(/__trickle_rc\("helper"/g);
143
+ strict_1.default.ok(!rcCalls, 'lowercase helper should not be tracked');
144
+ });
145
+ });
146
+ // ── findFunctionBodyBrace — destructured params don't confuse brace finding ───
147
+ (0, node_test_1.describe)('Correct function body brace detection', () => {
148
+ (0, node_test_1.it)('finds body brace even with destructured object params', () => {
149
+ const code = `function Form({ onSubmit, title }) {\n const x = 1;\n return null;\n}`;
150
+ const out = transformTsx(code);
151
+ strict_1.default.ok(out, 'should transform');
152
+ // __trickle_rc should be INSIDE the function body (before 'const x = 1')
153
+ const rcIdx = out.indexOf('__trickle_rc');
154
+ const bodyIdx = out.indexOf('const x = 1');
155
+ strict_1.default.ok(rcIdx < bodyIdx, 'render tracker must be inside the function body, before first statement');
156
+ // The wrap insertion should be AFTER the closing brace of the function
157
+ const wrapIdx = out.indexOf('__trickle_wrap');
158
+ strict_1.default.ok(wrapIdx > bodyIdx, 'function wrap should be after the function body');
159
+ });
160
+ });
@@ -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,25 @@ 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
+ // propsExpr: JS expression to evaluate as the props object at render time
498
+ const bodyInsertions = [];
446
499
  let match;
447
500
  while ((match = funcRegex.exec(source)) !== null) {
448
501
  const name = match[1];
449
502
  if (name === 'require' || name === 'exports' || name === 'module')
450
503
  continue;
451
504
  const afterMatch = match.index + match[0].length;
452
- const openBrace = source.indexOf('{', afterMatch);
505
+ // Use findFunctionBodyBrace to correctly skip destructured params like ({ a, b }) =>
506
+ const openBrace = findFunctionBodyBrace(source, afterMatch);
453
507
  if (openBrace === -1)
454
508
  continue;
455
- // Extract parameter names
509
+ // Extract parameter names (between the opening ( and the body {)
456
510
  const paramStr = source.slice(afterMatch, openBrace).replace(/[()]/g, '').trim();
457
511
  const paramNames = paramStr
458
512
  ? paramStr.split(',').map(p => {
@@ -466,6 +520,16 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
466
520
  if (closeBrace === -1)
467
521
  continue;
468
522
  funcInsertions.push({ position: closeBrace + 1, name, paramNames });
523
+ // React component render tracking: uppercase function name in .tsx/.jsx
524
+ // function declarations have `arguments`, so arguments[0] is the raw props object
525
+ if (isReactFile && /^[A-Z]/.test(name)) {
526
+ let lineNo = 1;
527
+ for (let i = 0; i < match.index; i++) {
528
+ if (source[i] === '\n')
529
+ lineNo++;
530
+ }
531
+ bodyInsertions.push({ position: openBrace + 1, name, lineNo, propsExpr: 'arguments[0]' });
532
+ }
469
533
  }
470
534
  // Also match arrow functions assigned to const/let/var
471
535
  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 +556,61 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
492
556
  if (closeBrace === -1)
493
557
  continue;
494
558
  funcInsertions.push({ position: closeBrace + 1, name, paramNames });
559
+ // React component render tracking: uppercase arrow function in .tsx/.jsx
560
+ if (isReactFile && /^[A-Z]/.test(name)) {
561
+ let lineNo = 1;
562
+ for (let i = 0; i < match.index; i++) {
563
+ if (source[i] === '\n')
564
+ lineNo++;
565
+ }
566
+ // Determine props expression for arrow functions (no `arguments` object)
567
+ let propsExpr = 'undefined';
568
+ if (arrowParamMatch) {
569
+ const rawParams = (arrowParamMatch[1] || '').trim();
570
+ if (!rawParams) {
571
+ // No params: `() => {}`
572
+ propsExpr = 'undefined';
573
+ }
574
+ else if (rawParams.startsWith('{')) {
575
+ // Destructured: ({ name, age }) => {} — reconstruct object from field names
576
+ // Strip TS type annotation (e.g. `{a, b}: Props` → `{a, b}`)
577
+ // Find the matching `}` to isolate just the destructuring pattern
578
+ let depth2 = 0;
579
+ let endBrace = -1;
580
+ for (let i = 0; i < rawParams.length; i++) {
581
+ if (rawParams[i] === '{')
582
+ depth2++;
583
+ else if (rawParams[i] === '}') {
584
+ depth2--;
585
+ if (depth2 === 0) {
586
+ endBrace = i;
587
+ break;
588
+ }
589
+ }
590
+ }
591
+ const destructPattern = endBrace !== -1 ? rawParams.slice(0, endBrace + 1) : rawParams;
592
+ const fields = extractDestructuredNames(destructPattern);
593
+ if (fields.length > 0) {
594
+ propsExpr = `{ ${fields.join(', ')} }`;
595
+ }
596
+ }
597
+ else if (arrowParamMatch[2]) {
598
+ // Single simple param (no parens): `props => {}`
599
+ propsExpr = arrowParamMatch[2];
600
+ }
601
+ else if (paramNames.length === 1) {
602
+ // Single simple param: `(props) => {}`
603
+ propsExpr = paramNames[0];
604
+ }
605
+ }
606
+ bodyInsertions.push({ position: openBrace + 1, name, lineNo, propsExpr });
607
+ }
495
608
  }
496
609
  // Find variable declarations for tracing
497
610
  const varInsertions = traceVars ? findVarDeclarations(source) : [];
498
611
  // Find destructured variable declarations for tracing
499
612
  const destructInsertions = traceVars ? findDestructuredDeclarations(source) : [];
500
- if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0)
613
+ if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && bodyInsertions.length === 0)
501
614
  return source;
502
615
  // Fix line numbers: Vite transforms (TypeScript stripping) may change line numbers.
503
616
  // Map transformed line numbers to original source line numbers.
@@ -522,7 +635,7 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
522
635
  const importLines = [
523
636
  `import { wrapFunction as __trickle_wrapFn, configure as __trickle_configure } from 'trickle-observe';`,
524
637
  ];
525
- if (varInsertions.length > 0 || destructInsertions.length > 0) {
638
+ if (varInsertions.length > 0 || destructInsertions.length > 0 || bodyInsertions.length > 0) {
526
639
  importLines.push(`import { mkdirSync as __trickle_mkdirSync, appendFileSync as __trickle_appendFileSync } from 'node:fs';`, `import { join as __trickle_join } from 'node:path';`);
527
640
  }
528
641
  const prefixLines = [
@@ -549,6 +662,10 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
549
662
  if (varInsertions.length > 0 || destructInsertions.length > 0) {
550
663
  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
664
  }
665
+ // Add React component render tracker if needed
666
+ 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) {}`, `}`);
668
+ }
552
669
  prefixLines.push('');
553
670
  const prefix = prefixLines.join('\n');
554
671
  const allInsertions = [];
@@ -572,6 +689,12 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
572
689
  code: `\n;try{${calls}}catch(__e){}\n`,
573
690
  });
574
691
  }
692
+ for (const { position, name, lineNo, propsExpr } of bodyInsertions) {
693
+ allInsertions.push({
694
+ position,
695
+ code: `\ntry{__trickle_rc(${JSON.stringify(name)},${lineNo},${propsExpr})}catch(__e){}\n`,
696
+ });
697
+ }
575
698
  // Sort by position descending (insert from end to preserve earlier positions)
576
699
  allInsertions.sort((a, b) => b.position - a.position);
577
700
  let result = source;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trickle-observe",
3
- "version": "0.2.54",
3
+ "version": "0.2.56",
4
4
  "description": "Runtime type observability for JavaScript applications",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -20,6 +20,7 @@
20
20
  },
21
21
  "scripts": {
22
22
  "build": "tsc && tsc -p tsconfig.esm.json",
23
+ "test": "npm run build && node --experimental-strip-types --test src/vite-plugin.test.ts",
23
24
  "prepublishOnly": "npm run build"
24
25
  },
25
26
  "optionalDependencies": {
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Unit tests for the Vite plugin transform (React component tracking).
3
+ *
4
+ * Run with: node --experimental-strip-types --test src/vite-plugin.test.ts
5
+ * Or after build: node --test dist/vite-plugin.test.js
6
+ */
7
+ import { describe, it } from 'node:test';
8
+ import assert from 'node:assert/strict';
9
+ import { tricklePlugin } from '../dist/vite-plugin.js';
10
+
11
+ // Helper: transform code as if it came from a .tsx file
12
+ function transformTsx(code: string): string | null {
13
+ const plugin = tricklePlugin({ debug: false, traceVars: false });
14
+ const result = plugin.transform(code, '/test/App.tsx');
15
+ return result ? result.code : null;
16
+ }
17
+
18
+ function transformTs(code: string): string | null {
19
+ const plugin = tricklePlugin({ debug: false, traceVars: false });
20
+ const result = plugin.transform(code, '/test/util.ts');
21
+ return result ? result.code : null;
22
+ }
23
+
24
+ // ── React file detection ─────────────────────────────────────────────────────
25
+
26
+ describe('React file detection', () => {
27
+ it('tracks uppercase components in .tsx files', () => {
28
+ const code = `function UserCard(props) { return null; }`;
29
+ const out = transformTsx(code);
30
+ assert.ok(out, 'should transform');
31
+ assert.ok(out!.includes('__trickle_rc'), 'should inject render tracker');
32
+ });
33
+
34
+ it('does not inject render tracker for .ts files', () => {
35
+ const code = `function UserCard(props) { return null; }`;
36
+ const out = transformTs(code);
37
+ // May still transform for function wrapping, but not for render tracking
38
+ if (out) {
39
+ assert.ok(!out.includes('__trickle_rc'), 'should NOT inject render tracker in .ts files');
40
+ }
41
+ });
42
+
43
+ it('does not track lowercase functions as components', () => {
44
+ const code = `function helper(x) { return x + 1; }`;
45
+ const out = transformTsx(code);
46
+ if (out) {
47
+ assert.ok(!out.includes('__trickle_rc'), 'lowercase function should not be tracked');
48
+ }
49
+ });
50
+ });
51
+
52
+ // ── Props capture: function declarations ─────────────────────────────────────
53
+
54
+ describe('Props capture — function declarations', () => {
55
+ it('uses arguments[0] for simple param: function Component(props)', () => {
56
+ const code = `function MyComponent(props) { return null; }`;
57
+ const out = transformTsx(code);
58
+ assert.ok(out, 'should transform');
59
+ assert.ok(out!.includes('arguments[0]'), 'should pass arguments[0] as props');
60
+ });
61
+
62
+ it('uses arguments[0] for destructured param: function Component({ name })', () => {
63
+ const code = `function UserCard({ name, age }) { return null; }`;
64
+ const out = transformTsx(code);
65
+ assert.ok(out, 'should transform');
66
+ assert.ok(out!.includes('arguments[0]'), 'should pass arguments[0] for destructured params');
67
+ });
68
+
69
+ it('injects __trickle_rc call at start of function body', () => {
70
+ const code = `function MyComponent(props) {\n const x = 1;\n return null;\n}`;
71
+ const out = transformTsx(code);
72
+ assert.ok(out, 'should transform');
73
+ // __trickle_rc should appear before body statements
74
+ const rcIdx = out!.indexOf('__trickle_rc');
75
+ const bodyIdx = out!.indexOf('const x = 1');
76
+ assert.ok(rcIdx !== -1, '__trickle_rc should be present');
77
+ assert.ok(bodyIdx !== -1, 'body code should be present');
78
+ assert.ok(rcIdx < bodyIdx, '__trickle_rc should come before body statements');
79
+ });
80
+
81
+ it('includes correct component name and line in __trickle_rc call', () => {
82
+ const code = `function UserCard(props) { return null; }`;
83
+ const out = transformTsx(code);
84
+ assert.ok(out, 'should transform');
85
+ assert.ok(out!.includes('"UserCard"'), 'should include component name');
86
+ assert.ok(out!.includes('__trickle_rc("UserCard"'), 'should call with component name');
87
+ });
88
+ });
89
+
90
+ // ── Props capture: arrow function components ──────────────────────────────────
91
+
92
+ describe('Props capture — arrow function components', () => {
93
+ it('uses single param name for simple arrow: const C = (props) => {}', () => {
94
+ const code = `const Dashboard = (props) => { return null; };`;
95
+ const out = transformTsx(code);
96
+ assert.ok(out, 'should transform');
97
+ assert.ok(out!.includes('__trickle_rc'), 'should inject render tracker');
98
+ // props should be the param variable, not arguments[0]
99
+ assert.ok(out!.includes('__trickle_rc("Dashboard"'), 'should use component name');
100
+ // should NOT use arguments[0] for arrow functions
101
+ const rcCall = out!.match(/__trickle_rc\("Dashboard",[^)]+\)/);
102
+ assert.ok(rcCall, 'should have __trickle_rc call');
103
+ assert.ok(!rcCall![0].includes('arguments[0]'), 'arrow functions should not use arguments[0]');
104
+ });
105
+
106
+ it('reconstructs object for destructured arrow: const C = ({ a, b }) => {}', () => {
107
+ const code = `const Counter = ({ count, label }) => { return null; };`;
108
+ const out = transformTsx(code);
109
+ assert.ok(out, 'should transform');
110
+ assert.ok(out!.includes('__trickle_rc'), 'should inject render tracker');
111
+ // Should reconstruct { count, label }
112
+ const rcCall = out!.match(/__trickle_rc\("Counter",[^,]+,([^)]+)\)/);
113
+ if (rcCall) {
114
+ assert.ok(
115
+ rcCall[1].includes('count') && rcCall[1].includes('label'),
116
+ 'should reconstruct props object from destructured fields',
117
+ );
118
+ }
119
+ });
120
+
121
+ it('handles TypeScript type annotations in destructured props: const C = ({ a }: Props) => {}', () => {
122
+ const code = `const Form = ({ onSubmit, title }: FormProps) => { return null; };`;
123
+ const out = transformTsx(code);
124
+ assert.ok(out, 'should transform');
125
+ assert.ok(out!.includes('__trickle_rc'), 'should inject render tracker');
126
+ // Should capture onSubmit and title, not include ': FormProps' in prop names
127
+ const rcCall = out!.match(/__trickle_rc\("Form",[^,]+,([^)]+)\)/);
128
+ if (rcCall) {
129
+ assert.ok(rcCall[1].includes('onSubmit'), 'should include onSubmit in props');
130
+ assert.ok(rcCall[1].includes('title'), 'should include title in props');
131
+ assert.ok(!rcCall[1].includes('FormProps'), 'should NOT include type annotation');
132
+ }
133
+ });
134
+
135
+ it('handles rest spread in destructured props: const C = ({ a, ...rest }) => {}', () => {
136
+ const code = `const Card = ({ children, ...props }: CardProps) => { return null; };`;
137
+ const out = transformTsx(code);
138
+ assert.ok(out, 'should transform');
139
+ if (out!.includes('__trickle_rc')) {
140
+ assert.ok(out!.includes('children'), 'should include children');
141
+ assert.ok(out!.includes('props'), 'should include rest spread as props');
142
+ }
143
+ });
144
+
145
+ it('passes undefined for no-param arrow: const C = () => {}', () => {
146
+ const code = `const NoProps = () => { return null; };`;
147
+ const out = transformTsx(code);
148
+ if (out && out.includes('__trickle_rc')) {
149
+ assert.ok(out.includes('undefined'), 'should pass undefined for no-param component');
150
+ }
151
+ });
152
+ });
153
+
154
+ // ── render count tracking ─────────────────────────────────────────────────────
155
+
156
+ describe('Render count tracking', () => {
157
+ it('includes react_render kind in emitted record code', () => {
158
+ const code = `function Card(props) { return null; }`;
159
+ const out = transformTsx(code);
160
+ assert.ok(out, 'should transform');
161
+ assert.ok(out!.includes("'react_render'"), 'emitted record should have kind react_render');
162
+ });
163
+
164
+ it('includes props data in emitted record', () => {
165
+ const code = `function Card(props) { return null; }`;
166
+ const out = transformTsx(code);
167
+ assert.ok(out, 'should transform');
168
+ assert.ok(out!.includes('rec.props'), 'should capture props onto the record');
169
+ assert.ok(out!.includes('propKeys'), 'should include propKeys');
170
+ });
171
+
172
+ it('tracks multiple components in one file', () => {
173
+ const code = [
174
+ `function Header(props) { return null; }`,
175
+ `function Footer(props) { return null; }`,
176
+ `function helper(x) { return x; }`,
177
+ ].join('\n');
178
+ const out = transformTsx(code);
179
+ assert.ok(out, 'should transform');
180
+ assert.ok(out!.includes('"Header"'), 'should track Header');
181
+ assert.ok(out!.includes('"Footer"'), 'should track Footer');
182
+ // helper should not be tracked as a component
183
+ const rcCalls = out!.match(/__trickle_rc\("helper"/g);
184
+ assert.ok(!rcCalls, 'lowercase helper should not be tracked');
185
+ });
186
+ });
187
+
188
+ // ── findFunctionBodyBrace — destructured params don't confuse brace finding ───
189
+
190
+ describe('Correct function body brace detection', () => {
191
+ it('finds body brace even with destructured object params', () => {
192
+ const code = `function Form({ onSubmit, title }) {\n const x = 1;\n return null;\n}`;
193
+ const out = transformTsx(code);
194
+ assert.ok(out, 'should transform');
195
+ // __trickle_rc should be INSIDE the function body (before 'const x = 1')
196
+ const rcIdx = out!.indexOf('__trickle_rc');
197
+ const bodyIdx = out!.indexOf('const x = 1');
198
+ assert.ok(rcIdx < bodyIdx, 'render tracker must be inside the function body, before first statement');
199
+ // The wrap insertion (Form=__trickle_wrap(...)) should be AFTER the function body
200
+ const wrapIdx = out!.indexOf('Form=__trickle_wrap');
201
+ assert.ok(wrapIdx > bodyIdx, 'function wrap should be after the function body');
202
+ });
203
+ });
@@ -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,15 @@ 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
+ // propsExpr: JS expression to evaluate as the props object at render time
501
+ const bodyInsertions: Array<{ position: number; name: string; lineNo: number; propsExpr: string }> = [];
454
502
  let match;
455
503
 
456
504
  while ((match = funcRegex.exec(source)) !== null) {
@@ -458,10 +506,11 @@ function transformEsmSource(
458
506
  if (name === 'require' || name === 'exports' || name === 'module') continue;
459
507
 
460
508
  const afterMatch = match.index + match[0].length;
461
- const openBrace = source.indexOf('{', afterMatch);
509
+ // Use findFunctionBodyBrace to correctly skip destructured params like ({ a, b }) =>
510
+ const openBrace = findFunctionBodyBrace(source, afterMatch);
462
511
  if (openBrace === -1) continue;
463
512
 
464
- // Extract parameter names
513
+ // Extract parameter names (between the opening ( and the body {)
465
514
  const paramStr = source.slice(afterMatch, openBrace).replace(/[()]/g, '').trim();
466
515
  const paramNames = paramStr
467
516
  ? paramStr.split(',').map(p => {
@@ -475,6 +524,16 @@ function transformEsmSource(
475
524
  if (closeBrace === -1) continue;
476
525
 
477
526
  funcInsertions.push({ position: closeBrace + 1, name, paramNames });
527
+
528
+ // React component render tracking: uppercase function name in .tsx/.jsx
529
+ // function declarations have `arguments`, so arguments[0] is the raw props object
530
+ if (isReactFile && /^[A-Z]/.test(name)) {
531
+ let lineNo = 1;
532
+ for (let i = 0; i < match.index; i++) {
533
+ if (source[i] === '\n') lineNo++;
534
+ }
535
+ bodyInsertions.push({ position: openBrace + 1, name, lineNo, propsExpr: 'arguments[0]' });
536
+ }
478
537
  }
479
538
 
480
539
  // Also match arrow functions assigned to const/let/var
@@ -503,15 +562,58 @@ function transformEsmSource(
503
562
  if (closeBrace === -1) continue;
504
563
 
505
564
  funcInsertions.push({ position: closeBrace + 1, name, paramNames });
565
+
566
+ // React component render tracking: uppercase arrow function in .tsx/.jsx
567
+ if (isReactFile && /^[A-Z]/.test(name)) {
568
+ let lineNo = 1;
569
+ for (let i = 0; i < match.index; i++) {
570
+ if (source[i] === '\n') lineNo++;
571
+ }
572
+
573
+ // Determine props expression for arrow functions (no `arguments` object)
574
+ let propsExpr = 'undefined';
575
+ if (arrowParamMatch) {
576
+ const rawParams = (arrowParamMatch[1] || '').trim();
577
+ if (!rawParams) {
578
+ // No params: `() => {}`
579
+ propsExpr = 'undefined';
580
+ } else if (rawParams.startsWith('{')) {
581
+ // Destructured: ({ name, age }) => {} — reconstruct object from field names
582
+ // Strip TS type annotation (e.g. `{a, b}: Props` → `{a, b}`)
583
+ // Find the matching `}` to isolate just the destructuring pattern
584
+ let depth2 = 0;
585
+ let endBrace = -1;
586
+ for (let i = 0; i < rawParams.length; i++) {
587
+ if (rawParams[i] === '{') depth2++;
588
+ else if (rawParams[i] === '}') { depth2--; if (depth2 === 0) { endBrace = i; break; } }
589
+ }
590
+ const destructPattern = endBrace !== -1 ? rawParams.slice(0, endBrace + 1) : rawParams;
591
+ const fields = extractDestructuredNames(destructPattern);
592
+ if (fields.length > 0) {
593
+ propsExpr = `{ ${fields.join(', ')} }`;
594
+ }
595
+ } else if (arrowParamMatch[2]) {
596
+ // Single simple param (no parens): `props => {}`
597
+ propsExpr = arrowParamMatch[2];
598
+ } else if (paramNames.length === 1) {
599
+ // Single simple param: `(props) => {}`
600
+ propsExpr = paramNames[0];
601
+ }
602
+ }
603
+
604
+ bodyInsertions.push({ position: openBrace + 1, name, lineNo, propsExpr });
605
+ }
506
606
  }
507
607
 
608
+
609
+
508
610
  // Find variable declarations for tracing
509
611
  const varInsertions = traceVars ? findVarDeclarations(source) : [];
510
612
 
511
613
  // Find destructured variable declarations for tracing
512
614
  const destructInsertions = traceVars ? findDestructuredDeclarations(source) : [];
513
615
 
514
- if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0) return source;
616
+ if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && bodyInsertions.length === 0) return source;
515
617
 
516
618
  // Fix line numbers: Vite transforms (TypeScript stripping) may change line numbers.
517
619
  // Map transformed line numbers to original source line numbers.
@@ -536,7 +638,7 @@ function transformEsmSource(
536
638
  const importLines: string[] = [
537
639
  `import { wrapFunction as __trickle_wrapFn, configure as __trickle_configure } from 'trickle-observe';`,
538
640
  ];
539
- if (varInsertions.length > 0 || destructInsertions.length > 0) {
641
+ if (varInsertions.length > 0 || destructInsertions.length > 0 || bodyInsertions.length > 0) {
540
642
  importLines.push(
541
643
  `import { mkdirSync as __trickle_mkdirSync, appendFileSync as __trickle_appendFileSync } from 'node:fs';`,
542
644
  `import { join as __trickle_join } from 'node:path';`,
@@ -619,6 +721,43 @@ function transformEsmSource(
619
721
  );
620
722
  }
621
723
 
724
+ // Add React component render tracker if needed
725
+ if (bodyInsertions.length > 0) {
726
+ prefixLines.push(
727
+ `if (!globalThis.__trickle_react_renders) { globalThis.__trickle_react_renders = new Map(); }`,
728
+ `function __trickle_rc(name, line, props) {`,
729
+ ` try {`,
730
+ ` const key = ${JSON.stringify(filename)} + ':' + line;`,
731
+ ` const count = (globalThis.__trickle_react_renders.get(key) || 0) + 1;`,
732
+ ` globalThis.__trickle_react_renders.set(key, count);`,
733
+ ` const dir = process.env.TRICKLE_LOCAL_DIR || __trickle_join(process.cwd(), '.trickle');`,
734
+ ` try { __trickle_mkdirSync(dir, { recursive: true }); } catch(e) {}`,
735
+ ` const f = __trickle_join(dir, 'variables.jsonl');`,
736
+ ` const rec = { kind: 'react_render', file: ${JSON.stringify(filename)}, line: line, component: name, renderCount: count, timestamp: Date.now() / 1000 };`,
737
+ ` if (props !== undefined && props !== null && typeof props === 'object') {`,
738
+ ` try {`,
739
+ ` const propKeys = Object.keys(props).filter(k => k !== 'children');`,
740
+ ` const propSample = {};`,
741
+ ` for (const k of propKeys.slice(0, 10)) {`,
742
+ ` const v = props[k];`,
743
+ ` const t = typeof v;`,
744
+ ` if (t === 'string') propSample[k] = v.length > 40 ? v.slice(0, 40) + '...' : v;`,
745
+ ` else if (t === 'number' || t === 'boolean') propSample[k] = v;`,
746
+ ` else if (v === null || v === undefined) propSample[k] = v;`,
747
+ ` else if (Array.isArray(v)) propSample[k] = '[' + t + '[' + v.length + ']]';`,
748
+ ` else if (t === 'function') propSample[k] = '[fn]';`,
749
+ ` else propSample[k] = '[object]';`,
750
+ ` }`,
751
+ ` rec.props = propSample;`,
752
+ ` rec.propKeys = propKeys;`,
753
+ ` } catch(e2) {}`,
754
+ ` }`,
755
+ ` __trickle_appendFileSync(f, JSON.stringify(rec) + '\\n');`,
756
+ ` } catch(e) {}`,
757
+ `}`,
758
+ );
759
+ }
760
+
622
761
  prefixLines.push('');
623
762
  const prefix = prefixLines.join('\n');
624
763
 
@@ -649,6 +788,13 @@ function transformEsmSource(
649
788
  });
650
789
  }
651
790
 
791
+ for (const { position, name, lineNo, propsExpr } of bodyInsertions) {
792
+ allInsertions.push({
793
+ position,
794
+ code: `\ntry{__trickle_rc(${JSON.stringify(name)},${lineNo},${propsExpr})}catch(__e){}\n`,
795
+ });
796
+ }
797
+
652
798
  // Sort by position descending (insert from end to preserve earlier positions)
653
799
  allInsertions.sort((a, b) => b.position - a.position);
654
800
 
package/tsconfig.json CHANGED
@@ -4,5 +4,6 @@
4
4
  "outDir": "./dist",
5
5
  "rootDir": "./src"
6
6
  },
7
- "include": ["src/**/*"]
7
+ "include": ["src/**/*"],
8
+ "exclude": ["src/**/*.test.ts"]
8
9
  }