trickle-observe 0.2.62 → 0.2.64
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/next-loader.d.ts +21 -0
- package/dist/next-loader.js +50 -0
- package/dist/next-plugin.d.ts +63 -0
- package/dist/next-plugin.js +65 -0
- package/dist/vite-plugin.js +85 -1
- package/dist-esm/vite-plugin.js +85 -1
- package/package.json +5 -3
- package/src/next-loader.ts +55 -0
- package/src/next-plugin.test.ts +158 -0
- package/src/next-plugin.ts +92 -0
- package/src/vite-plugin.test.ts +35 -0
- package/src/vite-plugin.ts +74 -1
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* trickle/next-loader — Webpack loader for Next.js component instrumentation.
|
|
3
|
+
*
|
|
4
|
+
* Applied via withTrickle() in next.config.js. Instruments .tsx/.jsx files
|
|
5
|
+
* with render tracking, useState change tracking, and hook observability —
|
|
6
|
+
* the same transforms the Vite plugin provides, but via webpack's loader API.
|
|
7
|
+
*
|
|
8
|
+
* Do not use directly. Use withTrickle() from 'trickle-observe/next-plugin' instead.
|
|
9
|
+
*/
|
|
10
|
+
interface LoaderOptions {
|
|
11
|
+
backendUrl?: string;
|
|
12
|
+
include?: string[];
|
|
13
|
+
exclude?: string[];
|
|
14
|
+
debug?: boolean;
|
|
15
|
+
traceVars?: boolean;
|
|
16
|
+
}
|
|
17
|
+
export default function trickleNextLoader(this: {
|
|
18
|
+
resourcePath: string;
|
|
19
|
+
getOptions(): LoaderOptions;
|
|
20
|
+
}, source: string): string;
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* trickle/next-loader — Webpack loader for Next.js component instrumentation.
|
|
4
|
+
*
|
|
5
|
+
* Applied via withTrickle() in next.config.js. Instruments .tsx/.jsx files
|
|
6
|
+
* with render tracking, useState change tracking, and hook observability —
|
|
7
|
+
* the same transforms the Vite plugin provides, but via webpack's loader API.
|
|
8
|
+
*
|
|
9
|
+
* Do not use directly. Use withTrickle() from 'trickle-observe/next-plugin' instead.
|
|
10
|
+
*/
|
|
11
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
12
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
13
|
+
};
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.default = trickleNextLoader;
|
|
16
|
+
const path_1 = __importDefault(require("path"));
|
|
17
|
+
const vite_plugin_1 = require("./vite-plugin");
|
|
18
|
+
// webpack loader — `this` is the LoaderContext (must not be an arrow function)
|
|
19
|
+
function trickleNextLoader(source) {
|
|
20
|
+
const options = (this.getOptions && this.getOptions()) || {};
|
|
21
|
+
const resourcePath = this.resourcePath;
|
|
22
|
+
// Skip node_modules and trickle internals
|
|
23
|
+
if (resourcePath.includes('node_modules') || resourcePath.includes('trickle-observe')) {
|
|
24
|
+
return source;
|
|
25
|
+
}
|
|
26
|
+
// Include/exclude filters
|
|
27
|
+
if (options.include && options.include.length > 0) {
|
|
28
|
+
if (!options.include.some(p => resourcePath.includes(p)))
|
|
29
|
+
return source;
|
|
30
|
+
}
|
|
31
|
+
if (options.exclude && options.exclude.length > 0) {
|
|
32
|
+
if (options.exclude.some(p => resourcePath.includes(p)))
|
|
33
|
+
return source;
|
|
34
|
+
}
|
|
35
|
+
const backendUrl = options.backendUrl ?? process.env.TRICKLE_BACKEND_URL ?? 'http://localhost:4888';
|
|
36
|
+
const debug = options.debug ?? (process.env.TRICKLE_DEBUG === '1');
|
|
37
|
+
const traceVars = options.traceVars ?? true;
|
|
38
|
+
const moduleName = path_1.default.basename(resourcePath).replace(/\.[jt]sx?$/, '');
|
|
39
|
+
try {
|
|
40
|
+
const transformed = (0, vite_plugin_1.transformEsmSource)(source, resourcePath, moduleName, backendUrl, debug, traceVars, source);
|
|
41
|
+
if (debug && transformed !== source) {
|
|
42
|
+
console.log(`[trickle/next] Instrumented ${resourcePath}`);
|
|
43
|
+
}
|
|
44
|
+
return transformed;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// Never crash the build
|
|
48
|
+
return source;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* trickle/next-plugin — Next.js observability via withTrickle().
|
|
3
|
+
*
|
|
4
|
+
* Wraps your Next.js config to add trickle's webpack loader, which instruments
|
|
5
|
+
* all React components (.tsx/.jsx) with render tracking, useState change tracking,
|
|
6
|
+
* and hook observability — the same hints you see when using the Vite plugin,
|
|
7
|
+
* but for Next.js (App Router and Pages Router, Client and Server Components).
|
|
8
|
+
*
|
|
9
|
+
* Setup in next.config.js:
|
|
10
|
+
*
|
|
11
|
+
* const { withTrickle } = require('trickle-observe/next-plugin');
|
|
12
|
+
*
|
|
13
|
+
* module.exports = withTrickle({
|
|
14
|
+
* // ...your existing Next.js config
|
|
15
|
+
* });
|
|
16
|
+
*
|
|
17
|
+
* Or with options:
|
|
18
|
+
*
|
|
19
|
+
* module.exports = withTrickle({
|
|
20
|
+
* reactStrictMode: true,
|
|
21
|
+
* }, {
|
|
22
|
+
* backendUrl: process.env.TRICKLE_BACKEND_URL,
|
|
23
|
+
* debug: process.env.TRICKLE_DEBUG === '1',
|
|
24
|
+
* });
|
|
25
|
+
*
|
|
26
|
+
* Environment variables:
|
|
27
|
+
* TRICKLE_BACKEND_URL — Backend URL (default: http://localhost:4888)
|
|
28
|
+
* TRICKLE_DEBUG — Set to "1" for debug logging
|
|
29
|
+
*/
|
|
30
|
+
export interface TrickleNextOptions {
|
|
31
|
+
/** Backend URL (default: http://localhost:4888 or TRICKLE_BACKEND_URL env var) */
|
|
32
|
+
backendUrl?: string;
|
|
33
|
+
/** Only instrument files whose paths contain one of these substrings */
|
|
34
|
+
include?: string[];
|
|
35
|
+
/** Skip files whose paths contain one of these substrings */
|
|
36
|
+
exclude?: string[];
|
|
37
|
+
/** Enable debug logging */
|
|
38
|
+
debug?: boolean;
|
|
39
|
+
/** Enable variable tracing (default: true) */
|
|
40
|
+
traceVars?: boolean;
|
|
41
|
+
}
|
|
42
|
+
type NextConfig = Record<string, unknown> & {
|
|
43
|
+
webpack?: (config: WebpackConfig, context: WebpackContext) => WebpackConfig;
|
|
44
|
+
};
|
|
45
|
+
interface WebpackConfig {
|
|
46
|
+
module: {
|
|
47
|
+
rules: unknown[];
|
|
48
|
+
};
|
|
49
|
+
[key: string]: unknown;
|
|
50
|
+
}
|
|
51
|
+
interface WebpackContext {
|
|
52
|
+
isServer: boolean;
|
|
53
|
+
nextRuntime?: string;
|
|
54
|
+
[key: string]: unknown;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Wrap your Next.js config with trickle observability.
|
|
58
|
+
*
|
|
59
|
+
* Adds a webpack loader that instruments all .tsx/.jsx component files at build
|
|
60
|
+
* time with render tracking, useState change tracking, and hook observability.
|
|
61
|
+
*/
|
|
62
|
+
export declare function withTrickle(nextConfig?: NextConfig, options?: TrickleNextOptions): NextConfig;
|
|
63
|
+
export {};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* trickle/next-plugin — Next.js observability via withTrickle().
|
|
4
|
+
*
|
|
5
|
+
* Wraps your Next.js config to add trickle's webpack loader, which instruments
|
|
6
|
+
* all React components (.tsx/.jsx) with render tracking, useState change tracking,
|
|
7
|
+
* and hook observability — the same hints you see when using the Vite plugin,
|
|
8
|
+
* but for Next.js (App Router and Pages Router, Client and Server Components).
|
|
9
|
+
*
|
|
10
|
+
* Setup in next.config.js:
|
|
11
|
+
*
|
|
12
|
+
* const { withTrickle } = require('trickle-observe/next-plugin');
|
|
13
|
+
*
|
|
14
|
+
* module.exports = withTrickle({
|
|
15
|
+
* // ...your existing Next.js config
|
|
16
|
+
* });
|
|
17
|
+
*
|
|
18
|
+
* Or with options:
|
|
19
|
+
*
|
|
20
|
+
* module.exports = withTrickle({
|
|
21
|
+
* reactStrictMode: true,
|
|
22
|
+
* }, {
|
|
23
|
+
* backendUrl: process.env.TRICKLE_BACKEND_URL,
|
|
24
|
+
* debug: process.env.TRICKLE_DEBUG === '1',
|
|
25
|
+
* });
|
|
26
|
+
*
|
|
27
|
+
* Environment variables:
|
|
28
|
+
* TRICKLE_BACKEND_URL — Backend URL (default: http://localhost:4888)
|
|
29
|
+
* TRICKLE_DEBUG — Set to "1" for debug logging
|
|
30
|
+
*/
|
|
31
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
32
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
33
|
+
};
|
|
34
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
35
|
+
exports.withTrickle = withTrickle;
|
|
36
|
+
const path_1 = __importDefault(require("path"));
|
|
37
|
+
/**
|
|
38
|
+
* Wrap your Next.js config with trickle observability.
|
|
39
|
+
*
|
|
40
|
+
* Adds a webpack loader that instruments all .tsx/.jsx component files at build
|
|
41
|
+
* time with render tracking, useState change tracking, and hook observability.
|
|
42
|
+
*/
|
|
43
|
+
function withTrickle(nextConfig = {}, options = {}) {
|
|
44
|
+
const loaderPath = path_1.default.resolve(__dirname, './next-loader.js');
|
|
45
|
+
return {
|
|
46
|
+
...nextConfig,
|
|
47
|
+
webpack(config, context) {
|
|
48
|
+
// Preserve existing webpack config
|
|
49
|
+
if (typeof nextConfig.webpack === 'function') {
|
|
50
|
+
config = nextConfig.webpack(config, context);
|
|
51
|
+
}
|
|
52
|
+
config.module.rules.push({
|
|
53
|
+
test: /\.(tsx?|jsx?)$/,
|
|
54
|
+
exclude: /node_modules/,
|
|
55
|
+
use: [
|
|
56
|
+
{
|
|
57
|
+
loader: loaderPath,
|
|
58
|
+
options,
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
});
|
|
62
|
+
return config;
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
package/dist/vite-plugin.js
CHANGED
|
@@ -539,7 +539,8 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
|
|
|
539
539
|
}
|
|
540
540
|
}
|
|
541
541
|
// Also match arrow functions assigned to const/let/var
|
|
542
|
-
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;
|
|
543
544
|
while ((match = arrowRegex.exec(source)) !== null) {
|
|
544
545
|
const name = match[1];
|
|
545
546
|
const openBrace = source.indexOf('{', match.index + match[0].length - 1);
|
|
@@ -613,6 +614,89 @@ function transformEsmSource(source, filename, moduleName, backendUrl, debug, tra
|
|
|
613
614
|
bodyInsertions.push({ position: openBrace + 1, name, lineNo, propsExpr });
|
|
614
615
|
}
|
|
615
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
|
+
}
|
|
616
700
|
const hookInsertions = [];
|
|
617
701
|
if (isReactFile) {
|
|
618
702
|
// Match useEffect(, useMemo(, useCallback( — also handles React.useEffect(, etc.
|
package/dist-esm/vite-plugin.js
CHANGED
|
@@ -532,7 +532,8 @@ export function transformEsmSource(source, filename, moduleName, backendUrl, deb
|
|
|
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 @@ export function transformEsmSource(source, filename, moduleName, backendUrl, deb
|
|
|
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.64",
|
|
4
4
|
"description": "Runtime type observability for JavaScript applications",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -18,11 +18,13 @@
|
|
|
18
18
|
},
|
|
19
19
|
"./trace-var": "./dist/trace-var.js",
|
|
20
20
|
"./lambda": "./dist/lambda.js",
|
|
21
|
-
"./metro-transformer": "./dist/metro-transformer.js"
|
|
21
|
+
"./metro-transformer": "./dist/metro-transformer.js",
|
|
22
|
+
"./next-plugin": "./dist/next-plugin.js",
|
|
23
|
+
"./next-loader": "./dist/next-loader.js"
|
|
22
24
|
},
|
|
23
25
|
"scripts": {
|
|
24
26
|
"build": "tsc && tsc -p tsconfig.esm.json",
|
|
25
|
-
"test": "npm run build && node --experimental-strip-types --test src/vite-plugin.test.ts src/lambda.test.ts src/metro-transformer.test.ts",
|
|
27
|
+
"test": "npm run build && node --experimental-strip-types --test src/vite-plugin.test.ts src/lambda.test.ts src/metro-transformer.test.ts src/next-plugin.test.ts",
|
|
26
28
|
"prepublishOnly": "npm run build"
|
|
27
29
|
},
|
|
28
30
|
"optionalDependencies": {
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* trickle/next-loader — Webpack loader for Next.js component instrumentation.
|
|
3
|
+
*
|
|
4
|
+
* Applied via withTrickle() in next.config.js. Instruments .tsx/.jsx files
|
|
5
|
+
* with render tracking, useState change tracking, and hook observability —
|
|
6
|
+
* the same transforms the Vite plugin provides, but via webpack's loader API.
|
|
7
|
+
*
|
|
8
|
+
* Do not use directly. Use withTrickle() from 'trickle-observe/next-plugin' instead.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import path from 'path';
|
|
12
|
+
import { transformEsmSource } from './vite-plugin';
|
|
13
|
+
|
|
14
|
+
interface LoaderOptions {
|
|
15
|
+
backendUrl?: string;
|
|
16
|
+
include?: string[];
|
|
17
|
+
exclude?: string[];
|
|
18
|
+
debug?: boolean;
|
|
19
|
+
traceVars?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// webpack loader — `this` is the LoaderContext (must not be an arrow function)
|
|
23
|
+
export default function trickleNextLoader(this: { resourcePath: string; getOptions(): LoaderOptions }, source: string): string {
|
|
24
|
+
const options: LoaderOptions = (this.getOptions && this.getOptions()) || {};
|
|
25
|
+
const resourcePath = this.resourcePath;
|
|
26
|
+
|
|
27
|
+
// Skip node_modules and trickle internals
|
|
28
|
+
if (resourcePath.includes('node_modules') || resourcePath.includes('trickle-observe')) {
|
|
29
|
+
return source;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Include/exclude filters
|
|
33
|
+
if (options.include && options.include.length > 0) {
|
|
34
|
+
if (!options.include.some(p => resourcePath.includes(p))) return source;
|
|
35
|
+
}
|
|
36
|
+
if (options.exclude && options.exclude.length > 0) {
|
|
37
|
+
if (options.exclude.some(p => resourcePath.includes(p))) return source;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const backendUrl = options.backendUrl ?? process.env.TRICKLE_BACKEND_URL ?? 'http://localhost:4888';
|
|
41
|
+
const debug = options.debug ?? (process.env.TRICKLE_DEBUG === '1');
|
|
42
|
+
const traceVars = options.traceVars ?? true;
|
|
43
|
+
const moduleName = path.basename(resourcePath).replace(/\.[jt]sx?$/, '');
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const transformed = transformEsmSource(source, resourcePath, moduleName, backendUrl, debug, traceVars, source);
|
|
47
|
+
if (debug && transformed !== source) {
|
|
48
|
+
console.log(`[trickle/next] Instrumented ${resourcePath}`);
|
|
49
|
+
}
|
|
50
|
+
return transformed;
|
|
51
|
+
} catch {
|
|
52
|
+
// Never crash the build
|
|
53
|
+
return source;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the Next.js plugin and loader (withTrickle, next-loader).
|
|
3
|
+
*
|
|
4
|
+
* Tests the transformation logic directly using transformEsmSource,
|
|
5
|
+
* mirroring what the webpack loader applies to Next.js component files.
|
|
6
|
+
*
|
|
7
|
+
* Run with: node --experimental-strip-types --test src/next-plugin.test.ts
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it } from 'node:test';
|
|
10
|
+
import assert from 'node:assert/strict';
|
|
11
|
+
import { transformEsmSource } from '../dist/vite-plugin.js';
|
|
12
|
+
|
|
13
|
+
const BACKEND = 'http://localhost:4888';
|
|
14
|
+
|
|
15
|
+
function transformNextTsx(code: string, filename = '/app/components/MyComponent.tsx'): string {
|
|
16
|
+
return transformEsmSource(code, filename, 'MyComponent', BACKEND, false, false);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function transformNextTs(code: string, filename = '/app/utils/helper.ts'): string {
|
|
20
|
+
return transformEsmSource(code, filename, 'helper', BACKEND, false, false);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ── Next.js component patterns ─────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
describe('Next.js Client Component tracking', () => {
|
|
26
|
+
it('tracks a "use client" component with useState', () => {
|
|
27
|
+
const code = [
|
|
28
|
+
`'use client';`,
|
|
29
|
+
`import { useState } from 'react';`,
|
|
30
|
+
`export default function Counter() {`,
|
|
31
|
+
` const [count, setCount] = useState(0);`,
|
|
32
|
+
` return <div>{count}</div>;`,
|
|
33
|
+
`}`,
|
|
34
|
+
].join('\n');
|
|
35
|
+
const out = transformNextTsx(code);
|
|
36
|
+
assert.ok(out.includes('__trickle_rc'), 'should track render count');
|
|
37
|
+
assert.ok(out.includes('__trickle_ss'), 'should track useState');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('tracks a "use client" arrow component with hooks', () => {
|
|
41
|
+
const code = [
|
|
42
|
+
`'use client';`,
|
|
43
|
+
`import { useState, useEffect } from 'react';`,
|
|
44
|
+
`const ProductCart: React.FC<Props> = ({ items }) => {`,
|
|
45
|
+
` const [open, setOpen] = useState(false);`,
|
|
46
|
+
` useEffect(() => { syncCart(); }, [items]);`,
|
|
47
|
+
` return <div />;`,
|
|
48
|
+
`};`,
|
|
49
|
+
].join('\n');
|
|
50
|
+
const out = transformNextTsx(code);
|
|
51
|
+
assert.ok(out.includes('__trickle_rc'), 'should track render');
|
|
52
|
+
assert.ok(out.includes('__trickle_ss'), 'should track useState');
|
|
53
|
+
assert.ok(out.includes('__trickle_hw'), 'should track useEffect');
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('Next.js Server Component tracking', () => {
|
|
58
|
+
it('tracks a Server Component (no use client directive)', () => {
|
|
59
|
+
const code = [
|
|
60
|
+
`import { db } from '@/lib/db';`,
|
|
61
|
+
`export default async function ProductPage({ params }: { params: { id: string } }) {`,
|
|
62
|
+
` const product = await db.product.findUnique({ where: { id: params.id } });`,
|
|
63
|
+
` return <div>{product?.name}</div>;`,
|
|
64
|
+
`}`,
|
|
65
|
+
].join('\n');
|
|
66
|
+
const out = transformNextTsx(code);
|
|
67
|
+
assert.ok(out.includes('__trickle_rc'), 'should track Server Component renders');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('tracks a named Server Component export', () => {
|
|
71
|
+
const code = [
|
|
72
|
+
`export function Navbar({ user }: { user: User }) {`,
|
|
73
|
+
` return <nav>{user.name}</nav>;`,
|
|
74
|
+
`}`,
|
|
75
|
+
].join('\n');
|
|
76
|
+
const out = transformNextTsx(code);
|
|
77
|
+
assert.ok(out.includes('__trickle_rc'), 'should track named export server component');
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('Next.js App Router component patterns', () => {
|
|
82
|
+
it('tracks export default function (most common Next.js page pattern)', () => {
|
|
83
|
+
const code = `export default function Page() { return <main />; }`;
|
|
84
|
+
const out = transformNextTsx(code);
|
|
85
|
+
assert.ok(out.includes('__trickle_rc'), 'should track default page export');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('tracks React.memo wrapped component in Next.js', () => {
|
|
89
|
+
const code = [
|
|
90
|
+
`const ProductCard = React.memo(({ product }: { product: Product }) => {`,
|
|
91
|
+
` return <div>{product.name}</div>;`,
|
|
92
|
+
`});`,
|
|
93
|
+
].join('\n');
|
|
94
|
+
const out = transformNextTsx(code);
|
|
95
|
+
assert.ok(out.includes('__trickle_rc'), 'should track memo-wrapped Next.js component');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('tracks React.FC typed component in Next.js', () => {
|
|
99
|
+
const code = [
|
|
100
|
+
`const SiteHeader: React.FC<{ title: string }> = ({ title }) => {`,
|
|
101
|
+
` return <header>{title}</header>;`,
|
|
102
|
+
`};`,
|
|
103
|
+
].join('\n');
|
|
104
|
+
const out = transformNextTsx(code);
|
|
105
|
+
assert.ok(out.includes('__trickle_rc'), 'should track React.FC component');
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('Next.js: withTrickle plugin registration', () => {
|
|
110
|
+
it('withTrickle returns an object with a webpack function', async () => {
|
|
111
|
+
const { withTrickle } = await import('../dist/next-plugin.js');
|
|
112
|
+
const result = withTrickle({ reactStrictMode: true });
|
|
113
|
+
assert.ok(result, 'withTrickle should return a config object');
|
|
114
|
+
assert.equal(typeof result.webpack, 'function', 'should add webpack function');
|
|
115
|
+
assert.equal(result.reactStrictMode, true, 'should preserve existing config');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('withTrickle webpack function returns config with trickle loader rule', async () => {
|
|
119
|
+
const { withTrickle } = await import('../dist/next-plugin.js');
|
|
120
|
+
const result = withTrickle({});
|
|
121
|
+
const mockConfig = { module: { rules: [] } };
|
|
122
|
+
const updatedConfig = result.webpack(mockConfig, { isServer: false });
|
|
123
|
+
assert.ok(updatedConfig.module.rules.length > 0, 'should add at least one rule');
|
|
124
|
+
const rule = updatedConfig.module.rules[0] as { test: RegExp; use: { loader: string }[] };
|
|
125
|
+
assert.ok(rule.test instanceof RegExp, 'rule should have a test regex');
|
|
126
|
+
assert.ok(rule.test.test('Component.tsx'), 'rule should match .tsx files');
|
|
127
|
+
assert.ok(rule.test.test('Component.jsx'), 'rule should match .jsx files');
|
|
128
|
+
assert.ok(!rule.test.test('helper.py'), 'rule should not match .py files');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('withTrickle preserves existing webpack config', async () => {
|
|
132
|
+
const { withTrickle } = await import('../dist/next-plugin.js');
|
|
133
|
+
let calledWith = false;
|
|
134
|
+
const originalWebpack = (_config: unknown, _ctx: unknown) => { calledWith = true; return { module: { rules: [] } }; };
|
|
135
|
+
const result = withTrickle({ webpack: originalWebpack });
|
|
136
|
+
result.webpack({ module: { rules: [] } }, { isServer: false });
|
|
137
|
+
assert.ok(calledWith, 'should call original webpack function');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('withTrickle works with no arguments', async () => {
|
|
141
|
+
const { withTrickle } = await import('../dist/next-plugin.js');
|
|
142
|
+
assert.doesNotThrow(() => withTrickle(), 'withTrickle() with no args should not throw');
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('Next.js: does not instrument non-React files', () => {
|
|
147
|
+
it('does not inject render tracker in .ts utility files', () => {
|
|
148
|
+
const code = `export function formatPrice(amount: number): string { return '$' + amount; }`;
|
|
149
|
+
const out = transformNextTs(code);
|
|
150
|
+
assert.ok(!out.includes('__trickle_rc'), 'should not inject render tracker in .ts files');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('does not inject useState tracker in .ts files', () => {
|
|
154
|
+
const code = `function helper() { const [x, setX] = useState(0); return x; }`;
|
|
155
|
+
const out = transformNextTs(code);
|
|
156
|
+
assert.ok(!out.includes('__trickle_ss'), 'should not track useState in .ts files');
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* trickle/next-plugin — Next.js observability via withTrickle().
|
|
3
|
+
*
|
|
4
|
+
* Wraps your Next.js config to add trickle's webpack loader, which instruments
|
|
5
|
+
* all React components (.tsx/.jsx) with render tracking, useState change tracking,
|
|
6
|
+
* and hook observability — the same hints you see when using the Vite plugin,
|
|
7
|
+
* but for Next.js (App Router and Pages Router, Client and Server Components).
|
|
8
|
+
*
|
|
9
|
+
* Setup in next.config.js:
|
|
10
|
+
*
|
|
11
|
+
* const { withTrickle } = require('trickle-observe/next-plugin');
|
|
12
|
+
*
|
|
13
|
+
* module.exports = withTrickle({
|
|
14
|
+
* // ...your existing Next.js config
|
|
15
|
+
* });
|
|
16
|
+
*
|
|
17
|
+
* Or with options:
|
|
18
|
+
*
|
|
19
|
+
* module.exports = withTrickle({
|
|
20
|
+
* reactStrictMode: true,
|
|
21
|
+
* }, {
|
|
22
|
+
* backendUrl: process.env.TRICKLE_BACKEND_URL,
|
|
23
|
+
* debug: process.env.TRICKLE_DEBUG === '1',
|
|
24
|
+
* });
|
|
25
|
+
*
|
|
26
|
+
* Environment variables:
|
|
27
|
+
* TRICKLE_BACKEND_URL — Backend URL (default: http://localhost:4888)
|
|
28
|
+
* TRICKLE_DEBUG — Set to "1" for debug logging
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import path from 'path';
|
|
32
|
+
|
|
33
|
+
export interface TrickleNextOptions {
|
|
34
|
+
/** Backend URL (default: http://localhost:4888 or TRICKLE_BACKEND_URL env var) */
|
|
35
|
+
backendUrl?: string;
|
|
36
|
+
/** Only instrument files whose paths contain one of these substrings */
|
|
37
|
+
include?: string[];
|
|
38
|
+
/** Skip files whose paths contain one of these substrings */
|
|
39
|
+
exclude?: string[];
|
|
40
|
+
/** Enable debug logging */
|
|
41
|
+
debug?: boolean;
|
|
42
|
+
/** Enable variable tracing (default: true) */
|
|
43
|
+
traceVars?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type NextConfig = Record<string, unknown> & {
|
|
47
|
+
webpack?: (config: WebpackConfig, context: WebpackContext) => WebpackConfig;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
interface WebpackConfig {
|
|
51
|
+
module: { rules: unknown[] };
|
|
52
|
+
[key: string]: unknown;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface WebpackContext {
|
|
56
|
+
isServer: boolean;
|
|
57
|
+
nextRuntime?: string;
|
|
58
|
+
[key: string]: unknown;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Wrap your Next.js config with trickle observability.
|
|
63
|
+
*
|
|
64
|
+
* Adds a webpack loader that instruments all .tsx/.jsx component files at build
|
|
65
|
+
* time with render tracking, useState change tracking, and hook observability.
|
|
66
|
+
*/
|
|
67
|
+
export function withTrickle(nextConfig: NextConfig = {}, options: TrickleNextOptions = {}): NextConfig {
|
|
68
|
+
const loaderPath = path.resolve(__dirname, './next-loader.js');
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
...nextConfig,
|
|
72
|
+
webpack(config: WebpackConfig, context: WebpackContext) {
|
|
73
|
+
// Preserve existing webpack config
|
|
74
|
+
if (typeof nextConfig.webpack === 'function') {
|
|
75
|
+
config = nextConfig.webpack(config, context);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
config.module.rules.push({
|
|
79
|
+
test: /\.(tsx?|jsx?)$/,
|
|
80
|
+
exclude: /node_modules/,
|
|
81
|
+
use: [
|
|
82
|
+
{
|
|
83
|
+
loader: loaderPath,
|
|
84
|
+
options,
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
return config;
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
package/src/vite-plugin.test.ts
CHANGED
|
@@ -47,6 +47,41 @@ describe('React file detection', () => {
|
|
|
47
47
|
assert.ok(out!.includes('__trickle_rc'), 'export default function should be tracked as component');
|
|
48
48
|
});
|
|
49
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
|
+
|
|
50
85
|
it('does not track lowercase functions as components', () => {
|
|
51
86
|
const code = `function helper(x) { return x + 1; }`;
|
|
52
87
|
const out = transformTsx(code);
|
package/src/vite-plugin.ts
CHANGED
|
@@ -537,7 +537,8 @@ export 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 @@ export 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 `}`).
|