trickle-observe 0.2.61 → 0.2.63
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/metro-transformer.d.ts +28 -0
- package/dist/metro-transformer.js +67 -0
- package/dist/vite-plugin.d.ts +1 -0
- package/dist/vite-plugin.js +88 -3
- package/dist-esm/vite-plugin.js +88 -4
- package/package.json +4 -3
- package/src/metro-transformer.test.ts +148 -0
- package/src/metro-transformer.ts +75 -0
- package/src/vite-plugin.test.ts +42 -0
- package/src/vite-plugin.ts +77 -4
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* trickle/metro-transformer — Metro bundler transform for React Native observability.
|
|
3
|
+
*
|
|
4
|
+
* Drop-in Metro transformer that instruments React Native components with
|
|
5
|
+
* trickle's render tracking, useState change tracking, and hook observability —
|
|
6
|
+
* the same tracking trickle's Vite plugin provides for web React apps.
|
|
7
|
+
*
|
|
8
|
+
* Setup in metro.config.js:
|
|
9
|
+
*
|
|
10
|
+
* const { getDefaultConfig } = require('expo/metro-config');
|
|
11
|
+
* // or: const { getDefaultConfig } = require('@react-native/metro-config');
|
|
12
|
+
*
|
|
13
|
+
* const config = getDefaultConfig(__dirname);
|
|
14
|
+
* config.transformer.babelTransformerPath = require.resolve('trickle-observe/metro-transformer');
|
|
15
|
+
* module.exports = config;
|
|
16
|
+
*
|
|
17
|
+
* Environment variables:
|
|
18
|
+
* TRICKLE_BACKEND_URL — URL of your trickle backend (default: http://localhost:4888)
|
|
19
|
+
* For real device: use your machine's LAN IP, e.g. http://192.168.1.5:4888
|
|
20
|
+
* TRICKLE_DEBUG — Set to "1" for debug logging
|
|
21
|
+
*/
|
|
22
|
+
interface MetroTransformArgs {
|
|
23
|
+
src: string;
|
|
24
|
+
filename: string;
|
|
25
|
+
options: Record<string, unknown>;
|
|
26
|
+
}
|
|
27
|
+
export declare function transform({ src, filename, options }: MetroTransformArgs): Promise<any>;
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* trickle/metro-transformer — Metro bundler transform for React Native observability.
|
|
4
|
+
*
|
|
5
|
+
* Drop-in Metro transformer that instruments React Native components with
|
|
6
|
+
* trickle's render tracking, useState change tracking, and hook observability —
|
|
7
|
+
* the same tracking trickle's Vite plugin provides for web React apps.
|
|
8
|
+
*
|
|
9
|
+
* Setup in metro.config.js:
|
|
10
|
+
*
|
|
11
|
+
* const { getDefaultConfig } = require('expo/metro-config');
|
|
12
|
+
* // or: const { getDefaultConfig } = require('@react-native/metro-config');
|
|
13
|
+
*
|
|
14
|
+
* const config = getDefaultConfig(__dirname);
|
|
15
|
+
* config.transformer.babelTransformerPath = require.resolve('trickle-observe/metro-transformer');
|
|
16
|
+
* module.exports = config;
|
|
17
|
+
*
|
|
18
|
+
* Environment variables:
|
|
19
|
+
* TRICKLE_BACKEND_URL — URL of your trickle backend (default: http://localhost:4888)
|
|
20
|
+
* For real device: use your machine's LAN IP, e.g. http://192.168.1.5:4888
|
|
21
|
+
* TRICKLE_DEBUG — Set to "1" for debug logging
|
|
22
|
+
*/
|
|
23
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
24
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
25
|
+
};
|
|
26
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
27
|
+
exports.transform = transform;
|
|
28
|
+
const path_1 = __importDefault(require("path"));
|
|
29
|
+
const vite_plugin_1 = require("./vite-plugin");
|
|
30
|
+
// The upstream Babel transformer — try Expo first, fall back to bare RN
|
|
31
|
+
function getUpstreamTransformer() {
|
|
32
|
+
const candidates = [
|
|
33
|
+
'@expo/metro-config/babel-transformer',
|
|
34
|
+
'metro-react-native-babel-transformer',
|
|
35
|
+
];
|
|
36
|
+
for (const candidate of candidates) {
|
|
37
|
+
try {
|
|
38
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
39
|
+
return require(candidate);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// not installed, try next
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
throw new Error('[trickle/metro-transformer] Could not find a Metro Babel transformer. ' +
|
|
46
|
+
'Install either @expo/metro-config or metro-react-native-babel-transformer.');
|
|
47
|
+
}
|
|
48
|
+
const backendUrl = process.env.TRICKLE_BACKEND_URL ?? 'http://localhost:4888';
|
|
49
|
+
const debug = process.env.TRICKLE_DEBUG === '1';
|
|
50
|
+
async function transform({ src, filename, options }) {
|
|
51
|
+
const upstreamTransformer = getUpstreamTransformer();
|
|
52
|
+
// Only instrument React Native component files
|
|
53
|
+
const ext = path_1.default.extname(filename).toLowerCase();
|
|
54
|
+
const isReactFile = ext === '.tsx' || ext === '.jsx';
|
|
55
|
+
const isJsFile = ext === '.ts' || ext === '.js';
|
|
56
|
+
if ((isReactFile || isJsFile) && !filename.includes('node_modules') && !filename.includes('trickle-observe')) {
|
|
57
|
+
const moduleName = path_1.default.basename(filename).replace(/\.[jt]sx?$/, '');
|
|
58
|
+
const transformed = (0, vite_plugin_1.transformEsmSource)(src, filename, moduleName, backendUrl, debug, false);
|
|
59
|
+
if (transformed !== src) {
|
|
60
|
+
if (debug) {
|
|
61
|
+
console.log(`[trickle/metro] Instrumented ${filename}`);
|
|
62
|
+
}
|
|
63
|
+
return upstreamTransformer.transform({ src: transformed, filename, options });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return upstreamTransformer.transform({ src, filename, options });
|
|
67
|
+
}
|
package/dist/vite-plugin.d.ts
CHANGED
|
@@ -37,4 +37,5 @@ export declare function tricklePlugin(options?: TricklePluginOptions): {
|
|
|
37
37
|
map: null;
|
|
38
38
|
} | null;
|
|
39
39
|
};
|
|
40
|
+
export declare function transformEsmSource(source: string, filename: string, moduleName: string, backendUrl: string, debug: boolean, traceVars: boolean, originalSource?: string | null): string;
|
|
40
41
|
export default tricklePlugin;
|
package/dist/vite-plugin.js
CHANGED
|
@@ -23,6 +23,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
23
23
|
};
|
|
24
24
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
25
25
|
exports.tricklePlugin = tricklePlugin;
|
|
26
|
+
exports.transformEsmSource = transformEsmSource;
|
|
26
27
|
const path_1 = __importDefault(require("path"));
|
|
27
28
|
const fs_1 = __importDefault(require("fs"));
|
|
28
29
|
function tricklePlugin(options = {}) {
|
|
@@ -496,8 +497,8 @@ function escapeRegexStr(str) {
|
|
|
496
497
|
function transformEsmSource(source, filename, moduleName, backendUrl, debug, traceVars, originalSource) {
|
|
497
498
|
// Detect React files for component render tracking
|
|
498
499
|
const isReactFile = /\.(tsx|jsx)$/.test(filename);
|
|
499
|
-
// Match top-level and nested function declarations (including async, export)
|
|
500
|
-
const funcRegex = /^[ \t]*(?:export\s+)?(?:async\s+)?function\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/gm;
|
|
500
|
+
// Match top-level and nested function declarations (including async, export, export default)
|
|
501
|
+
const funcRegex = /^[ \t]*(?:export\s+(?:default\s+)?)?(?:async\s+)?function\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/gm;
|
|
501
502
|
const funcInsertions = [];
|
|
502
503
|
// Body insertions: insert at start of function body (for React render tracking)
|
|
503
504
|
// propsExpr: JS expression to evaluate as the props object at render time
|
|
@@ -538,7 +539,8 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
|
|
|
538
539
|
}
|
|
539
540
|
}
|
|
540
541
|
// Also match arrow functions assigned to const/let/var
|
|
541
|
-
const
|
|
542
|
+
// 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;
|
|
542
544
|
while ((match = arrowRegex.exec(source)) !== null) {
|
|
543
545
|
const name = match[1];
|
|
544
546
|
const openBrace = source.indexOf('{', match.index + match[0].length - 1);
|
|
@@ -612,6 +614,89 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
|
|
|
612
614
|
bodyInsertions.push({ position: openBrace + 1, name, lineNo, propsExpr });
|
|
613
615
|
}
|
|
614
616
|
}
|
|
617
|
+
// Match React.memo() and React.forwardRef() wrapped components
|
|
618
|
+
// Pattern: const Name = (React.)?memo( or const Name = (React.)?forwardRef<T,P>(
|
|
619
|
+
// Then scan forward to find the inner arrow => { body
|
|
620
|
+
if (isReactFile) {
|
|
621
|
+
const memoRefRegex = /^[ \t]*(?:export\s+)?(?:const|let|var)\s+([A-Z][a-zA-Z0-9_$]*)(?:\s*:\s*[^=]+?)?\s*=\s*(?:React\.)?(?:memo|forwardRef)\s*(?:<[^(]*>)?\s*\(/gm;
|
|
622
|
+
let memoMatch;
|
|
623
|
+
while ((memoMatch = memoRefRegex.exec(source)) !== null) {
|
|
624
|
+
const name = memoMatch[1];
|
|
625
|
+
// Position after the opening `(` of memo/forwardRef call
|
|
626
|
+
const afterMemoOpen = memoMatch.index + memoMatch[0].length;
|
|
627
|
+
// Scan forward to find `=> {` — the arrow body of the inner function.
|
|
628
|
+
// We need to skip over the inner function's parameter list (which may contain nested parens).
|
|
629
|
+
// Strategy: find the next `=>` that is followed by optional whitespace and `{`.
|
|
630
|
+
let pos = afterMemoOpen;
|
|
631
|
+
let arrowPos = -1;
|
|
632
|
+
let parenDepth = 0;
|
|
633
|
+
while (pos < source.length - 1) {
|
|
634
|
+
const ch = source[pos];
|
|
635
|
+
if (ch === '(')
|
|
636
|
+
parenDepth++;
|
|
637
|
+
else if (ch === ')')
|
|
638
|
+
parenDepth--;
|
|
639
|
+
else if (ch === '=' && source[pos + 1] === '>' && parenDepth <= 0) {
|
|
640
|
+
arrowPos = pos;
|
|
641
|
+
break;
|
|
642
|
+
}
|
|
643
|
+
pos++;
|
|
644
|
+
}
|
|
645
|
+
if (arrowPos === -1)
|
|
646
|
+
continue;
|
|
647
|
+
// Skip `=>` and whitespace to find `{`
|
|
648
|
+
let bracePos = arrowPos + 2;
|
|
649
|
+
while (bracePos < source.length && /[\s]/.test(source[bracePos]))
|
|
650
|
+
bracePos++;
|
|
651
|
+
if (source[bracePos] !== '{')
|
|
652
|
+
continue;
|
|
653
|
+
const openBrace = bracePos;
|
|
654
|
+
const closeBrace = findClosingBrace(source, openBrace);
|
|
655
|
+
if (closeBrace === -1)
|
|
656
|
+
continue;
|
|
657
|
+
// Extract the param list: everything between memo( and arrowPos
|
|
658
|
+
const innerParamStr = source.slice(afterMemoOpen, arrowPos).trim();
|
|
659
|
+
// innerParamStr is like `({ item, onSelect })` or `(props, ref)` or `props`
|
|
660
|
+
let propsExpr = 'undefined';
|
|
661
|
+
if (innerParamStr.startsWith('(')) {
|
|
662
|
+
// Peel outer parens
|
|
663
|
+
const inner = innerParamStr.slice(1, innerParamStr.lastIndexOf(')')).trim();
|
|
664
|
+
if (inner.startsWith('{')) {
|
|
665
|
+
// Find the matching `}` of the destructuring pattern, ignoring any type annotation after it
|
|
666
|
+
let depth3 = 0, destructEnd = -1;
|
|
667
|
+
for (let i = 0; i < inner.length; i++) {
|
|
668
|
+
if (inner[i] === '{')
|
|
669
|
+
depth3++;
|
|
670
|
+
else if (inner[i] === '}') {
|
|
671
|
+
depth3--;
|
|
672
|
+
if (depth3 === 0) {
|
|
673
|
+
destructEnd = i;
|
|
674
|
+
break;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
const destructPart = destructEnd !== -1 ? inner.slice(0, destructEnd + 1) : inner;
|
|
679
|
+
const fields = extractDestructuredNames(destructPart);
|
|
680
|
+
if (fields.length > 0)
|
|
681
|
+
propsExpr = `{ ${fields.join(', ')} }`;
|
|
682
|
+
}
|
|
683
|
+
else {
|
|
684
|
+
const firstParam = inner.split(',')[0].trim().split(':')[0].trim();
|
|
685
|
+
if (firstParam)
|
|
686
|
+
propsExpr = firstParam;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
else if (innerParamStr && /^[a-zA-Z_$]/.test(innerParamStr)) {
|
|
690
|
+
propsExpr = innerParamStr.split(/[\s,:(]/)[0];
|
|
691
|
+
}
|
|
692
|
+
let lineNo = 1;
|
|
693
|
+
for (let i = 0; i < memoMatch.index; i++) {
|
|
694
|
+
if (source[i] === '\n')
|
|
695
|
+
lineNo++;
|
|
696
|
+
}
|
|
697
|
+
bodyInsertions.push({ position: openBrace + 1, name, lineNo, propsExpr });
|
|
698
|
+
}
|
|
699
|
+
}
|
|
615
700
|
const hookInsertions = [];
|
|
616
701
|
if (isReactFile) {
|
|
617
702
|
// Match useEffect(, useMemo(, useCallback( — also handles React.useEffect(, etc.
|
package/dist-esm/vite-plugin.js
CHANGED
|
@@ -487,11 +487,11 @@ function findOriginalLineDestructured(origLines, varNames, transformedLine) {
|
|
|
487
487
|
function escapeRegexStr(str) {
|
|
488
488
|
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
489
489
|
}
|
|
490
|
-
function transformEsmSource(source, filename, moduleName, backendUrl, debug, traceVars, originalSource) {
|
|
490
|
+
export function transformEsmSource(source, filename, moduleName, backendUrl, debug, traceVars, originalSource) {
|
|
491
491
|
// Detect React files for component render tracking
|
|
492
492
|
const isReactFile = /\.(tsx|jsx)$/.test(filename);
|
|
493
|
-
// Match top-level and nested function declarations (including async, export)
|
|
494
|
-
const funcRegex = /^[ \t]*(?:export\s+)?(?:async\s+)?function\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/gm;
|
|
493
|
+
// Match top-level and nested function declarations (including async, export, export default)
|
|
494
|
+
const funcRegex = /^[ \t]*(?:export\s+(?:default\s+)?)?(?:async\s+)?function\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/gm;
|
|
495
495
|
const funcInsertions = [];
|
|
496
496
|
// Body insertions: insert at start of function body (for React render tracking)
|
|
497
497
|
// propsExpr: JS expression to evaluate as the props object at render time
|
|
@@ -532,7 +532,8 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
|
|
|
532
532
|
}
|
|
533
533
|
}
|
|
534
534
|
// Also match arrow functions assigned to const/let/var
|
|
535
|
-
const
|
|
535
|
+
// 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;
|
|
536
537
|
while ((match = arrowRegex.exec(source)) !== null) {
|
|
537
538
|
const name = match[1];
|
|
538
539
|
const openBrace = source.indexOf('{', match.index + match[0].length - 1);
|
|
@@ -606,6 +607,89 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
|
|
|
606
607
|
bodyInsertions.push({ position: openBrace + 1, name, lineNo, propsExpr });
|
|
607
608
|
}
|
|
608
609
|
}
|
|
610
|
+
// Match React.memo() and React.forwardRef() wrapped components
|
|
611
|
+
// Pattern: const Name = (React.)?memo( or const Name = (React.)?forwardRef<T,P>(
|
|
612
|
+
// Then scan forward to find the inner arrow => { body
|
|
613
|
+
if (isReactFile) {
|
|
614
|
+
const memoRefRegex = /^[ \t]*(?:export\s+)?(?:const|let|var)\s+([A-Z][a-zA-Z0-9_$]*)(?:\s*:\s*[^=]+?)?\s*=\s*(?:React\.)?(?:memo|forwardRef)\s*(?:<[^(]*>)?\s*\(/gm;
|
|
615
|
+
let memoMatch;
|
|
616
|
+
while ((memoMatch = memoRefRegex.exec(source)) !== null) {
|
|
617
|
+
const name = memoMatch[1];
|
|
618
|
+
// Position after the opening `(` of memo/forwardRef call
|
|
619
|
+
const afterMemoOpen = memoMatch.index + memoMatch[0].length;
|
|
620
|
+
// Scan forward to find `=> {` — the arrow body of the inner function.
|
|
621
|
+
// We need to skip over the inner function's parameter list (which may contain nested parens).
|
|
622
|
+
// Strategy: find the next `=>` that is followed by optional whitespace and `{`.
|
|
623
|
+
let pos = afterMemoOpen;
|
|
624
|
+
let arrowPos = -1;
|
|
625
|
+
let parenDepth = 0;
|
|
626
|
+
while (pos < source.length - 1) {
|
|
627
|
+
const ch = source[pos];
|
|
628
|
+
if (ch === '(')
|
|
629
|
+
parenDepth++;
|
|
630
|
+
else if (ch === ')')
|
|
631
|
+
parenDepth--;
|
|
632
|
+
else if (ch === '=' && source[pos + 1] === '>' && parenDepth <= 0) {
|
|
633
|
+
arrowPos = pos;
|
|
634
|
+
break;
|
|
635
|
+
}
|
|
636
|
+
pos++;
|
|
637
|
+
}
|
|
638
|
+
if (arrowPos === -1)
|
|
639
|
+
continue;
|
|
640
|
+
// Skip `=>` and whitespace to find `{`
|
|
641
|
+
let bracePos = arrowPos + 2;
|
|
642
|
+
while (bracePos < source.length && /[\s]/.test(source[bracePos]))
|
|
643
|
+
bracePos++;
|
|
644
|
+
if (source[bracePos] !== '{')
|
|
645
|
+
continue;
|
|
646
|
+
const openBrace = bracePos;
|
|
647
|
+
const closeBrace = findClosingBrace(source, openBrace);
|
|
648
|
+
if (closeBrace === -1)
|
|
649
|
+
continue;
|
|
650
|
+
// Extract the param list: everything between memo( and arrowPos
|
|
651
|
+
const innerParamStr = source.slice(afterMemoOpen, arrowPos).trim();
|
|
652
|
+
// innerParamStr is like `({ item, onSelect })` or `(props, ref)` or `props`
|
|
653
|
+
let propsExpr = 'undefined';
|
|
654
|
+
if (innerParamStr.startsWith('(')) {
|
|
655
|
+
// Peel outer parens
|
|
656
|
+
const inner = innerParamStr.slice(1, innerParamStr.lastIndexOf(')')).trim();
|
|
657
|
+
if (inner.startsWith('{')) {
|
|
658
|
+
// Find the matching `}` of the destructuring pattern, ignoring any type annotation after it
|
|
659
|
+
let depth3 = 0, destructEnd = -1;
|
|
660
|
+
for (let i = 0; i < inner.length; i++) {
|
|
661
|
+
if (inner[i] === '{')
|
|
662
|
+
depth3++;
|
|
663
|
+
else if (inner[i] === '}') {
|
|
664
|
+
depth3--;
|
|
665
|
+
if (depth3 === 0) {
|
|
666
|
+
destructEnd = i;
|
|
667
|
+
break;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
const destructPart = destructEnd !== -1 ? inner.slice(0, destructEnd + 1) : inner;
|
|
672
|
+
const fields = extractDestructuredNames(destructPart);
|
|
673
|
+
if (fields.length > 0)
|
|
674
|
+
propsExpr = `{ ${fields.join(', ')} }`;
|
|
675
|
+
}
|
|
676
|
+
else {
|
|
677
|
+
const firstParam = inner.split(',')[0].trim().split(':')[0].trim();
|
|
678
|
+
if (firstParam)
|
|
679
|
+
propsExpr = firstParam;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
else if (innerParamStr && /^[a-zA-Z_$]/.test(innerParamStr)) {
|
|
683
|
+
propsExpr = innerParamStr.split(/[\s,:(]/)[0];
|
|
684
|
+
}
|
|
685
|
+
let lineNo = 1;
|
|
686
|
+
for (let i = 0; i < memoMatch.index; i++) {
|
|
687
|
+
if (source[i] === '\n')
|
|
688
|
+
lineNo++;
|
|
689
|
+
}
|
|
690
|
+
bodyInsertions.push({ position: openBrace + 1, name, lineNo, propsExpr });
|
|
691
|
+
}
|
|
692
|
+
}
|
|
609
693
|
const hookInsertions = [];
|
|
610
694
|
if (isReactFile) {
|
|
611
695
|
// Match useEffect(, useMemo(, useCallback( — also handles React.useEffect(, etc.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trickle-observe",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.63",
|
|
4
4
|
"description": "Runtime type observability for JavaScript applications",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -17,11 +17,12 @@
|
|
|
17
17
|
"require": "./dist/vite-plugin.js"
|
|
18
18
|
},
|
|
19
19
|
"./trace-var": "./dist/trace-var.js",
|
|
20
|
-
"./lambda": "./dist/lambda.js"
|
|
20
|
+
"./lambda": "./dist/lambda.js",
|
|
21
|
+
"./metro-transformer": "./dist/metro-transformer.js"
|
|
21
22
|
},
|
|
22
23
|
"scripts": {
|
|
23
24
|
"build": "tsc && tsc -p tsconfig.esm.json",
|
|
24
|
-
"test": "npm run build && node --experimental-strip-types --test src/vite-plugin.test.ts src/lambda.test.ts",
|
|
25
|
+
"test": "npm run build && node --experimental-strip-types --test src/vite-plugin.test.ts src/lambda.test.ts src/metro-transformer.test.ts",
|
|
25
26
|
"prepublishOnly": "npm run build"
|
|
26
27
|
},
|
|
27
28
|
"optionalDependencies": {
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the Metro transformer (React Native observability).
|
|
3
|
+
*
|
|
4
|
+
* Tests the transformation logic independently — without needing a real Metro
|
|
5
|
+
* bundler or Babel pipeline. We test that transformEsmSource is applied correctly
|
|
6
|
+
* to React Native component source files.
|
|
7
|
+
*
|
|
8
|
+
* Run with: node --experimental-strip-types --test src/metro-transformer.test.ts
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it } from 'node:test';
|
|
11
|
+
import assert from 'node:assert/strict';
|
|
12
|
+
import { transformEsmSource } from '../dist/vite-plugin.js';
|
|
13
|
+
|
|
14
|
+
const BACKEND_URL = 'http://localhost:4888';
|
|
15
|
+
|
|
16
|
+
// Helper: transform as if coming from a .tsx React Native file
|
|
17
|
+
function transformRNTsx(code: string, filename = '/app/components/MyComponent.tsx'): string {
|
|
18
|
+
return transformEsmSource(code, filename, 'MyComponent', BACKEND_URL, false, false);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Helper: transform as if coming from a .ts utility file
|
|
22
|
+
function transformRNTs(code: string, filename = '/app/utils/helper.ts'): string {
|
|
23
|
+
return transformEsmSource(code, filename, 'helper', BACKEND_URL, false, false);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ── Metro transformer: React component detection ──────────────────────────────
|
|
27
|
+
|
|
28
|
+
describe('Metro transformer: React component detection', () => {
|
|
29
|
+
it('instruments uppercase components in .tsx files', () => {
|
|
30
|
+
const code = `function OrderCard({ order }) { return null; }`;
|
|
31
|
+
const out = transformRNTsx(code);
|
|
32
|
+
assert.notEqual(out, code, 'should transform');
|
|
33
|
+
assert.ok(out.includes('__trickle_rc'), 'should inject render tracker');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('does not inject render tracker for .ts utility files', () => {
|
|
37
|
+
const code = `function formatPrice(amount) { return '$' + amount; }`;
|
|
38
|
+
const out = transformRNTs(code);
|
|
39
|
+
assert.ok(!out.includes('__trickle_rc'), 'should not inject render tracker in .ts files');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('instruments export default function components (common React Native screen pattern)', () => {
|
|
43
|
+
const code = `export default function HomeScreen() { return null; }`;
|
|
44
|
+
const out = transformRNTsx(code);
|
|
45
|
+
assert.ok(out.includes('__trickle_rc'), 'should inject render tracker for export default function');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('does not instrument lowercase utility functions as components', () => {
|
|
49
|
+
const code = `function formatOrder(order) { return order.id; }`;
|
|
50
|
+
const out = transformRNTsx(code);
|
|
51
|
+
if (out !== code) {
|
|
52
|
+
assert.ok(!out.includes('__trickle_rc'), 'lowercase function should not be tracked as component');
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// ── Metro transformer: useState tracking ─────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
describe('Metro transformer: useState tracking in React Native', () => {
|
|
60
|
+
it('tracks useState in a React Native functional component', () => {
|
|
61
|
+
const code = [
|
|
62
|
+
`import React, { useState } from 'react';`,
|
|
63
|
+
`function Counter() {`,
|
|
64
|
+
` const [count, setCount] = useState(0);`,
|
|
65
|
+
` return null;`,
|
|
66
|
+
`}`,
|
|
67
|
+
].join('\n');
|
|
68
|
+
const out = transformRNTsx(code);
|
|
69
|
+
assert.ok(out.includes('__trickle_ss'), 'should inject state setter wrapper');
|
|
70
|
+
assert.ok(out.includes('__trickle_s_setCount'), 'should rename original setter');
|
|
71
|
+
assert.ok(out.includes('"count"'), 'should include state variable name');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('tracks multiple useState calls in a React Native screen', () => {
|
|
75
|
+
const code = [
|
|
76
|
+
`function CheckoutScreen() {`,
|
|
77
|
+
` const [items, setItems] = useState([]);`,
|
|
78
|
+
` const [total, setTotal] = useState(0);`,
|
|
79
|
+
` const [loading, setLoading] = useState(false);`,
|
|
80
|
+
` return null;`,
|
|
81
|
+
`}`,
|
|
82
|
+
].join('\n');
|
|
83
|
+
const out = transformRNTsx(code);
|
|
84
|
+
const count = (out.match(/const \w+=__trickle_ss/g) || []).length;
|
|
85
|
+
assert.equal(count, 3, 'should wrap all 3 useState setters');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('handles TypeScript typed useState in React Native', () => {
|
|
89
|
+
const code = [
|
|
90
|
+
`function ProfileScreen() {`,
|
|
91
|
+
` const [user, setUser] = useState<User | null>(null);`,
|
|
92
|
+
` return null;`,
|
|
93
|
+
`}`,
|
|
94
|
+
].join('\n');
|
|
95
|
+
const out = transformRNTsx(code);
|
|
96
|
+
assert.ok(out.includes('__trickle_ss'), 'should track typed useState');
|
|
97
|
+
assert.ok(out.includes('"user"'), 'should include state name');
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// ── Metro transformer: hook observability ────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
describe('Metro transformer: hook observability in React Native', () => {
|
|
104
|
+
it('wraps useEffect in a React Native component', () => {
|
|
105
|
+
const code = [
|
|
106
|
+
`function DataScreen() {`,
|
|
107
|
+
` useEffect(() => {`,
|
|
108
|
+
` fetchData();`,
|
|
109
|
+
` }, []);`,
|
|
110
|
+
` return null;`,
|
|
111
|
+
`}`,
|
|
112
|
+
].join('\n');
|
|
113
|
+
const out = transformRNTsx(code);
|
|
114
|
+
assert.ok(out.includes('__trickle_hw'), 'should inject hook wrapper for useEffect');
|
|
115
|
+
assert.ok(out.includes('"useEffect"'), 'should record hook name');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('wraps useCallback in a React Native component', () => {
|
|
119
|
+
const code = [
|
|
120
|
+
`function ListScreen() {`,
|
|
121
|
+
` const handlePress = useCallback(() => {`,
|
|
122
|
+
` navigate('Detail');`,
|
|
123
|
+
` }, []);`,
|
|
124
|
+
` return null;`,
|
|
125
|
+
`}`,
|
|
126
|
+
].join('\n');
|
|
127
|
+
const out = transformRNTsx(code);
|
|
128
|
+
assert.ok(out.includes('__trickle_hw'), 'should inject hook wrapper for useCallback');
|
|
129
|
+
assert.ok(out.includes('"useCallback"'), 'should record hook name');
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// ── Metro transformer: source unchanged for non-RN files ─────────────────────
|
|
134
|
+
|
|
135
|
+
describe('Metro transformer: passthrough for non-component files', () => {
|
|
136
|
+
it('does not modify a plain TypeScript utility file', () => {
|
|
137
|
+
const code = `export function add(a: number, b: number): number { return a + b; }`;
|
|
138
|
+
const out = transformRNTs(code);
|
|
139
|
+
// .ts files should not get __trickle_rc (no React components)
|
|
140
|
+
assert.ok(!out.includes('__trickle_rc'), 'should not inject React tracking in .ts files');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('does not inject useState tracking in .ts files', () => {
|
|
144
|
+
const code = `function helper() {\n const [x, setX] = useState(0);\n return x;\n}`;
|
|
145
|
+
const out = transformRNTs(code);
|
|
146
|
+
assert.ok(!out.includes('__trickle_ss'), 'should not track useState in .ts files');
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* trickle/metro-transformer — Metro bundler transform for React Native observability.
|
|
3
|
+
*
|
|
4
|
+
* Drop-in Metro transformer that instruments React Native components with
|
|
5
|
+
* trickle's render tracking, useState change tracking, and hook observability —
|
|
6
|
+
* the same tracking trickle's Vite plugin provides for web React apps.
|
|
7
|
+
*
|
|
8
|
+
* Setup in metro.config.js:
|
|
9
|
+
*
|
|
10
|
+
* const { getDefaultConfig } = require('expo/metro-config');
|
|
11
|
+
* // or: const { getDefaultConfig } = require('@react-native/metro-config');
|
|
12
|
+
*
|
|
13
|
+
* const config = getDefaultConfig(__dirname);
|
|
14
|
+
* config.transformer.babelTransformerPath = require.resolve('trickle-observe/metro-transformer');
|
|
15
|
+
* module.exports = config;
|
|
16
|
+
*
|
|
17
|
+
* Environment variables:
|
|
18
|
+
* TRICKLE_BACKEND_URL — URL of your trickle backend (default: http://localhost:4888)
|
|
19
|
+
* For real device: use your machine's LAN IP, e.g. http://192.168.1.5:4888
|
|
20
|
+
* TRICKLE_DEBUG — Set to "1" for debug logging
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import path from 'path';
|
|
24
|
+
import { transformEsmSource } from './vite-plugin';
|
|
25
|
+
|
|
26
|
+
// The upstream Babel transformer — try Expo first, fall back to bare RN
|
|
27
|
+
function getUpstreamTransformer() {
|
|
28
|
+
const candidates = [
|
|
29
|
+
'@expo/metro-config/babel-transformer',
|
|
30
|
+
'metro-react-native-babel-transformer',
|
|
31
|
+
];
|
|
32
|
+
for (const candidate of candidates) {
|
|
33
|
+
try {
|
|
34
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
35
|
+
return require(candidate);
|
|
36
|
+
} catch {
|
|
37
|
+
// not installed, try next
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
throw new Error(
|
|
41
|
+
'[trickle/metro-transformer] Could not find a Metro Babel transformer. ' +
|
|
42
|
+
'Install either @expo/metro-config or metro-react-native-babel-transformer.',
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface MetroTransformArgs {
|
|
47
|
+
src: string;
|
|
48
|
+
filename: string;
|
|
49
|
+
options: Record<string, unknown>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const backendUrl = process.env.TRICKLE_BACKEND_URL ?? 'http://localhost:4888';
|
|
53
|
+
const debug = process.env.TRICKLE_DEBUG === '1';
|
|
54
|
+
|
|
55
|
+
export async function transform({ src, filename, options }: MetroTransformArgs) {
|
|
56
|
+
const upstreamTransformer = getUpstreamTransformer();
|
|
57
|
+
|
|
58
|
+
// Only instrument React Native component files
|
|
59
|
+
const ext = path.extname(filename).toLowerCase();
|
|
60
|
+
const isReactFile = ext === '.tsx' || ext === '.jsx';
|
|
61
|
+
const isJsFile = ext === '.ts' || ext === '.js';
|
|
62
|
+
|
|
63
|
+
if ((isReactFile || isJsFile) && !filename.includes('node_modules') && !filename.includes('trickle-observe')) {
|
|
64
|
+
const moduleName = path.basename(filename).replace(/\.[jt]sx?$/, '');
|
|
65
|
+
const transformed = transformEsmSource(src, filename, moduleName, backendUrl, debug, false);
|
|
66
|
+
if (transformed !== src) {
|
|
67
|
+
if (debug) {
|
|
68
|
+
console.log(`[trickle/metro] Instrumented ${filename}`);
|
|
69
|
+
}
|
|
70
|
+
return upstreamTransformer.transform({ src: transformed, filename, options });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return upstreamTransformer.transform({ src, filename, options });
|
|
75
|
+
}
|
package/src/vite-plugin.test.ts
CHANGED
|
@@ -40,6 +40,48 @@ describe('React file detection', () => {
|
|
|
40
40
|
}
|
|
41
41
|
});
|
|
42
42
|
|
|
43
|
+
it('tracks export default function components', () => {
|
|
44
|
+
const code = `export default function HomeScreen() { return null; }`;
|
|
45
|
+
const out = transformTsx(code);
|
|
46
|
+
assert.ok(out, 'should transform');
|
|
47
|
+
assert.ok(out!.includes('__trickle_rc'), 'export default function should be tracked as component');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('tracks React.FC typed arrow components', () => {
|
|
51
|
+
const code = `const UserCard: React.FC = () => { return null; }`;
|
|
52
|
+
const out = transformTsx(code);
|
|
53
|
+
assert.ok(out, 'should transform');
|
|
54
|
+
assert.ok(out!.includes('__trickle_rc'), 'React.FC arrow should be tracked');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('tracks React.FC<Props> typed arrow components', () => {
|
|
58
|
+
const code = `const UserCard: React.FC<Props> = ({ name }) => { return null; }`;
|
|
59
|
+
const out = transformTsx(code);
|
|
60
|
+
assert.ok(out, 'should transform');
|
|
61
|
+
assert.ok(out!.includes('__trickle_rc'), 'React.FC<Props> arrow should be tracked');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('tracks React.memo() wrapped components', () => {
|
|
65
|
+
const code = `const UserCard = React.memo(() => { return null; });`;
|
|
66
|
+
const out = transformTsx(code);
|
|
67
|
+
assert.ok(out, 'should transform');
|
|
68
|
+
assert.ok(out!.includes('__trickle_rc'), 'React.memo component should be tracked');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('tracks memo() wrapped components with destructured props', () => {
|
|
72
|
+
const code = `const UserCard = memo(({ name, age }) => { return null; });`;
|
|
73
|
+
const out = transformTsx(code);
|
|
74
|
+
assert.ok(out, 'should transform');
|
|
75
|
+
assert.ok(out!.includes('__trickle_rc'), 'memo() component should be tracked');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('tracks React.forwardRef() wrapped components', () => {
|
|
79
|
+
const code = `const UserCard = React.forwardRef<View, Props>((props, ref) => { return null; });`;
|
|
80
|
+
const out = transformTsx(code);
|
|
81
|
+
assert.ok(out, 'should transform');
|
|
82
|
+
assert.ok(out!.includes('__trickle_rc'), 'forwardRef component should be tracked');
|
|
83
|
+
});
|
|
84
|
+
|
|
43
85
|
it('does not track lowercase functions as components', () => {
|
|
44
86
|
const code = `function helper(x) { return x + 1; }`;
|
|
45
87
|
const out = transformTsx(code);
|
package/src/vite-plugin.ts
CHANGED
|
@@ -481,7 +481,7 @@ function escapeRegexStr(str: string): string {
|
|
|
481
481
|
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
482
482
|
}
|
|
483
483
|
|
|
484
|
-
function transformEsmSource(
|
|
484
|
+
export function transformEsmSource(
|
|
485
485
|
source: string,
|
|
486
486
|
filename: string,
|
|
487
487
|
moduleName: string,
|
|
@@ -493,8 +493,8 @@ function transformEsmSource(
|
|
|
493
493
|
// Detect React files for component render tracking
|
|
494
494
|
const isReactFile = /\.(tsx|jsx)$/.test(filename);
|
|
495
495
|
|
|
496
|
-
// Match top-level and nested function declarations (including async, export)
|
|
497
|
-
const funcRegex = /^[ \t]*(?:export\s+)?(?:async\s+)?function\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/gm;
|
|
496
|
+
// Match top-level and nested function declarations (including async, export, export default)
|
|
497
|
+
const funcRegex = /^[ \t]*(?:export\s+(?:default\s+)?)?(?:async\s+)?function\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*\(/gm;
|
|
498
498
|
const funcInsertions: Array<{ position: number; name: string; paramNames: string[] }> = [];
|
|
499
499
|
// Body insertions: insert at start of function body (for React render tracking)
|
|
500
500
|
// propsExpr: JS expression to evaluate as the props object at render time
|
|
@@ -537,7 +537,8 @@ function transformEsmSource(
|
|
|
537
537
|
}
|
|
538
538
|
|
|
539
539
|
// Also match arrow functions assigned to const/let/var
|
|
540
|
-
const
|
|
540
|
+
// 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;
|
|
541
542
|
|
|
542
543
|
while ((match = arrowRegex.exec(source)) !== null) {
|
|
543
544
|
const name = match[1];
|
|
@@ -607,6 +608,78 @@ function transformEsmSource(
|
|
|
607
608
|
|
|
608
609
|
|
|
609
610
|
|
|
611
|
+
// Match React.memo() and React.forwardRef() wrapped components
|
|
612
|
+
// Pattern: const Name = (React.)?memo( or const Name = (React.)?forwardRef<T,P>(
|
|
613
|
+
// Then scan forward to find the inner arrow => { body
|
|
614
|
+
if (isReactFile) {
|
|
615
|
+
const memoRefRegex = /^[ \t]*(?:export\s+)?(?:const|let|var)\s+([A-Z][a-zA-Z0-9_$]*)(?:\s*:\s*[^=]+?)?\s*=\s*(?:React\.)?(?:memo|forwardRef)\s*(?:<[^(]*>)?\s*\(/gm;
|
|
616
|
+
let memoMatch;
|
|
617
|
+
while ((memoMatch = memoRefRegex.exec(source)) !== null) {
|
|
618
|
+
const name = memoMatch[1];
|
|
619
|
+
// Position after the opening `(` of memo/forwardRef call
|
|
620
|
+
const afterMemoOpen = memoMatch.index + memoMatch[0].length;
|
|
621
|
+
|
|
622
|
+
// Scan forward to find `=> {` — the arrow body of the inner function.
|
|
623
|
+
// We need to skip over the inner function's parameter list (which may contain nested parens).
|
|
624
|
+
// Strategy: find the next `=>` that is followed by optional whitespace and `{`.
|
|
625
|
+
let pos = afterMemoOpen;
|
|
626
|
+
let arrowPos = -1;
|
|
627
|
+
let parenDepth = 0;
|
|
628
|
+
while (pos < source.length - 1) {
|
|
629
|
+
const ch = source[pos];
|
|
630
|
+
if (ch === '(') parenDepth++;
|
|
631
|
+
else if (ch === ')') parenDepth--;
|
|
632
|
+
else if (ch === '=' && source[pos + 1] === '>' && parenDepth <= 0) {
|
|
633
|
+
arrowPos = pos;
|
|
634
|
+
break;
|
|
635
|
+
}
|
|
636
|
+
pos++;
|
|
637
|
+
}
|
|
638
|
+
if (arrowPos === -1) continue;
|
|
639
|
+
|
|
640
|
+
// Skip `=>` and whitespace to find `{`
|
|
641
|
+
let bracePos = arrowPos + 2;
|
|
642
|
+
while (bracePos < source.length && /[\s]/.test(source[bracePos])) bracePos++;
|
|
643
|
+
if (source[bracePos] !== '{') continue;
|
|
644
|
+
const openBrace = bracePos;
|
|
645
|
+
|
|
646
|
+
const closeBrace = findClosingBrace(source, openBrace);
|
|
647
|
+
if (closeBrace === -1) continue;
|
|
648
|
+
|
|
649
|
+
// Extract the param list: everything between memo( and arrowPos
|
|
650
|
+
const innerParamStr = source.slice(afterMemoOpen, arrowPos).trim();
|
|
651
|
+
// innerParamStr is like `({ item, onSelect })` or `(props, ref)` or `props`
|
|
652
|
+
let propsExpr = 'undefined';
|
|
653
|
+
if (innerParamStr.startsWith('(')) {
|
|
654
|
+
// Peel outer parens
|
|
655
|
+
const inner = innerParamStr.slice(1, innerParamStr.lastIndexOf(')')).trim();
|
|
656
|
+
if (inner.startsWith('{')) {
|
|
657
|
+
// Find the matching `}` of the destructuring pattern, ignoring any type annotation after it
|
|
658
|
+
let depth3 = 0, destructEnd = -1;
|
|
659
|
+
for (let i = 0; i < inner.length; i++) {
|
|
660
|
+
if (inner[i] === '{') depth3++;
|
|
661
|
+
else if (inner[i] === '}') { depth3--; if (depth3 === 0) { destructEnd = i; break; } }
|
|
662
|
+
}
|
|
663
|
+
const destructPart = destructEnd !== -1 ? inner.slice(0, destructEnd + 1) : inner;
|
|
664
|
+
const fields = extractDestructuredNames(destructPart);
|
|
665
|
+
if (fields.length > 0) propsExpr = `{ ${fields.join(', ')} }`;
|
|
666
|
+
} else {
|
|
667
|
+
const firstParam = inner.split(',')[0].trim().split(':')[0].trim();
|
|
668
|
+
if (firstParam) propsExpr = firstParam;
|
|
669
|
+
}
|
|
670
|
+
} else if (innerParamStr && /^[a-zA-Z_$]/.test(innerParamStr)) {
|
|
671
|
+
propsExpr = innerParamStr.split(/[\s,:(]/)[0];
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
let lineNo = 1;
|
|
675
|
+
for (let i = 0; i < memoMatch.index; i++) {
|
|
676
|
+
if (source[i] === '\n') lineNo++;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
bodyInsertions.push({ position: openBrace + 1, name, lineNo, propsExpr });
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
610
683
|
// React hook tracking — wrap the callback arg of useEffect/useMemo/useCallback
|
|
611
684
|
// to count how many times each hook fires (effect ran, memo recomputed, callback invoked).
|
|
612
685
|
// Each hook produces TWO insertions: wrapStart (before callback) and wrapEnd (after callback `}`).
|