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.
- package/dist/vite-plugin.js +127 -4
- package/dist/vite-plugin.test.d.ts +1 -0
- package/dist/vite-plugin.test.js +160 -0
- package/dist-esm/vite-plugin.js +127 -4
- package/package.json +2 -1
- package/src/vite-plugin.test.ts +203 -0
- package/src/vite-plugin.ts +150 -4
- package/tsconfig.json +2 -1
package/dist/vite-plugin.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
});
|
package/dist-esm/vite-plugin.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
+
});
|
package/src/vite-plugin.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|