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