trickle-observe 0.2.64 → 0.2.65

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.
@@ -193,6 +193,32 @@ function findClosingBrace(source, openBrace) {
193
193
  }
194
194
  return -1;
195
195
  }
196
+ /**
197
+ * Find the matching closing paren for an opening paren at openParen.
198
+ * JSX-safe version: counts `(` and `)` depth, handles JSX curly expressions,
199
+ * but does NOT treat `'` as a string delimiter (apostrophes in JSX text content
200
+ * like `I'm` would incorrectly consume parens). Double-quoted strings inside
201
+ * JSX attr values typically contain balanced parens so can be ignored safely.
202
+ */
203
+ function findMatchingParen(source, openParen) {
204
+ let depth = 1;
205
+ let pos = openParen + 1;
206
+ while (pos < source.length && depth > 0) {
207
+ const ch = source[pos];
208
+ if (ch === '(') {
209
+ depth++;
210
+ }
211
+ else if (ch === ')') {
212
+ depth--;
213
+ if (depth === 0)
214
+ return pos;
215
+ }
216
+ // Skip JSX expression blocks {expr} — not parens but skip curly content to avoid
217
+ // false paren matches inside template expressions
218
+ pos++;
219
+ }
220
+ return -1;
221
+ }
196
222
  /**
197
223
  * Find variable declarations in source and return insertions for tracing.
198
224
  * Handles: const x = ...; let x = ...; var x = ...;
@@ -540,12 +566,14 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
540
566
  }
541
567
  // Also match arrow functions assigned to const/let/var
542
568
  // Handles: const X = () => {}, const X: React.FC = () => {}, const X: React.FC<Props> = ({ a }) => {}
543
- const arrowRegex = /^[ \t]*(?:export\s+)?(?:const|let|var)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)(?:\s*:\s*[^=]+?)?\s*=\s*(?:async\s+)?(?:\([^)]*\)|[a-zA-Z_$][a-zA-Z0-9_$]*)\s*(?::\s*[^=]+?)?\s*=>\s*\{/gm;
569
+ // Also handles concise bodies: const X = (props) => (<div/>)
570
+ const arrowRegex = /^[ \t]*(?:export\s+)?(?:const|let|var)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)(?:\s*:\s*[^=]+?)?\s*=\s*(?:async\s+)?(?:\([^)]*\)|[a-zA-Z_$][a-zA-Z0-9_$]*)\s*(?::\s*[^=]+?)?\s*=>\s*(?:\{|\()/gm;
571
+ // Concise body insertions: for `=> (expr)`, wrap with block body for render tracking
572
+ const conciseBodyInsertions = [];
544
573
  while ((match = arrowRegex.exec(source)) !== null) {
545
574
  const name = match[1];
546
- const openBrace = source.indexOf('{', match.index + match[0].length - 1);
547
- if (openBrace === -1)
548
- continue;
575
+ const bodyStartPos = match.index + match[0].length - 1;
576
+ const isConcise = source[bodyStartPos] === '(';
549
577
  const arrowStr = match[0];
550
578
  const arrowParamMatch = arrowStr.match(/=\s*(?:async\s+)?(?:\(([^)]*)\)|([a-zA-Z_$][a-zA-Z0-9_$]*))\s*(?::\s*[^=]+?)?\s*=>/);
551
579
  let paramNames = [];
@@ -560,58 +588,69 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
560
588
  }).filter(Boolean);
561
589
  }
562
590
  }
563
- const closeBrace = findClosingBrace(source, openBrace);
564
- if (closeBrace === -1)
565
- continue;
566
- funcInsertions.push({ position: closeBrace + 1, name, paramNames });
567
- // React component render tracking: uppercase arrow function in .tsx/.jsx
568
- if (isReactFile && /^[A-Z]/.test(name)) {
569
- let lineNo = 1;
570
- for (let i = 0; i < match.index; i++) {
571
- if (source[i] === '\n')
572
- lineNo++;
573
- }
574
- // Determine props expression for arrow functions (no `arguments` object)
575
- let propsExpr = 'undefined';
576
- if (arrowParamMatch) {
577
- const rawParams = (arrowParamMatch[1] || '').trim();
578
- if (!rawParams) {
579
- // No params: `() => {}`
580
- propsExpr = 'undefined';
581
- }
582
- else if (rawParams.startsWith('{')) {
583
- // Destructured: ({ name, age }) => {} — reconstruct object from field names
584
- // Strip TS type annotation (e.g. `{a, b}: Props` → `{a, b}`)
585
- // Find the matching `}` to isolate just the destructuring pattern
586
- let depth2 = 0;
587
- let endBrace = -1;
588
- for (let i = 0; i < rawParams.length; i++) {
589
- if (rawParams[i] === '{')
590
- depth2++;
591
- else if (rawParams[i] === '}') {
592
- depth2--;
593
- if (depth2 === 0) {
594
- endBrace = i;
595
- break;
596
- }
591
+ // Helper to build propsExpr from arrowParamMatch
592
+ const buildPropsExpr = () => {
593
+ if (!arrowParamMatch)
594
+ return 'undefined';
595
+ const rawParams = (arrowParamMatch[1] || '').trim();
596
+ if (!rawParams)
597
+ return 'undefined';
598
+ if (rawParams.startsWith('{')) {
599
+ let depth2 = 0, endBrace = -1;
600
+ for (let i = 0; i < rawParams.length; i++) {
601
+ if (rawParams[i] === '{')
602
+ depth2++;
603
+ else if (rawParams[i] === '}') {
604
+ depth2--;
605
+ if (depth2 === 0) {
606
+ endBrace = i;
607
+ break;
597
608
  }
598
609
  }
599
- const destructPattern = endBrace !== -1 ? rawParams.slice(0, endBrace + 1) : rawParams;
600
- const fields = extractDestructuredNames(destructPattern);
601
- if (fields.length > 0) {
602
- propsExpr = `{ ${fields.join(', ')} }`;
603
- }
604
610
  }
605
- else if (arrowParamMatch[2]) {
606
- // Single simple param (no parens): `props => {}`
607
- propsExpr = arrowParamMatch[2];
611
+ const destructPattern = endBrace !== -1 ? rawParams.slice(0, endBrace + 1) : rawParams;
612
+ const fields = extractDestructuredNames(destructPattern);
613
+ return fields.length > 0 ? `{ ${fields.join(', ')} }` : 'undefined';
614
+ }
615
+ else if (arrowParamMatch[2]) {
616
+ return arrowParamMatch[2];
617
+ }
618
+ else if (paramNames.length === 1) {
619
+ return paramNames[0];
620
+ }
621
+ return 'undefined';
622
+ };
623
+ if (isConcise) {
624
+ // Concise body: `const X = (props) => (<div/>)` — no block body
625
+ // Only add render tracking for React components (uppercase names in .tsx/.jsx)
626
+ if (isReactFile && /^[A-Z]/.test(name)) {
627
+ const closeParen = findMatchingParen(source, bodyStartPos);
628
+ if (closeParen === -1)
629
+ continue;
630
+ let lineNo = 1;
631
+ for (let i = 0; i < match.index; i++) {
632
+ if (source[i] === '\n')
633
+ lineNo++;
608
634
  }
609
- else if (paramNames.length === 1) {
610
- // Single simple param: `(props) => {}`
611
- propsExpr = paramNames[0];
635
+ conciseBodyInsertions.push({ beforeParen: bodyStartPos, afterCloseParen: closeParen + 1, name, lineNo, propsExpr: buildPropsExpr() });
636
+ }
637
+ }
638
+ else {
639
+ // Block body: `const X = (props) => { ... }`
640
+ const openBrace = bodyStartPos;
641
+ const closeBrace = findClosingBrace(source, openBrace);
642
+ if (closeBrace === -1)
643
+ continue;
644
+ funcInsertions.push({ position: closeBrace + 1, name, paramNames });
645
+ // React component render tracking: uppercase arrow function in .tsx/.jsx
646
+ if (isReactFile && /^[A-Z]/.test(name)) {
647
+ let lineNo = 1;
648
+ for (let i = 0; i < match.index; i++) {
649
+ if (source[i] === '\n')
650
+ lineNo++;
612
651
  }
652
+ bodyInsertions.push({ position: openBrace + 1, name, lineNo, propsExpr: buildPropsExpr() });
613
653
  }
614
- bodyInsertions.push({ position: openBrace + 1, name, lineNo, propsExpr });
615
654
  }
616
655
  }
617
656
  // Match React.memo() and React.forwardRef() wrapped components
@@ -824,7 +863,7 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
824
863
  const varInsertions = traceVars ? findVarDeclarations(source) : [];
825
864
  // Find destructured variable declarations for tracing
826
865
  const destructInsertions = traceVars ? findDestructuredDeclarations(source) : [];
827
- if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && bodyInsertions.length === 0 && hookInsertions.length === 0 && stateInsertions.length === 0)
866
+ if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && bodyInsertions.length === 0 && hookInsertions.length === 0 && stateInsertions.length === 0 && conciseBodyInsertions.length === 0)
828
867
  return source;
829
868
  // Fix line numbers: Vite transforms (TypeScript stripping) may change line numbers.
830
869
  // Map transformed line numbers to original source line numbers.
@@ -849,7 +888,7 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
849
888
  const importLines = [
850
889
  `import { wrapFunction as __trickle_wrapFn, configure as __trickle_configure } from 'trickle-observe';`,
851
890
  ];
852
- if (varInsertions.length > 0 || destructInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0 || stateInsertions.length > 0) {
891
+ if (varInsertions.length > 0 || destructInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0 || stateInsertions.length > 0 || conciseBodyInsertions.length > 0) {
853
892
  importLines.push(`import { mkdirSync as __trickle_mkdirSync, appendFileSync as __trickle_appendFileSync } from 'node:fs';`, `import { join as __trickle_join } from 'node:path';`);
854
893
  }
855
894
  const prefixLines = [
@@ -877,7 +916,7 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
877
916
  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) {} }`);
878
917
  }
879
918
  // Add React component render tracker if needed
880
- if (bodyInsertions.length > 0) {
919
+ if (bodyInsertions.length > 0 || conciseBodyInsertions.length > 0) {
881
920
  prefixLines.push(`if (!globalThis.__trickle_react_renders) { globalThis.__trickle_react_renders = new Map(); }`, `if (!globalThis.__trickle_react_prev_props) { globalThis.__trickle_react_prev_props = new Map(); }`, `function __trickle_rc(name, line, props) {`, ` try {`, ` const key = ${JSON.stringify(filename)} + ':' + line;`, ` const count = (globalThis.__trickle_react_renders.get(key) || 0) + 1;`, ` globalThis.__trickle_react_renders.set(key, count);`, ` const dir = process.env.TRICKLE_LOCAL_DIR || __trickle_join(process.cwd(), '.trickle');`, ` try { __trickle_mkdirSync(dir, { recursive: true }); } catch(e) {}`, ` const f = __trickle_join(dir, 'variables.jsonl');`, ` const rec = { kind: 'react_render', file: ${JSON.stringify(filename)}, line: line, component: name, renderCount: count, timestamp: Date.now() / 1000 };`, ` if (props !== undefined && props !== null && typeof props === 'object') {`, ` try {`, ` const propKeys = Object.keys(props).filter(k => k !== 'children');`, ` const propSample = {};`, ` for (const k of propKeys.slice(0, 10)) {`, ` const v = props[k];`, ` const t = typeof v;`, ` if (t === 'string') propSample[k] = v.length > 40 ? v.slice(0, 40) + '...' : v;`, ` else if (t === 'number' || t === 'boolean') propSample[k] = v;`, ` else if (v === null || v === undefined) propSample[k] = v;`, ` else if (Array.isArray(v)) propSample[k] = '[arr:' + v.length + ']';`, ` else if (t === 'function') propSample[k] = '[fn]';`, ` else propSample[k] = '[object]';`, ` }`, ` rec.props = propSample;`, ` rec.propKeys = propKeys;`, ` // Detect which props changed vs previous render`, ` const prevProps = globalThis.__trickle_react_prev_props.get(key);`, ` if (prevProps && count > 1) {`, ` const changedProps = [];`, ` const allKeys = new Set([...Object.keys(prevProps), ...Object.keys(propSample)]);`, ` for (const k of allKeys) {`, ` const prev = prevProps[k];`, ` const curr = propSample[k];`, ` if (String(prev) !== String(curr)) {`, ` changedProps.push({ key: k, from: prev, to: curr });`, ` }`, ` }`, ` if (changedProps.length > 0) rec.changedProps = changedProps;`, ` }`, ` globalThis.__trickle_react_prev_props.set(key, propSample);`, ` } catch(e2) {}`, ` }`, ` __trickle_appendFileSync(f, JSON.stringify(rec) + '\\n');`, ` } catch(e) {}`, `}`);
882
921
  }
883
922
  // Add React hook tracker if needed
@@ -932,6 +971,18 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
932
971
  code: `const ${setterName}=__trickle_ss(${JSON.stringify(stateName)},${lineNo},__trickle_s_${setterName});\n`,
933
972
  });
934
973
  }
974
+ // Concise arrow body insertions: convert `=> (expr)` to `=> { try{__trickle_rc(...)} return (expr); }`
975
+ // Two insertions per component: one before `(`, one after matching `)`
976
+ for (const { beforeParen, afterCloseParen, name, lineNo, propsExpr } of conciseBodyInsertions) {
977
+ allInsertions.push({
978
+ position: beforeParen,
979
+ code: `{ try{__trickle_rc(${JSON.stringify(name)},${lineNo},${propsExpr})}catch(__e){} return `,
980
+ });
981
+ allInsertions.push({
982
+ position: afterCloseParen,
983
+ code: `\n}`,
984
+ });
985
+ }
935
986
  // Sort by position descending (insert from end to preserve earlier positions)
936
987
  allInsertions.sort((a, b) => b.position - a.position);
937
988
  let result = source;
@@ -186,6 +186,32 @@ function findClosingBrace(source, openBrace) {
186
186
  }
187
187
  return -1;
188
188
  }
189
+ /**
190
+ * Find the matching closing paren for an opening paren at openParen.
191
+ * JSX-safe version: counts `(` and `)` depth, handles JSX curly expressions,
192
+ * but does NOT treat `'` as a string delimiter (apostrophes in JSX text content
193
+ * like `I'm` would incorrectly consume parens). Double-quoted strings inside
194
+ * JSX attr values typically contain balanced parens so can be ignored safely.
195
+ */
196
+ function findMatchingParen(source, openParen) {
197
+ let depth = 1;
198
+ let pos = openParen + 1;
199
+ while (pos < source.length && depth > 0) {
200
+ const ch = source[pos];
201
+ if (ch === '(') {
202
+ depth++;
203
+ }
204
+ else if (ch === ')') {
205
+ depth--;
206
+ if (depth === 0)
207
+ return pos;
208
+ }
209
+ // Skip JSX expression blocks {expr} — not parens but skip curly content to avoid
210
+ // false paren matches inside template expressions
211
+ pos++;
212
+ }
213
+ return -1;
214
+ }
189
215
  /**
190
216
  * Find variable declarations in source and return insertions for tracing.
191
217
  * Handles: const x = ...; let x = ...; var x = ...;
@@ -533,12 +559,14 @@ export function transformEsmSource(source, filename, moduleName, backendUrl, deb
533
559
  }
534
560
  // Also match arrow functions assigned to const/let/var
535
561
  // Handles: const X = () => {}, const X: React.FC = () => {}, const X: React.FC<Props> = ({ a }) => {}
536
- const arrowRegex = /^[ \t]*(?:export\s+)?(?:const|let|var)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)(?:\s*:\s*[^=]+?)?\s*=\s*(?:async\s+)?(?:\([^)]*\)|[a-zA-Z_$][a-zA-Z0-9_$]*)\s*(?::\s*[^=]+?)?\s*=>\s*\{/gm;
562
+ // Also handles concise bodies: const X = (props) => (<div/>)
563
+ const arrowRegex = /^[ \t]*(?:export\s+)?(?:const|let|var)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)(?:\s*:\s*[^=]+?)?\s*=\s*(?:async\s+)?(?:\([^)]*\)|[a-zA-Z_$][a-zA-Z0-9_$]*)\s*(?::\s*[^=]+?)?\s*=>\s*(?:\{|\()/gm;
564
+ // Concise body insertions: for `=> (expr)`, wrap with block body for render tracking
565
+ const conciseBodyInsertions = [];
537
566
  while ((match = arrowRegex.exec(source)) !== null) {
538
567
  const name = match[1];
539
- const openBrace = source.indexOf('{', match.index + match[0].length - 1);
540
- if (openBrace === -1)
541
- continue;
568
+ const bodyStartPos = match.index + match[0].length - 1;
569
+ const isConcise = source[bodyStartPos] === '(';
542
570
  const arrowStr = match[0];
543
571
  const arrowParamMatch = arrowStr.match(/=\s*(?:async\s+)?(?:\(([^)]*)\)|([a-zA-Z_$][a-zA-Z0-9_$]*))\s*(?::\s*[^=]+?)?\s*=>/);
544
572
  let paramNames = [];
@@ -553,58 +581,69 @@ export function transformEsmSource(source, filename, moduleName, backendUrl, deb
553
581
  }).filter(Boolean);
554
582
  }
555
583
  }
556
- const closeBrace = findClosingBrace(source, openBrace);
557
- if (closeBrace === -1)
558
- continue;
559
- funcInsertions.push({ position: closeBrace + 1, name, paramNames });
560
- // React component render tracking: uppercase arrow function in .tsx/.jsx
561
- if (isReactFile && /^[A-Z]/.test(name)) {
562
- let lineNo = 1;
563
- for (let i = 0; i < match.index; i++) {
564
- if (source[i] === '\n')
565
- lineNo++;
566
- }
567
- // Determine props expression for arrow functions (no `arguments` object)
568
- let propsExpr = 'undefined';
569
- if (arrowParamMatch) {
570
- const rawParams = (arrowParamMatch[1] || '').trim();
571
- if (!rawParams) {
572
- // No params: `() => {}`
573
- propsExpr = 'undefined';
574
- }
575
- else if (rawParams.startsWith('{')) {
576
- // Destructured: ({ name, age }) => {} — reconstruct object from field names
577
- // Strip TS type annotation (e.g. `{a, b}: Props` → `{a, b}`)
578
- // Find the matching `}` to isolate just the destructuring pattern
579
- let depth2 = 0;
580
- let endBrace = -1;
581
- for (let i = 0; i < rawParams.length; i++) {
582
- if (rawParams[i] === '{')
583
- depth2++;
584
- else if (rawParams[i] === '}') {
585
- depth2--;
586
- if (depth2 === 0) {
587
- endBrace = i;
588
- break;
589
- }
584
+ // Helper to build propsExpr from arrowParamMatch
585
+ const buildPropsExpr = () => {
586
+ if (!arrowParamMatch)
587
+ return 'undefined';
588
+ const rawParams = (arrowParamMatch[1] || '').trim();
589
+ if (!rawParams)
590
+ return 'undefined';
591
+ if (rawParams.startsWith('{')) {
592
+ let depth2 = 0, endBrace = -1;
593
+ for (let i = 0; i < rawParams.length; i++) {
594
+ if (rawParams[i] === '{')
595
+ depth2++;
596
+ else if (rawParams[i] === '}') {
597
+ depth2--;
598
+ if (depth2 === 0) {
599
+ endBrace = i;
600
+ break;
590
601
  }
591
602
  }
592
- const destructPattern = endBrace !== -1 ? rawParams.slice(0, endBrace + 1) : rawParams;
593
- const fields = extractDestructuredNames(destructPattern);
594
- if (fields.length > 0) {
595
- propsExpr = `{ ${fields.join(', ')} }`;
596
- }
597
603
  }
598
- else if (arrowParamMatch[2]) {
599
- // Single simple param (no parens): `props => {}`
600
- propsExpr = arrowParamMatch[2];
604
+ const destructPattern = endBrace !== -1 ? rawParams.slice(0, endBrace + 1) : rawParams;
605
+ const fields = extractDestructuredNames(destructPattern);
606
+ return fields.length > 0 ? `{ ${fields.join(', ')} }` : 'undefined';
607
+ }
608
+ else if (arrowParamMatch[2]) {
609
+ return arrowParamMatch[2];
610
+ }
611
+ else if (paramNames.length === 1) {
612
+ return paramNames[0];
613
+ }
614
+ return 'undefined';
615
+ };
616
+ if (isConcise) {
617
+ // Concise body: `const X = (props) => (<div/>)` — no block body
618
+ // Only add render tracking for React components (uppercase names in .tsx/.jsx)
619
+ if (isReactFile && /^[A-Z]/.test(name)) {
620
+ const closeParen = findMatchingParen(source, bodyStartPos);
621
+ if (closeParen === -1)
622
+ continue;
623
+ let lineNo = 1;
624
+ for (let i = 0; i < match.index; i++) {
625
+ if (source[i] === '\n')
626
+ lineNo++;
601
627
  }
602
- else if (paramNames.length === 1) {
603
- // Single simple param: `(props) => {}`
604
- propsExpr = paramNames[0];
628
+ conciseBodyInsertions.push({ beforeParen: bodyStartPos, afterCloseParen: closeParen + 1, name, lineNo, propsExpr: buildPropsExpr() });
629
+ }
630
+ }
631
+ else {
632
+ // Block body: `const X = (props) => { ... }`
633
+ const openBrace = bodyStartPos;
634
+ const closeBrace = findClosingBrace(source, openBrace);
635
+ if (closeBrace === -1)
636
+ continue;
637
+ funcInsertions.push({ position: closeBrace + 1, name, paramNames });
638
+ // React component render tracking: uppercase arrow function in .tsx/.jsx
639
+ if (isReactFile && /^[A-Z]/.test(name)) {
640
+ let lineNo = 1;
641
+ for (let i = 0; i < match.index; i++) {
642
+ if (source[i] === '\n')
643
+ lineNo++;
605
644
  }
645
+ bodyInsertions.push({ position: openBrace + 1, name, lineNo, propsExpr: buildPropsExpr() });
606
646
  }
607
- bodyInsertions.push({ position: openBrace + 1, name, lineNo, propsExpr });
608
647
  }
609
648
  }
610
649
  // Match React.memo() and React.forwardRef() wrapped components
@@ -817,7 +856,7 @@ export function transformEsmSource(source, filename, moduleName, backendUrl, deb
817
856
  const varInsertions = traceVars ? findVarDeclarations(source) : [];
818
857
  // Find destructured variable declarations for tracing
819
858
  const destructInsertions = traceVars ? findDestructuredDeclarations(source) : [];
820
- if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && bodyInsertions.length === 0 && hookInsertions.length === 0 && stateInsertions.length === 0)
859
+ if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && bodyInsertions.length === 0 && hookInsertions.length === 0 && stateInsertions.length === 0 && conciseBodyInsertions.length === 0)
821
860
  return source;
822
861
  // Fix line numbers: Vite transforms (TypeScript stripping) may change line numbers.
823
862
  // Map transformed line numbers to original source line numbers.
@@ -842,7 +881,7 @@ export function transformEsmSource(source, filename, moduleName, backendUrl, deb
842
881
  const importLines = [
843
882
  `import { wrapFunction as __trickle_wrapFn, configure as __trickle_configure } from 'trickle-observe';`,
844
883
  ];
845
- if (varInsertions.length > 0 || destructInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0 || stateInsertions.length > 0) {
884
+ if (varInsertions.length > 0 || destructInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0 || stateInsertions.length > 0 || conciseBodyInsertions.length > 0) {
846
885
  importLines.push(`import { mkdirSync as __trickle_mkdirSync, appendFileSync as __trickle_appendFileSync } from 'node:fs';`, `import { join as __trickle_join } from 'node:path';`);
847
886
  }
848
887
  const prefixLines = [
@@ -870,7 +909,7 @@ export function transformEsmSource(source, filename, moduleName, backendUrl, deb
870
909
  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) {} }`);
871
910
  }
872
911
  // Add React component render tracker if needed
873
- if (bodyInsertions.length > 0) {
912
+ if (bodyInsertions.length > 0 || conciseBodyInsertions.length > 0) {
874
913
  prefixLines.push(`if (!globalThis.__trickle_react_renders) { globalThis.__trickle_react_renders = new Map(); }`, `if (!globalThis.__trickle_react_prev_props) { globalThis.__trickle_react_prev_props = new Map(); }`, `function __trickle_rc(name, line, props) {`, ` try {`, ` const key = ${JSON.stringify(filename)} + ':' + line;`, ` const count = (globalThis.__trickle_react_renders.get(key) || 0) + 1;`, ` globalThis.__trickle_react_renders.set(key, count);`, ` const dir = process.env.TRICKLE_LOCAL_DIR || __trickle_join(process.cwd(), '.trickle');`, ` try { __trickle_mkdirSync(dir, { recursive: true }); } catch(e) {}`, ` const f = __trickle_join(dir, 'variables.jsonl');`, ` const rec = { kind: 'react_render', file: ${JSON.stringify(filename)}, line: line, component: name, renderCount: count, timestamp: Date.now() / 1000 };`, ` if (props !== undefined && props !== null && typeof props === 'object') {`, ` try {`, ` const propKeys = Object.keys(props).filter(k => k !== 'children');`, ` const propSample = {};`, ` for (const k of propKeys.slice(0, 10)) {`, ` const v = props[k];`, ` const t = typeof v;`, ` if (t === 'string') propSample[k] = v.length > 40 ? v.slice(0, 40) + '...' : v;`, ` else if (t === 'number' || t === 'boolean') propSample[k] = v;`, ` else if (v === null || v === undefined) propSample[k] = v;`, ` else if (Array.isArray(v)) propSample[k] = '[arr:' + v.length + ']';`, ` else if (t === 'function') propSample[k] = '[fn]';`, ` else propSample[k] = '[object]';`, ` }`, ` rec.props = propSample;`, ` rec.propKeys = propKeys;`, ` // Detect which props changed vs previous render`, ` const prevProps = globalThis.__trickle_react_prev_props.get(key);`, ` if (prevProps && count > 1) {`, ` const changedProps = [];`, ` const allKeys = new Set([...Object.keys(prevProps), ...Object.keys(propSample)]);`, ` for (const k of allKeys) {`, ` const prev = prevProps[k];`, ` const curr = propSample[k];`, ` if (String(prev) !== String(curr)) {`, ` changedProps.push({ key: k, from: prev, to: curr });`, ` }`, ` }`, ` if (changedProps.length > 0) rec.changedProps = changedProps;`, ` }`, ` globalThis.__trickle_react_prev_props.set(key, propSample);`, ` } catch(e2) {}`, ` }`, ` __trickle_appendFileSync(f, JSON.stringify(rec) + '\\n');`, ` } catch(e) {}`, `}`);
875
914
  }
876
915
  // Add React hook tracker if needed
@@ -925,6 +964,18 @@ export function transformEsmSource(source, filename, moduleName, backendUrl, deb
925
964
  code: `const ${setterName}=__trickle_ss(${JSON.stringify(stateName)},${lineNo},__trickle_s_${setterName});\n`,
926
965
  });
927
966
  }
967
+ // Concise arrow body insertions: convert `=> (expr)` to `=> { try{__trickle_rc(...)} return (expr); }`
968
+ // Two insertions per component: one before `(`, one after matching `)`
969
+ for (const { beforeParen, afterCloseParen, name, lineNo, propsExpr } of conciseBodyInsertions) {
970
+ allInsertions.push({
971
+ position: beforeParen,
972
+ code: `{ try{__trickle_rc(${JSON.stringify(name)},${lineNo},${propsExpr})}catch(__e){} return `,
973
+ });
974
+ allInsertions.push({
975
+ position: afterCloseParen,
976
+ code: `\n}`,
977
+ });
978
+ }
928
979
  // Sort by position descending (insert from end to preserve earlier positions)
929
980
  allInsertions.sort((a, b) => b.position - a.position);
930
981
  let result = source;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trickle-observe",
3
- "version": "0.2.64",
3
+ "version": "0.2.65",
4
4
  "description": "Runtime type observability for JavaScript applications",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -156,3 +156,30 @@ describe('Next.js: does not instrument non-React files', () => {
156
156
  assert.ok(!out.includes('__trickle_ss'), 'should not track useState in .ts files');
157
157
  });
158
158
  });
159
+
160
+ describe('Next.js: concise arrow body (=> (...)) tracking', () => {
161
+ it('tracks a concise arrow component (common in Next.js layouts)', () => {
162
+ const code = [
163
+ `const Layout = ({ children, title = "Default" }: Props) => (`,
164
+ ` <div>`,
165
+ ` <main>{children}</main>`,
166
+ ` </div>`,
167
+ `);`,
168
+ ].join('\n');
169
+ const out = transformNextTsx(code);
170
+ assert.ok(out.includes('__trickle_rc'), 'should track concise arrow component in Next.js');
171
+ });
172
+
173
+ it('tracks a simple presentational component with concise body', () => {
174
+ const code = `const Badge = ({ label }: { label: string }) => (<span className="badge">{label}</span>);`;
175
+ const out = transformNextTsx(code);
176
+ assert.ok(out.includes('__trickle_rc'), 'should track concise arrow Badge component');
177
+ });
178
+
179
+ it('converted concise body includes return statement', () => {
180
+ const code = `const Hero = (props) => (<section>{props.title}</section>);`;
181
+ const out = transformNextTsx(code);
182
+ assert.ok(out.includes('return '), 'converted block body should have return statement');
183
+ assert.ok(out.includes('__trickle_rc'), 'should inject render tracker');
184
+ });
185
+ });
@@ -404,3 +404,59 @@ describe('React hook observability', () => {
404
404
  assert.ok(out!.includes('__trickle_hw'), 'should inject hook wrapper for function() {} form');
405
405
  });
406
406
  });
407
+
408
+ describe('Concise arrow body tracking (=> (...))', () => {
409
+ it('tracks a simple concise arrow component', () => {
410
+ const code = `const Layout = ({ children }) => (\n <div>{children}</div>\n);`;
411
+ const out = transformTsx(code);
412
+ assert.ok(out, 'should transform');
413
+ assert.ok(out!.includes('__trickle_rc'), 'should inject render tracker for concise arrow body');
414
+ });
415
+
416
+ it('tracks a typed concise arrow component (React.FC)', () => {
417
+ const code = `const Header: React.FC<{ title: string }> = ({ title }) => (\n <header>{title}</header>\n);`;
418
+ const out = transformTsx(code);
419
+ assert.ok(out, 'should transform');
420
+ assert.ok(out!.includes('__trickle_rc'), 'should inject render tracker for React.FC concise arrow');
421
+ });
422
+
423
+ it('wraps concise body with block body containing return statement', () => {
424
+ const code = `const Card = (props) => (<div>{props.name}</div>);`;
425
+ const out = transformTsx(code);
426
+ assert.ok(out, 'should transform');
427
+ assert.ok(out!.includes('__trickle_rc'), 'should inject render tracker');
428
+ assert.ok(out!.includes('return '), 'converted block body should have return statement');
429
+ });
430
+
431
+ it('does NOT track lowercase concise arrow (non-component)', () => {
432
+ const code = `const renderItem = (item) => (<span>{item}</span>);`;
433
+ const out = transformTsx(code);
434
+ // lowercase name - should not be tracked as component
435
+ if (out) {
436
+ assert.ok(!out.includes('__trickle_rc'), 'should NOT inject render tracker for lowercase concise arrow');
437
+ }
438
+ });
439
+
440
+ it('tracks Next.js Layout component with concise arrow body (real-world pattern)', () => {
441
+ const code = [
442
+ `const Layout = ({ children, title = "Default title" }: Props) => (`,
443
+ ` <div>`,
444
+ ` <Head><title>{title}</title></Head>`,
445
+ ` <main>{children}</main>`,
446
+ ` </div>`,
447
+ `);`,
448
+ ].join('\n');
449
+ const out = transformTsx(code);
450
+ assert.ok(out, 'should transform');
451
+ assert.ok(out!.includes('__trickle_rc'), 'should track Layout component (real Next.js pattern)');
452
+ });
453
+
454
+ it('does NOT track concise arrow in .ts files', () => {
455
+ const plugin = tricklePlugin({ debug: false, traceVars: false });
456
+ const code = `const Layout = ({ children }) => (<div>{children}</div>);`;
457
+ const result = plugin.transform(code, '/test/layout.ts');
458
+ if (result) {
459
+ assert.ok(!result.code.includes('__trickle_rc'), 'should NOT inject render tracker in .ts file');
460
+ }
461
+ });
462
+ });
@@ -189,6 +189,31 @@ function findClosingBrace(source: string, openBrace: number): number {
189
189
  return -1;
190
190
  }
191
191
 
192
+ /**
193
+ * Find the matching closing paren for an opening paren at openParen.
194
+ * JSX-safe version: counts `(` and `)` depth, handles JSX curly expressions,
195
+ * but does NOT treat `'` as a string delimiter (apostrophes in JSX text content
196
+ * like `I'm` would incorrectly consume parens). Double-quoted strings inside
197
+ * JSX attr values typically contain balanced parens so can be ignored safely.
198
+ */
199
+ function findMatchingParen(source: string, openParen: number): number {
200
+ let depth = 1;
201
+ let pos = openParen + 1;
202
+ while (pos < source.length && depth > 0) {
203
+ const ch = source[pos];
204
+ if (ch === '(') {
205
+ depth++;
206
+ } else if (ch === ')') {
207
+ depth--;
208
+ if (depth === 0) return pos;
209
+ }
210
+ // Skip JSX expression blocks {expr} — not parens but skip curly content to avoid
211
+ // false paren matches inside template expressions
212
+ pos++;
213
+ }
214
+ return -1;
215
+ }
216
+
192
217
  /**
193
218
  * Find variable declarations in source and return insertions for tracing.
194
219
  * Handles: const x = ...; let x = ...; var x = ...;
@@ -538,12 +563,16 @@ export function transformEsmSource(
538
563
 
539
564
  // Also match arrow functions assigned to const/let/var
540
565
  // Handles: const X = () => {}, const X: React.FC = () => {}, const X: React.FC<Props> = ({ a }) => {}
541
- const arrowRegex = /^[ \t]*(?:export\s+)?(?:const|let|var)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)(?:\s*:\s*[^=]+?)?\s*=\s*(?:async\s+)?(?:\([^)]*\)|[a-zA-Z_$][a-zA-Z0-9_$]*)\s*(?::\s*[^=]+?)?\s*=>\s*\{/gm;
566
+ // Also handles concise bodies: const X = (props) => (<div/>)
567
+ const arrowRegex = /^[ \t]*(?:export\s+)?(?:const|let|var)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)(?:\s*:\s*[^=]+?)?\s*=\s*(?:async\s+)?(?:\([^)]*\)|[a-zA-Z_$][a-zA-Z0-9_$]*)\s*(?::\s*[^=]+?)?\s*=>\s*(?:\{|\()/gm;
568
+
569
+ // Concise body insertions: for `=> (expr)`, wrap with block body for render tracking
570
+ const conciseBodyInsertions: Array<{ beforeParen: number; afterCloseParen: number; name: string; lineNo: number; propsExpr: string }> = [];
542
571
 
543
572
  while ((match = arrowRegex.exec(source)) !== null) {
544
573
  const name = match[1];
545
- const openBrace = source.indexOf('{', match.index + match[0].length - 1);
546
- if (openBrace === -1) continue;
574
+ const bodyStartPos = match.index + match[0].length - 1;
575
+ const isConcise = source[bodyStartPos] === '(';
547
576
 
548
577
  const arrowStr = match[0];
549
578
  const arrowParamMatch = arrowStr.match(/=\s*(?:async\s+)?(?:\(([^)]*)\)|([a-zA-Z_$][a-zA-Z0-9_$]*))\s*(?::\s*[^=]+?)?\s*=>/);
@@ -559,50 +588,60 @@ export function transformEsmSource(
559
588
  }
560
589
  }
561
590
 
562
- const closeBrace = findClosingBrace(source, openBrace);
563
- if (closeBrace === -1) continue;
564
-
565
- funcInsertions.push({ position: closeBrace + 1, name, paramNames });
591
+ // Helper to build propsExpr from arrowParamMatch
592
+ const buildPropsExpr = () => {
593
+ if (!arrowParamMatch) return 'undefined';
594
+ const rawParams = (arrowParamMatch[1] || '').trim();
595
+ if (!rawParams) return 'undefined';
596
+ if (rawParams.startsWith('{')) {
597
+ let depth2 = 0, endBrace = -1;
598
+ for (let i = 0; i < rawParams.length; i++) {
599
+ if (rawParams[i] === '{') depth2++;
600
+ else if (rawParams[i] === '}') { depth2--; if (depth2 === 0) { endBrace = i; break; } }
601
+ }
602
+ const destructPattern = endBrace !== -1 ? rawParams.slice(0, endBrace + 1) : rawParams;
603
+ const fields = extractDestructuredNames(destructPattern);
604
+ return fields.length > 0 ? `{ ${fields.join(', ')} }` : 'undefined';
605
+ } else if (arrowParamMatch[2]) {
606
+ return arrowParamMatch[2];
607
+ } else if (paramNames.length === 1) {
608
+ return paramNames[0];
609
+ }
610
+ return 'undefined';
611
+ };
612
+
613
+ if (isConcise) {
614
+ // Concise body: `const X = (props) => (<div/>)` — no block body
615
+ // Only add render tracking for React components (uppercase names in .tsx/.jsx)
616
+ if (isReactFile && /^[A-Z]/.test(name)) {
617
+ const closeParen = findMatchingParen(source, bodyStartPos);
618
+ if (closeParen === -1) continue;
619
+
620
+ let lineNo = 1;
621
+ for (let i = 0; i < match.index; i++) {
622
+ if (source[i] === '\n') lineNo++;
623
+ }
566
624
 
567
- // React component render tracking: uppercase arrow function in .tsx/.jsx
568
- if (isReactFile && /^[A-Z]/.test(name)) {
569
- let lineNo = 1;
570
- for (let i = 0; i < match.index; i++) {
571
- if (source[i] === '\n') lineNo++;
625
+ conciseBodyInsertions.push({ beforeParen: bodyStartPos, afterCloseParen: closeParen + 1, name, lineNo, propsExpr: buildPropsExpr() });
572
626
  }
627
+ } else {
628
+ // Block body: `const X = (props) => { ... }`
629
+ const openBrace = bodyStartPos;
573
630
 
574
- // Determine props expression for arrow functions (no `arguments` object)
575
- let propsExpr = 'undefined';
576
- if (arrowParamMatch) {
577
- const rawParams = (arrowParamMatch[1] || '').trim();
578
- if (!rawParams) {
579
- // No params: `() => {}`
580
- propsExpr = 'undefined';
581
- } else if (rawParams.startsWith('{')) {
582
- // Destructured: ({ name, age }) => {} reconstruct object from field names
583
- // Strip TS type annotation (e.g. `{a, b}: Props` → `{a, b}`)
584
- // Find the matching `}` to isolate just the destructuring pattern
585
- let depth2 = 0;
586
- let endBrace = -1;
587
- for (let i = 0; i < rawParams.length; i++) {
588
- if (rawParams[i] === '{') depth2++;
589
- else if (rawParams[i] === '}') { depth2--; if (depth2 === 0) { endBrace = i; break; } }
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
- } else if (arrowParamMatch[2]) {
597
- // Single simple param (no parens): `props => {}`
598
- propsExpr = arrowParamMatch[2];
599
- } else if (paramNames.length === 1) {
600
- // Single simple param: `(props) => {}`
601
- propsExpr = paramNames[0];
631
+ const closeBrace = findClosingBrace(source, openBrace);
632
+ if (closeBrace === -1) continue;
633
+
634
+ funcInsertions.push({ position: closeBrace + 1, name, paramNames });
635
+
636
+ // React component render tracking: uppercase arrow function in .tsx/.jsx
637
+ if (isReactFile && /^[A-Z]/.test(name)) {
638
+ let lineNo = 1;
639
+ for (let i = 0; i < match.index; i++) {
640
+ if (source[i] === '\n') lineNo++;
602
641
  }
603
- }
604
642
 
605
- bodyInsertions.push({ position: openBrace + 1, name, lineNo, propsExpr });
643
+ bodyInsertions.push({ position: openBrace + 1, name, lineNo, propsExpr: buildPropsExpr() });
644
+ }
606
645
  }
607
646
  }
608
647
 
@@ -814,7 +853,7 @@ export function transformEsmSource(
814
853
  // Find destructured variable declarations for tracing
815
854
  const destructInsertions = traceVars ? findDestructuredDeclarations(source) : [];
816
855
 
817
- if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && bodyInsertions.length === 0 && hookInsertions.length === 0 && stateInsertions.length === 0) return source;
856
+ if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && bodyInsertions.length === 0 && hookInsertions.length === 0 && stateInsertions.length === 0 && conciseBodyInsertions.length === 0) return source;
818
857
 
819
858
  // Fix line numbers: Vite transforms (TypeScript stripping) may change line numbers.
820
859
  // Map transformed line numbers to original source line numbers.
@@ -839,7 +878,7 @@ export function transformEsmSource(
839
878
  const importLines: string[] = [
840
879
  `import { wrapFunction as __trickle_wrapFn, configure as __trickle_configure } from 'trickle-observe';`,
841
880
  ];
842
- if (varInsertions.length > 0 || destructInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0 || stateInsertions.length > 0) {
881
+ if (varInsertions.length > 0 || destructInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0 || stateInsertions.length > 0 || conciseBodyInsertions.length > 0) {
843
882
  importLines.push(
844
883
  `import { mkdirSync as __trickle_mkdirSync, appendFileSync as __trickle_appendFileSync } from 'node:fs';`,
845
884
  `import { join as __trickle_join } from 'node:path';`,
@@ -923,7 +962,7 @@ export function transformEsmSource(
923
962
  }
924
963
 
925
964
  // Add React component render tracker if needed
926
- if (bodyInsertions.length > 0) {
965
+ if (bodyInsertions.length > 0 || conciseBodyInsertions.length > 0) {
927
966
  prefixLines.push(
928
967
  `if (!globalThis.__trickle_react_renders) { globalThis.__trickle_react_renders = new Map(); }`,
929
968
  `if (!globalThis.__trickle_react_prev_props) { globalThis.__trickle_react_prev_props = new Map(); }`,
@@ -1079,6 +1118,19 @@ export function transformEsmSource(
1079
1118
  });
1080
1119
  }
1081
1120
 
1121
+ // Concise arrow body insertions: convert `=> (expr)` to `=> { try{__trickle_rc(...)} return (expr); }`
1122
+ // Two insertions per component: one before `(`, one after matching `)`
1123
+ for (const { beforeParen, afterCloseParen, name, lineNo, propsExpr } of conciseBodyInsertions) {
1124
+ allInsertions.push({
1125
+ position: beforeParen,
1126
+ code: `{ try{__trickle_rc(${JSON.stringify(name)},${lineNo},${propsExpr})}catch(__e){} return `,
1127
+ });
1128
+ allInsertions.push({
1129
+ position: afterCloseParen,
1130
+ code: `\n}`,
1131
+ });
1132
+ }
1133
+
1082
1134
  // Sort by position descending (insert from end to preserve earlier positions)
1083
1135
  allInsertions.sort((a, b) => b.position - a.position);
1084
1136