trickle-observe 0.2.76 → 0.2.78

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.
@@ -629,6 +629,150 @@ function findCatchVars(source) {
629
629
  }
630
630
  return results;
631
631
  }
632
+ /**
633
+ * Find simple JSX text expressions and return insertions for tracing.
634
+ * Only traces simple expressions in text content positions (after > or between tags):
635
+ * <p>{count}</p> → trace count
636
+ * <span>{user.name}</span> → trace user.name
637
+ * <div>{a + b}</div> → trace a + b (simple binary)
638
+ * <p>{x ? 'a' : 'b'}</p> → trace ternary
639
+ * Skips: attribute expressions, .map() calls, JSX elements, spread, complex calls.
640
+ * Uses comma operator: {(__trickle_tv(expr, name, line), expr)} — safe, returns original value.
641
+ */
642
+ function findJsxExpressions(source) {
643
+ const results = [];
644
+ // Find JSX text expressions: characters `>` followed (with optional whitespace/text) by `{expr}`
645
+ // We look for `{` that follows `>` (possibly with whitespace or text between)
646
+ // and is NOT preceded by `=` (which would be an attribute value)
647
+ const jsxExprRegex = /\{/g;
648
+ let match;
649
+ while ((match = jsxExprRegex.exec(source)) !== null) {
650
+ const bracePos = match.index;
651
+ // Skip if inside a string or comment
652
+ // Simple check: look at the character before `{`
653
+ const charBefore = bracePos > 0 ? source[bracePos - 1] : '';
654
+ // Skip if this is an attribute value: preceded by `=` (with optional whitespace)
655
+ const beforeSlice = source.slice(Math.max(0, bracePos - 5), bracePos).trimEnd();
656
+ if (beforeSlice.endsWith('='))
657
+ continue;
658
+ // Skip if this looks like a template literal expression `${`
659
+ if (charBefore === '$')
660
+ continue;
661
+ // Skip if preceded by `,` (function arguments, not JSX)
662
+ if (charBefore === ',')
663
+ continue;
664
+ // Must be in a JSX context: look backward for `>` or `}` (closing tag bracket or prev expression)
665
+ // before hitting structural JS characters like `{`, `(`, `;`
666
+ let inJsx = false;
667
+ let scanPos = bracePos - 1;
668
+ while (scanPos >= 0) {
669
+ const ch = source[scanPos];
670
+ if (ch === '>') {
671
+ inJsx = true;
672
+ break;
673
+ }
674
+ if (ch === '}') {
675
+ inJsx = true;
676
+ break;
677
+ } // After a previous JSX expression
678
+ if (ch === '{' || ch === ';')
679
+ break;
680
+ // `(` breaks scan in code context, but in JSX text `(` is normal
681
+ // Check: if `(` is preceded by `>` or text, it's JSX text
682
+ if (ch === '(') {
683
+ const before = source.slice(Math.max(0, scanPos - 20), scanPos).trim();
684
+ if (before.endsWith('>') || /[a-zA-Z0-9\s]$/.test(before)) {
685
+ // Could be JSX text like "Users ({count})" — keep scanning
686
+ scanPos--;
687
+ continue;
688
+ }
689
+ break;
690
+ }
691
+ // `=` only breaks if NOT preceded by other text (could be JSX text like "count = 5")
692
+ if (ch === '=' && scanPos > 0 && /\s/.test(source[scanPos - 1])) {
693
+ // Check if this `=` is a JSX attribute assignment: look further back for tag
694
+ const attrCheck = source.slice(Math.max(0, scanPos - 30), scanPos).trim();
695
+ if (/^[a-zA-Z]/.test(attrCheck))
696
+ break; // Likely an attribute
697
+ }
698
+ if (ch === '\n') {
699
+ // Check context of previous lines
700
+ const lineAbove = source.slice(Math.max(0, scanPos - 100), scanPos);
701
+ if (/<|>|\}/.test(lineAbove)) {
702
+ inJsx = true;
703
+ break;
704
+ }
705
+ break;
706
+ }
707
+ scanPos--;
708
+ }
709
+ if (!inJsx)
710
+ continue;
711
+ // Find the matching closing `}` for this expression
712
+ let depth = 1;
713
+ let pos = bracePos + 1;
714
+ while (pos < source.length && depth > 0) {
715
+ const ch = source[pos];
716
+ if (ch === '{')
717
+ depth++;
718
+ else if (ch === '}')
719
+ depth--;
720
+ else if (ch === '"' || ch === "'" || ch === '`') {
721
+ const q = ch;
722
+ pos++;
723
+ while (pos < source.length && source[pos] !== q) {
724
+ if (source[pos] === '\\')
725
+ pos++;
726
+ pos++;
727
+ }
728
+ }
729
+ pos++;
730
+ }
731
+ if (depth !== 0)
732
+ continue;
733
+ const exprEnd = pos - 1; // position of closing `}`
734
+ const exprText = source.slice(bracePos + 1, exprEnd).trim();
735
+ // Skip empty expressions
736
+ if (!exprText)
737
+ continue;
738
+ // Skip complex expressions that we don't want to trace:
739
+ // - JSX elements: contains `<` (could be a component)
740
+ // - .map/.filter/.reduce calls (return arrays of elements)
741
+ // - Spread: starts with `...`
742
+ // - Arrow functions: contains `=>`
743
+ // - Already traced: starts with `__trickle`
744
+ if (exprText.includes('<') || exprText.includes('=>'))
745
+ continue;
746
+ if (exprText.startsWith('...'))
747
+ continue;
748
+ if (exprText.startsWith('__trickle'))
749
+ continue;
750
+ if (/\.(map|filter|reduce|forEach|flatMap)\s*\(/.test(exprText))
751
+ continue;
752
+ // Skip function calls with parens (complex expressions) — but allow property access
753
+ // Allow: user.name, count, x ? 'a' : 'b', a + b
754
+ // Skip: fn(), obj.method(), Component()
755
+ if (/\w\s*\(/.test(exprText) && !exprText.includes('?'))
756
+ continue;
757
+ // Only trace if it's a "simple" expression:
758
+ // - Identifier: count, name
759
+ // - Property access: user.name, item.price.formatted
760
+ // - Simple ternary: x ? 'a' : 'b'
761
+ // - Simple arithmetic: count * 2, price + tax
762
+ const isSimple = /^[a-zA-Z_$][a-zA-Z0-9_$]*(\.[a-zA-Z_$][a-zA-Z0-9_$]*)*$/.test(exprText) || // identifier/property
763
+ /^[a-zA-Z_$]/.test(exprText); // starts with identifier (covers ternary, arithmetic)
764
+ if (!isSimple)
765
+ continue;
766
+ // Calculate line number
767
+ let lineNo = 1;
768
+ for (let i = 0; i < bracePos; i++) {
769
+ if (source[i] === '\n')
770
+ lineNo++;
771
+ }
772
+ results.push({ exprStart: bracePos + 1, exprEnd, exprText, lineNo });
773
+ }
774
+ return results;
775
+ }
632
776
  function findForLoopVars(source) {
633
777
  const results = [];
634
778
  // Match: for (const/let/var ...
@@ -1260,7 +1404,9 @@ ingestUrl) {
1260
1404
  const catchInsertions = traceVars ? findCatchVars(source) : [];
1261
1405
  // Find function parameter names for tracing
1262
1406
  const funcParamInsertions = traceVars ? findFunctionParams(source, isReactFile) : [];
1263
- if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && reassignInsertions.length === 0 && forLoopInsertions.length === 0 && catchInsertions.length === 0 && funcParamInsertions.length === 0 && bodyInsertions.length === 0 && hookInsertions.length === 0 && stateInsertions.length === 0 && conciseBodyInsertions.length === 0)
1407
+ // Find JSX text expressions for tracing (React files only)
1408
+ const jsxExprInsertions = (traceVars && isReactFile) ? findJsxExpressions(source) : [];
1409
+ if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && reassignInsertions.length === 0 && forLoopInsertions.length === 0 && catchInsertions.length === 0 && funcParamInsertions.length === 0 && jsxExprInsertions.length === 0 && bodyInsertions.length === 0 && hookInsertions.length === 0 && stateInsertions.length === 0 && conciseBodyInsertions.length === 0)
1264
1410
  return source;
1265
1411
  // Fix line numbers: Vite transforms (TypeScript stripping) may change line numbers.
1266
1412
  // Map transformed line numbers to original source line numbers.
@@ -1329,7 +1475,7 @@ ingestUrl) {
1329
1475
  }
1330
1476
  }
1331
1477
  // Build prefix — ALL imports first (ESM requires imports before any statements)
1332
- const needsTracing = varInsertions.length > 0 || destructInsertions.length > 0 || reassignInsertions.length > 0 || forLoopInsertions.length > 0 || catchInsertions.length > 0 || funcParamInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0 || stateInsertions.length > 0 || conciseBodyInsertions.length > 0;
1478
+ const needsTracing = varInsertions.length > 0 || destructInsertions.length > 0 || reassignInsertions.length > 0 || forLoopInsertions.length > 0 || catchInsertions.length > 0 || funcParamInsertions.length > 0 || jsxExprInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0 || stateInsertions.length > 0 || conciseBodyInsertions.length > 0;
1333
1479
  const importLines = [];
1334
1480
  if (isSSR) {
1335
1481
  // SSR/Node.js — import trickle-observe for function wrapping + file system for writing
@@ -1364,7 +1510,7 @@ ingestUrl) {
1364
1510
  }
1365
1511
  }
1366
1512
  // Add variable tracing if needed — inlined to avoid import resolution issues in Vite SSR.
1367
- if (varInsertions.length > 0 || destructInsertions.length > 0 || reassignInsertions.length > 0 || forLoopInsertions.length > 0 || catchInsertions.length > 0 || funcParamInsertions.length > 0) {
1513
+ if (varInsertions.length > 0 || destructInsertions.length > 0 || reassignInsertions.length > 0 || forLoopInsertions.length > 0 || catchInsertions.length > 0 || funcParamInsertions.length > 0 || jsxExprInsertions.length > 0) {
1368
1514
  prefixLines.push(`if (!globalThis.__trickle_var_tracer) {`, ` const _cache = new Map();`, ` 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 {`, ` const type = _inferType(v, 3);`, ` const th = JSON.stringify(type).substring(0, 32);`, ` const sample = _sanitize(v, 2);`, ` const sv = typeof v === 'object' && v !== null ? JSON.stringify(sample).substring(0, 60) : String(v).substring(0, 60);`, ` const ck = file + ':' + l + ':' + n;`, ` const prev = _cache.get(ck);`, ` const now = Date.now();`, ` if (prev && prev.sv === sv && now - prev.ts < 5000) return;`, ` _cache.set(ck, { sv: sv, ts: now });`, ` __trickle_send(JSON.stringify({ kind: 'variable', varName: n, line: l, module: mod, file: file, type: type, typeHash: th, sample: sample }));`, ` } catch(e) {}`, ` };`, `}`, `function __trickle_tv(v, n, l) { try { globalThis.__trickle_var_tracer(v, n, l, ${JSON.stringify(moduleName)}, ${JSON.stringify(filename)}); } catch(e) {} }`);
1369
1515
  }
1370
1516
  // Add React component render tracker if needed
@@ -1433,6 +1579,20 @@ ingestUrl) {
1433
1579
  code: `\ntry{${calls}}catch(__e){}\n`,
1434
1580
  });
1435
1581
  }
1582
+ // JSX expression insertions: wrap with comma operator to trace without changing value
1583
+ // {expr} → {(__trickle_tv(expr, "expr", lineNo), expr)}
1584
+ for (const { exprStart, exprEnd, exprText, lineNo } of jsxExprInsertions) {
1585
+ // Use a display name: truncate long expressions, use the raw text
1586
+ const displayName = exprText.length > 30 ? exprText.slice(0, 27) + '...' : exprText;
1587
+ allInsertions.push({
1588
+ position: exprStart,
1589
+ code: `(__trickle_tv(`,
1590
+ });
1591
+ allInsertions.push({
1592
+ position: exprEnd,
1593
+ code: `,${JSON.stringify(displayName)},${lineNo}),${exprText})`,
1594
+ });
1595
+ }
1436
1596
  for (const { position, name, lineNo, propsExpr } of bodyInsertions) {
1437
1597
  allInsertions.push({
1438
1598
  position,
@@ -622,6 +622,150 @@ function findCatchVars(source) {
622
622
  }
623
623
  return results;
624
624
  }
625
+ /**
626
+ * Find simple JSX text expressions and return insertions for tracing.
627
+ * Only traces simple expressions in text content positions (after > or between tags):
628
+ * <p>{count}</p> → trace count
629
+ * <span>{user.name}</span> → trace user.name
630
+ * <div>{a + b}</div> → trace a + b (simple binary)
631
+ * <p>{x ? 'a' : 'b'}</p> → trace ternary
632
+ * Skips: attribute expressions, .map() calls, JSX elements, spread, complex calls.
633
+ * Uses comma operator: {(__trickle_tv(expr, name, line), expr)} — safe, returns original value.
634
+ */
635
+ function findJsxExpressions(source) {
636
+ const results = [];
637
+ // Find JSX text expressions: characters `>` followed (with optional whitespace/text) by `{expr}`
638
+ // We look for `{` that follows `>` (possibly with whitespace or text between)
639
+ // and is NOT preceded by `=` (which would be an attribute value)
640
+ const jsxExprRegex = /\{/g;
641
+ let match;
642
+ while ((match = jsxExprRegex.exec(source)) !== null) {
643
+ const bracePos = match.index;
644
+ // Skip if inside a string or comment
645
+ // Simple check: look at the character before `{`
646
+ const charBefore = bracePos > 0 ? source[bracePos - 1] : '';
647
+ // Skip if this is an attribute value: preceded by `=` (with optional whitespace)
648
+ const beforeSlice = source.slice(Math.max(0, bracePos - 5), bracePos).trimEnd();
649
+ if (beforeSlice.endsWith('='))
650
+ continue;
651
+ // Skip if this looks like a template literal expression `${`
652
+ if (charBefore === '$')
653
+ continue;
654
+ // Skip if preceded by `,` (function arguments, not JSX)
655
+ if (charBefore === ',')
656
+ continue;
657
+ // Must be in a JSX context: look backward for `>` or `}` (closing tag bracket or prev expression)
658
+ // before hitting structural JS characters like `{`, `(`, `;`
659
+ let inJsx = false;
660
+ let scanPos = bracePos - 1;
661
+ while (scanPos >= 0) {
662
+ const ch = source[scanPos];
663
+ if (ch === '>') {
664
+ inJsx = true;
665
+ break;
666
+ }
667
+ if (ch === '}') {
668
+ inJsx = true;
669
+ break;
670
+ } // After a previous JSX expression
671
+ if (ch === '{' || ch === ';')
672
+ break;
673
+ // `(` breaks scan in code context, but in JSX text `(` is normal
674
+ // Check: if `(` is preceded by `>` or text, it's JSX text
675
+ if (ch === '(') {
676
+ const before = source.slice(Math.max(0, scanPos - 20), scanPos).trim();
677
+ if (before.endsWith('>') || /[a-zA-Z0-9\s]$/.test(before)) {
678
+ // Could be JSX text like "Users ({count})" — keep scanning
679
+ scanPos--;
680
+ continue;
681
+ }
682
+ break;
683
+ }
684
+ // `=` only breaks if NOT preceded by other text (could be JSX text like "count = 5")
685
+ if (ch === '=' && scanPos > 0 && /\s/.test(source[scanPos - 1])) {
686
+ // Check if this `=` is a JSX attribute assignment: look further back for tag
687
+ const attrCheck = source.slice(Math.max(0, scanPos - 30), scanPos).trim();
688
+ if (/^[a-zA-Z]/.test(attrCheck))
689
+ break; // Likely an attribute
690
+ }
691
+ if (ch === '\n') {
692
+ // Check context of previous lines
693
+ const lineAbove = source.slice(Math.max(0, scanPos - 100), scanPos);
694
+ if (/<|>|\}/.test(lineAbove)) {
695
+ inJsx = true;
696
+ break;
697
+ }
698
+ break;
699
+ }
700
+ scanPos--;
701
+ }
702
+ if (!inJsx)
703
+ continue;
704
+ // Find the matching closing `}` for this expression
705
+ let depth = 1;
706
+ let pos = bracePos + 1;
707
+ while (pos < source.length && depth > 0) {
708
+ const ch = source[pos];
709
+ if (ch === '{')
710
+ depth++;
711
+ else if (ch === '}')
712
+ depth--;
713
+ else if (ch === '"' || ch === "'" || ch === '`') {
714
+ const q = ch;
715
+ pos++;
716
+ while (pos < source.length && source[pos] !== q) {
717
+ if (source[pos] === '\\')
718
+ pos++;
719
+ pos++;
720
+ }
721
+ }
722
+ pos++;
723
+ }
724
+ if (depth !== 0)
725
+ continue;
726
+ const exprEnd = pos - 1; // position of closing `}`
727
+ const exprText = source.slice(bracePos + 1, exprEnd).trim();
728
+ // Skip empty expressions
729
+ if (!exprText)
730
+ continue;
731
+ // Skip complex expressions that we don't want to trace:
732
+ // - JSX elements: contains `<` (could be a component)
733
+ // - .map/.filter/.reduce calls (return arrays of elements)
734
+ // - Spread: starts with `...`
735
+ // - Arrow functions: contains `=>`
736
+ // - Already traced: starts with `__trickle`
737
+ if (exprText.includes('<') || exprText.includes('=>'))
738
+ continue;
739
+ if (exprText.startsWith('...'))
740
+ continue;
741
+ if (exprText.startsWith('__trickle'))
742
+ continue;
743
+ if (/\.(map|filter|reduce|forEach|flatMap)\s*\(/.test(exprText))
744
+ continue;
745
+ // Skip function calls with parens (complex expressions) — but allow property access
746
+ // Allow: user.name, count, x ? 'a' : 'b', a + b
747
+ // Skip: fn(), obj.method(), Component()
748
+ if (/\w\s*\(/.test(exprText) && !exprText.includes('?'))
749
+ continue;
750
+ // Only trace if it's a "simple" expression:
751
+ // - Identifier: count, name
752
+ // - Property access: user.name, item.price.formatted
753
+ // - Simple ternary: x ? 'a' : 'b'
754
+ // - Simple arithmetic: count * 2, price + tax
755
+ const isSimple = /^[a-zA-Z_$][a-zA-Z0-9_$]*(\.[a-zA-Z_$][a-zA-Z0-9_$]*)*$/.test(exprText) || // identifier/property
756
+ /^[a-zA-Z_$]/.test(exprText); // starts with identifier (covers ternary, arithmetic)
757
+ if (!isSimple)
758
+ continue;
759
+ // Calculate line number
760
+ let lineNo = 1;
761
+ for (let i = 0; i < bracePos; i++) {
762
+ if (source[i] === '\n')
763
+ lineNo++;
764
+ }
765
+ results.push({ exprStart: bracePos + 1, exprEnd, exprText, lineNo });
766
+ }
767
+ return results;
768
+ }
625
769
  function findForLoopVars(source) {
626
770
  const results = [];
627
771
  // Match: for (const/let/var ...
@@ -1253,7 +1397,9 @@ ingestUrl) {
1253
1397
  const catchInsertions = traceVars ? findCatchVars(source) : [];
1254
1398
  // Find function parameter names for tracing
1255
1399
  const funcParamInsertions = traceVars ? findFunctionParams(source, isReactFile) : [];
1256
- if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && reassignInsertions.length === 0 && forLoopInsertions.length === 0 && catchInsertions.length === 0 && funcParamInsertions.length === 0 && bodyInsertions.length === 0 && hookInsertions.length === 0 && stateInsertions.length === 0 && conciseBodyInsertions.length === 0)
1400
+ // Find JSX text expressions for tracing (React files only)
1401
+ const jsxExprInsertions = (traceVars && isReactFile) ? findJsxExpressions(source) : [];
1402
+ if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && reassignInsertions.length === 0 && forLoopInsertions.length === 0 && catchInsertions.length === 0 && funcParamInsertions.length === 0 && jsxExprInsertions.length === 0 && bodyInsertions.length === 0 && hookInsertions.length === 0 && stateInsertions.length === 0 && conciseBodyInsertions.length === 0)
1257
1403
  return source;
1258
1404
  // Fix line numbers: Vite transforms (TypeScript stripping) may change line numbers.
1259
1405
  // Map transformed line numbers to original source line numbers.
@@ -1322,7 +1468,7 @@ ingestUrl) {
1322
1468
  }
1323
1469
  }
1324
1470
  // Build prefix — ALL imports first (ESM requires imports before any statements)
1325
- const needsTracing = varInsertions.length > 0 || destructInsertions.length > 0 || reassignInsertions.length > 0 || forLoopInsertions.length > 0 || catchInsertions.length > 0 || funcParamInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0 || stateInsertions.length > 0 || conciseBodyInsertions.length > 0;
1471
+ const needsTracing = varInsertions.length > 0 || destructInsertions.length > 0 || reassignInsertions.length > 0 || forLoopInsertions.length > 0 || catchInsertions.length > 0 || funcParamInsertions.length > 0 || jsxExprInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0 || stateInsertions.length > 0 || conciseBodyInsertions.length > 0;
1326
1472
  const importLines = [];
1327
1473
  if (isSSR) {
1328
1474
  // SSR/Node.js — import trickle-observe for function wrapping + file system for writing
@@ -1357,7 +1503,7 @@ ingestUrl) {
1357
1503
  }
1358
1504
  }
1359
1505
  // Add variable tracing if needed — inlined to avoid import resolution issues in Vite SSR.
1360
- if (varInsertions.length > 0 || destructInsertions.length > 0 || reassignInsertions.length > 0 || forLoopInsertions.length > 0 || catchInsertions.length > 0 || funcParamInsertions.length > 0) {
1506
+ if (varInsertions.length > 0 || destructInsertions.length > 0 || reassignInsertions.length > 0 || forLoopInsertions.length > 0 || catchInsertions.length > 0 || funcParamInsertions.length > 0 || jsxExprInsertions.length > 0) {
1361
1507
  prefixLines.push(`if (!globalThis.__trickle_var_tracer) {`, ` const _cache = new Map();`, ` 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 {`, ` const type = _inferType(v, 3);`, ` const th = JSON.stringify(type).substring(0, 32);`, ` const sample = _sanitize(v, 2);`, ` const sv = typeof v === 'object' && v !== null ? JSON.stringify(sample).substring(0, 60) : String(v).substring(0, 60);`, ` const ck = file + ':' + l + ':' + n;`, ` const prev = _cache.get(ck);`, ` const now = Date.now();`, ` if (prev && prev.sv === sv && now - prev.ts < 5000) return;`, ` _cache.set(ck, { sv: sv, ts: now });`, ` __trickle_send(JSON.stringify({ kind: 'variable', varName: n, line: l, module: mod, file: file, type: type, typeHash: th, sample: sample }));`, ` } catch(e) {}`, ` };`, `}`, `function __trickle_tv(v, n, l) { try { globalThis.__trickle_var_tracer(v, n, l, ${JSON.stringify(moduleName)}, ${JSON.stringify(filename)}); } catch(e) {} }`);
1362
1508
  }
1363
1509
  // Add React component render tracker if needed
@@ -1426,6 +1572,20 @@ ingestUrl) {
1426
1572
  code: `\ntry{${calls}}catch(__e){}\n`,
1427
1573
  });
1428
1574
  }
1575
+ // JSX expression insertions: wrap with comma operator to trace without changing value
1576
+ // {expr} → {(__trickle_tv(expr, "expr", lineNo), expr)}
1577
+ for (const { exprStart, exprEnd, exprText, lineNo } of jsxExprInsertions) {
1578
+ // Use a display name: truncate long expressions, use the raw text
1579
+ const displayName = exprText.length > 30 ? exprText.slice(0, 27) + '...' : exprText;
1580
+ allInsertions.push({
1581
+ position: exprStart,
1582
+ code: `(__trickle_tv(`,
1583
+ });
1584
+ allInsertions.push({
1585
+ position: exprEnd,
1586
+ code: `,${JSON.stringify(displayName)},${lineNo}),${exprText})`,
1587
+ });
1588
+ }
1429
1589
  for (const { position, name, lineNo, propsExpr } of bodyInsertions) {
1430
1590
  allInsertions.push({
1431
1591
  position,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trickle-observe",
3
- "version": "0.2.76",
3
+ "version": "0.2.78",
4
4
  "description": "Runtime type observability for JavaScript applications",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -599,6 +599,138 @@ function findCatchVars(source: string): Array<{ bodyStart: number; varNames: str
599
599
  return results;
600
600
  }
601
601
 
602
+ /**
603
+ * Find simple JSX text expressions and return insertions for tracing.
604
+ * Only traces simple expressions in text content positions (after > or between tags):
605
+ * <p>{count}</p> → trace count
606
+ * <span>{user.name}</span> → trace user.name
607
+ * <div>{a + b}</div> → trace a + b (simple binary)
608
+ * <p>{x ? 'a' : 'b'}</p> → trace ternary
609
+ * Skips: attribute expressions, .map() calls, JSX elements, spread, complex calls.
610
+ * Uses comma operator: {(__trickle_tv(expr, name, line), expr)} — safe, returns original value.
611
+ */
612
+ function findJsxExpressions(source: string): Array<{ exprStart: number; exprEnd: number; exprText: string; lineNo: number }> {
613
+ const results: Array<{ exprStart: number; exprEnd: number; exprText: string; lineNo: number }> = [];
614
+
615
+ // Find JSX text expressions: characters `>` followed (with optional whitespace/text) by `{expr}`
616
+ // We look for `{` that follows `>` (possibly with whitespace or text between)
617
+ // and is NOT preceded by `=` (which would be an attribute value)
618
+ const jsxExprRegex = /\{/g;
619
+ let match;
620
+
621
+ while ((match = jsxExprRegex.exec(source)) !== null) {
622
+ const bracePos = match.index;
623
+
624
+ // Skip if inside a string or comment
625
+ // Simple check: look at the character before `{`
626
+ const charBefore = bracePos > 0 ? source[bracePos - 1] : '';
627
+
628
+ // Skip if this is an attribute value: preceded by `=` (with optional whitespace)
629
+ const beforeSlice = source.slice(Math.max(0, bracePos - 5), bracePos).trimEnd();
630
+ if (beforeSlice.endsWith('=')) continue;
631
+
632
+ // Skip if this looks like a template literal expression `${`
633
+ if (charBefore === '$') continue;
634
+
635
+ // Skip if preceded by `,` (function arguments, not JSX)
636
+ if (charBefore === ',') continue;
637
+
638
+ // Must be in a JSX context: look backward for `>` or `}` (closing tag bracket or prev expression)
639
+ // before hitting structural JS characters like `{`, `(`, `;`
640
+ let inJsx = false;
641
+ let scanPos = bracePos - 1;
642
+ while (scanPos >= 0) {
643
+ const ch = source[scanPos];
644
+ if (ch === '>') { inJsx = true; break; }
645
+ if (ch === '}') { inJsx = true; break; } // After a previous JSX expression
646
+ if (ch === '{' || ch === ';') break;
647
+ // `(` breaks scan in code context, but in JSX text `(` is normal
648
+ // Check: if `(` is preceded by `>` or text, it's JSX text
649
+ if (ch === '(') {
650
+ const before = source.slice(Math.max(0, scanPos - 20), scanPos).trim();
651
+ if (before.endsWith('>') || /[a-zA-Z0-9\s]$/.test(before)) {
652
+ // Could be JSX text like "Users ({count})" — keep scanning
653
+ scanPos--;
654
+ continue;
655
+ }
656
+ break;
657
+ }
658
+ // `=` only breaks if NOT preceded by other text (could be JSX text like "count = 5")
659
+ if (ch === '=' && scanPos > 0 && /\s/.test(source[scanPos - 1])) {
660
+ // Check if this `=` is a JSX attribute assignment: look further back for tag
661
+ const attrCheck = source.slice(Math.max(0, scanPos - 30), scanPos).trim();
662
+ if (/^[a-zA-Z]/.test(attrCheck)) break; // Likely an attribute
663
+ }
664
+ if (ch === '\n') {
665
+ // Check context of previous lines
666
+ const lineAbove = source.slice(Math.max(0, scanPos - 100), scanPos);
667
+ if (/<|>|\}/.test(lineAbove)) { inJsx = true; break; }
668
+ break;
669
+ }
670
+ scanPos--;
671
+ }
672
+ if (!inJsx) continue;
673
+
674
+ // Find the matching closing `}` for this expression
675
+ let depth = 1;
676
+ let pos = bracePos + 1;
677
+ while (pos < source.length && depth > 0) {
678
+ const ch = source[pos];
679
+ if (ch === '{') depth++;
680
+ else if (ch === '}') depth--;
681
+ else if (ch === '"' || ch === "'" || ch === '`') {
682
+ const q = ch; pos++;
683
+ while (pos < source.length && source[pos] !== q) {
684
+ if (source[pos] === '\\') pos++;
685
+ pos++;
686
+ }
687
+ }
688
+ pos++;
689
+ }
690
+ if (depth !== 0) continue;
691
+
692
+ const exprEnd = pos - 1; // position of closing `}`
693
+ const exprText = source.slice(bracePos + 1, exprEnd).trim();
694
+
695
+ // Skip empty expressions
696
+ if (!exprText) continue;
697
+
698
+ // Skip complex expressions that we don't want to trace:
699
+ // - JSX elements: contains `<` (could be a component)
700
+ // - .map/.filter/.reduce calls (return arrays of elements)
701
+ // - Spread: starts with `...`
702
+ // - Arrow functions: contains `=>`
703
+ // - Already traced: starts with `__trickle`
704
+ if (exprText.includes('<') || exprText.includes('=>')) continue;
705
+ if (exprText.startsWith('...')) continue;
706
+ if (exprText.startsWith('__trickle')) continue;
707
+ if (/\.(map|filter|reduce|forEach|flatMap)\s*\(/.test(exprText)) continue;
708
+ // Skip function calls with parens (complex expressions) — but allow property access
709
+ // Allow: user.name, count, x ? 'a' : 'b', a + b
710
+ // Skip: fn(), obj.method(), Component()
711
+ if (/\w\s*\(/.test(exprText) && !exprText.includes('?')) continue;
712
+
713
+ // Only trace if it's a "simple" expression:
714
+ // - Identifier: count, name
715
+ // - Property access: user.name, item.price.formatted
716
+ // - Simple ternary: x ? 'a' : 'b'
717
+ // - Simple arithmetic: count * 2, price + tax
718
+ const isSimple = /^[a-zA-Z_$][a-zA-Z0-9_$]*(\.[a-zA-Z_$][a-zA-Z0-9_$]*)*$/.test(exprText) || // identifier/property
719
+ /^[a-zA-Z_$]/.test(exprText); // starts with identifier (covers ternary, arithmetic)
720
+ if (!isSimple) continue;
721
+
722
+ // Calculate line number
723
+ let lineNo = 1;
724
+ for (let i = 0; i < bracePos; i++) {
725
+ if (source[i] === '\n') lineNo++;
726
+ }
727
+
728
+ results.push({ exprStart: bracePos + 1, exprEnd, exprText, lineNo });
729
+ }
730
+
731
+ return results;
732
+ }
733
+
602
734
  function findForLoopVars(source: string): Array<{ bodyStart: number; varNames: string[]; lineNo: number }> {
603
735
  const results: Array<{ bodyStart: number; varNames: string[]; lineNo: number }> = [];
604
736
 
@@ -1250,7 +1382,10 @@ export function transformEsmSource(
1250
1382
  // Find function parameter names for tracing
1251
1383
  const funcParamInsertions = traceVars ? findFunctionParams(source, isReactFile) : [];
1252
1384
 
1253
- if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && reassignInsertions.length === 0 && forLoopInsertions.length === 0 && catchInsertions.length === 0 && funcParamInsertions.length === 0 && bodyInsertions.length === 0 && hookInsertions.length === 0 && stateInsertions.length === 0 && conciseBodyInsertions.length === 0) return source;
1385
+ // Find JSX text expressions for tracing (React files only)
1386
+ const jsxExprInsertions = (traceVars && isReactFile) ? findJsxExpressions(source) : [];
1387
+
1388
+ if (funcInsertions.length === 0 && varInsertions.length === 0 && destructInsertions.length === 0 && reassignInsertions.length === 0 && forLoopInsertions.length === 0 && catchInsertions.length === 0 && funcParamInsertions.length === 0 && jsxExprInsertions.length === 0 && bodyInsertions.length === 0 && hookInsertions.length === 0 && stateInsertions.length === 0 && conciseBodyInsertions.length === 0) return source;
1254
1389
 
1255
1390
  // Fix line numbers: Vite transforms (TypeScript stripping) may change line numbers.
1256
1391
  // Map transformed line numbers to original source line numbers.
@@ -1318,7 +1453,7 @@ export function transformEsmSource(
1318
1453
  }
1319
1454
 
1320
1455
  // Build prefix — ALL imports first (ESM requires imports before any statements)
1321
- const needsTracing = varInsertions.length > 0 || destructInsertions.length > 0 || reassignInsertions.length > 0 || forLoopInsertions.length > 0 || catchInsertions.length > 0 || funcParamInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0 || stateInsertions.length > 0 || conciseBodyInsertions.length > 0;
1456
+ const needsTracing = varInsertions.length > 0 || destructInsertions.length > 0 || reassignInsertions.length > 0 || forLoopInsertions.length > 0 || catchInsertions.length > 0 || funcParamInsertions.length > 0 || jsxExprInsertions.length > 0 || bodyInsertions.length > 0 || hookInsertions.length > 0 || stateInsertions.length > 0 || conciseBodyInsertions.length > 0;
1322
1457
  const importLines: string[] = [];
1323
1458
 
1324
1459
  if (isSSR) {
@@ -1420,7 +1555,7 @@ export function transformEsmSource(
1420
1555
  }
1421
1556
 
1422
1557
  // Add variable tracing if needed — inlined to avoid import resolution issues in Vite SSR.
1423
- if (varInsertions.length > 0 || destructInsertions.length > 0 || reassignInsertions.length > 0 || forLoopInsertions.length > 0 || catchInsertions.length > 0 || funcParamInsertions.length > 0) {
1558
+ if (varInsertions.length > 0 || destructInsertions.length > 0 || reassignInsertions.length > 0 || forLoopInsertions.length > 0 || catchInsertions.length > 0 || funcParamInsertions.length > 0 || jsxExprInsertions.length > 0) {
1424
1559
  prefixLines.push(
1425
1560
  `if (!globalThis.__trickle_var_tracer) {`,
1426
1561
  ` const _cache = new Map();`,
@@ -1630,6 +1765,21 @@ export function transformEsmSource(
1630
1765
  });
1631
1766
  }
1632
1767
 
1768
+ // JSX expression insertions: wrap with comma operator to trace without changing value
1769
+ // {expr} → {(__trickle_tv(expr, "expr", lineNo), expr)}
1770
+ for (const { exprStart, exprEnd, exprText, lineNo } of jsxExprInsertions) {
1771
+ // Use a display name: truncate long expressions, use the raw text
1772
+ const displayName = exprText.length > 30 ? exprText.slice(0, 27) + '...' : exprText;
1773
+ allInsertions.push({
1774
+ position: exprStart,
1775
+ code: `(__trickle_tv(`,
1776
+ });
1777
+ allInsertions.push({
1778
+ position: exprEnd,
1779
+ code: `,${JSON.stringify(displayName)},${lineNo}),${exprText})`,
1780
+ });
1781
+ }
1782
+
1633
1783
  for (const { position, name, lineNo, propsExpr } of bodyInsertions) {
1634
1784
  allInsertions.push({
1635
1785
  position,