trickle-observe 0.2.55 → 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.
@@ -500,6 +500,7 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
500
500
  const funcRegex = /^[ \t]*(?:export\s+)?(?:async\s+)?function\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/gm;
501
501
  const funcInsertions = [];
502
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
503
504
  const bodyInsertions = [];
504
505
  let match;
505
506
  while ((match = funcRegex.exec(source)) !== null) {
@@ -526,13 +527,14 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
526
527
  continue;
527
528
  funcInsertions.push({ position: closeBrace + 1, name, paramNames });
528
529
  // React component render tracking: uppercase function name in .tsx/.jsx
530
+ // function declarations have `arguments`, so arguments[0] is the raw props object
529
531
  if (isReactFile && /^[A-Z]/.test(name)) {
530
532
  let lineNo = 1;
531
533
  for (let i = 0; i < match.index; i++) {
532
534
  if (source[i] === '\n')
533
535
  lineNo++;
534
536
  }
535
- bodyInsertions.push({ position: openBrace + 1, name, lineNo });
537
+ bodyInsertions.push({ position: openBrace + 1, name, lineNo, propsExpr: 'arguments[0]' });
536
538
  }
537
539
  }
538
540
  // Also match arrow functions assigned to const/let/var
@@ -567,7 +569,47 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
567
569
  if (source[i] === '\n')
568
570
  lineNo++;
569
571
  }
570
- bodyInsertions.push({ position: openBrace + 1, name, lineNo });
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 });
571
613
  }
572
614
  }
573
615
  // Find variable declarations for tracing
@@ -628,7 +670,7 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
628
670
  }
629
671
  // Add React component render tracker if needed
630
672
  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) {}`, `}`);
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) {}`, `}`);
632
674
  }
633
675
  prefixLines.push('');
634
676
  const prefix = prefixLines.join('\n');
@@ -653,10 +695,10 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
653
695
  code: `\n;try{${calls}}catch(__e){}\n`,
654
696
  });
655
697
  }
656
- for (const { position, name, lineNo } of bodyInsertions) {
698
+ for (const { position, name, lineNo, propsExpr } of bodyInsertions) {
657
699
  allInsertions.push({
658
700
  position,
659
- code: `\ntry{__trickle_rc(${JSON.stringify(name)},${lineNo})}catch(__e){}\n`,
701
+ code: `\ntry{__trickle_rc(${JSON.stringify(name)},${lineNo},${propsExpr})}catch(__e){}\n`,
660
702
  });
661
703
  }
662
704
  // Sort by position descending (insert from end to preserve earlier positions)
@@ -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
+ });
@@ -494,6 +494,7 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
494
494
  const funcRegex = /^[ \t]*(?:export\s+)?(?:async\s+)?function\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/gm;
495
495
  const funcInsertions = [];
496
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
497
498
  const bodyInsertions = [];
498
499
  let match;
499
500
  while ((match = funcRegex.exec(source)) !== null) {
@@ -520,13 +521,14 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
520
521
  continue;
521
522
  funcInsertions.push({ position: closeBrace + 1, name, paramNames });
522
523
  // React component render tracking: uppercase function name in .tsx/.jsx
524
+ // function declarations have `arguments`, so arguments[0] is the raw props object
523
525
  if (isReactFile && /^[A-Z]/.test(name)) {
524
526
  let lineNo = 1;
525
527
  for (let i = 0; i < match.index; i++) {
526
528
  if (source[i] === '\n')
527
529
  lineNo++;
528
530
  }
529
- bodyInsertions.push({ position: openBrace + 1, name, lineNo });
531
+ bodyInsertions.push({ position: openBrace + 1, name, lineNo, propsExpr: 'arguments[0]' });
530
532
  }
531
533
  }
532
534
  // Also match arrow functions assigned to const/let/var
@@ -561,7 +563,47 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
561
563
  if (source[i] === '\n')
562
564
  lineNo++;
563
565
  }
564
- bodyInsertions.push({ position: openBrace + 1, name, lineNo });
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 });
565
607
  }
566
608
  }
567
609
  // Find variable declarations for tracing
@@ -622,7 +664,7 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
622
664
  }
623
665
  // Add React component render tracker if needed
624
666
  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) {}`, `}`);
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) {}`, `}`);
626
668
  }
627
669
  prefixLines.push('');
628
670
  const prefix = prefixLines.join('\n');
@@ -647,10 +689,10 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
647
689
  code: `\n;try{${calls}}catch(__e){}\n`,
648
690
  });
649
691
  }
650
- for (const { position, name, lineNo } of bodyInsertions) {
692
+ for (const { position, name, lineNo, propsExpr } of bodyInsertions) {
651
693
  allInsertions.push({
652
694
  position,
653
- code: `\ntry{__trickle_rc(${JSON.stringify(name)},${lineNo})}catch(__e){}\n`,
695
+ code: `\ntry{__trickle_rc(${JSON.stringify(name)},${lineNo},${propsExpr})}catch(__e){}\n`,
654
696
  });
655
697
  }
656
698
  // Sort by position descending (insert from end to preserve earlier positions)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trickle-observe",
3
- "version": "0.2.55",
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
+ });
@@ -497,7 +497,8 @@ function transformEsmSource(
497
497
  const funcRegex = /^[ \t]*(?:export\s+)?(?:async\s+)?function\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/gm;
498
498
  const funcInsertions: Array<{ position: number; name: string; paramNames: string[] }> = [];
499
499
  // Body insertions: insert at start of function body (for React render tracking)
500
- const bodyInsertions: Array<{ position: number; name: string; lineNo: number }> = [];
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 }> = [];
501
502
  let match;
502
503
 
503
504
  while ((match = funcRegex.exec(source)) !== null) {
@@ -525,12 +526,13 @@ function transformEsmSource(
525
526
  funcInsertions.push({ position: closeBrace + 1, name, paramNames });
526
527
 
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
528
530
  if (isReactFile && /^[A-Z]/.test(name)) {
529
531
  let lineNo = 1;
530
532
  for (let i = 0; i < match.index; i++) {
531
533
  if (source[i] === '\n') lineNo++;
532
534
  }
533
- bodyInsertions.push({ position: openBrace + 1, name, lineNo });
535
+ bodyInsertions.push({ position: openBrace + 1, name, lineNo, propsExpr: 'arguments[0]' });
534
536
  }
535
537
  }
536
538
 
@@ -567,7 +569,39 @@ function transformEsmSource(
567
569
  for (let i = 0; i < match.index; i++) {
568
570
  if (source[i] === '\n') lineNo++;
569
571
  }
570
- bodyInsertions.push({ position: openBrace + 1, name, lineNo });
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 });
571
605
  }
572
606
  }
573
607
 
@@ -691,7 +725,7 @@ function transformEsmSource(
691
725
  if (bodyInsertions.length > 0) {
692
726
  prefixLines.push(
693
727
  `if (!globalThis.__trickle_react_renders) { globalThis.__trickle_react_renders = new Map(); }`,
694
- `function __trickle_rc(name, line) {`,
728
+ `function __trickle_rc(name, line, props) {`,
695
729
  ` try {`,
696
730
  ` const key = ${JSON.stringify(filename)} + ':' + line;`,
697
731
  ` const count = (globalThis.__trickle_react_renders.get(key) || 0) + 1;`,
@@ -699,7 +733,26 @@ function transformEsmSource(
699
733
  ` const dir = process.env.TRICKLE_LOCAL_DIR || __trickle_join(process.cwd(), '.trickle');`,
700
734
  ` try { __trickle_mkdirSync(dir, { recursive: true }); } catch(e) {}`,
701
735
  ` 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');`,
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');`,
703
756
  ` } catch(e) {}`,
704
757
  `}`,
705
758
  );
@@ -735,10 +788,10 @@ function transformEsmSource(
735
788
  });
736
789
  }
737
790
 
738
- for (const { position, name, lineNo } of bodyInsertions) {
791
+ for (const { position, name, lineNo, propsExpr } of bodyInsertions) {
739
792
  allInsertions.push({
740
793
  position,
741
- code: `\ntry{__trickle_rc(${JSON.stringify(name)},${lineNo})}catch(__e){}\n`,
794
+ code: `\ntry{__trickle_rc(${JSON.stringify(name)},${lineNo},${propsExpr})}catch(__e){}\n`,
742
795
  });
743
796
  }
744
797
 
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
  }