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.
@@ -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
+ }
@@ -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 arrowRegex = /^[ \t]*(?:export\s+)?(?:const|let|var)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=\s*(?:async\s+)?(?:\([^)]*\)|[a-zA-Z_$][a-zA-Z0-9_$]*)\s*(?::\s*[^=]+?)?\s*=>\s*\{/gm;
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.
@@ -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 arrowRegex = /^[ \t]*(?:export\s+)?(?:const|let|var)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=\s*(?:async\s+)?(?:\([^)]*\)|[a-zA-Z_$][a-zA-Z0-9_$]*)\s*(?::\s*[^=]+?)?\s*=>\s*\{/gm;
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.62",
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
+ }
@@ -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);
@@ -537,7 +537,8 @@ export function transformEsmSource(
537
537
  }
538
538
 
539
539
  // Also match arrow functions assigned to const/let/var
540
- const arrowRegex = /^[ \t]*(?:export\s+)?(?:const|let|var)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=\s*(?:async\s+)?(?:\([^)]*\)|[a-zA-Z_$][a-zA-Z0-9_$]*)\s*(?::\s*[^=]+?)?\s*=>\s*\{/gm;
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 `}`).