@rangojs/router 0.0.0-experimental.53 → 0.0.0-experimental.54

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.
@@ -1745,7 +1745,7 @@ import { resolve } from "node:path";
1745
1745
  // package.json
1746
1746
  var package_default = {
1747
1747
  name: "@rangojs/router",
1748
- version: "0.0.0-experimental.53",
1748
+ version: "0.0.0-experimental.54",
1749
1749
  description: "Django-inspired RSC router with composable URL patterns",
1750
1750
  keywords: [
1751
1751
  "react",
@@ -1887,7 +1887,7 @@ var package_default = {
1887
1887
  "test:unit:watch": "vitest"
1888
1888
  },
1889
1889
  dependencies: {
1890
- "@vitejs/plugin-rsc": "^0.5.14",
1890
+ "@vitejs/plugin-rsc": "^0.5.19",
1891
1891
  "magic-string": "^0.30.17",
1892
1892
  picomatch: "^4.0.3",
1893
1893
  "rsc-html-stream": "^0.0.7"
@@ -2784,6 +2784,68 @@ function createVersionPlugin() {
2784
2784
 
2785
2785
  // src/vite/utils/shared-utils.ts
2786
2786
  import * as Vite from "vite";
2787
+
2788
+ // src/vite/plugins/performance-tracks.ts
2789
+ import { readFile } from "node:fs/promises";
2790
+ var RSDW_PATCH_RE = /((?:var|let|const)\s+\w+\s*=\s*root\._children\s*,\s*(\w+)\s*=\s*root\._debugInfo\s*[;,])/;
2791
+ function buildPatchReplacement(match, debugInfoVar) {
2792
+ return `${match}
2793
+ if (${debugInfoVar} && 0 === ${debugInfoVar}.length && "fulfilled" === root.status) {
2794
+ var _resolved = "function" === typeof resolveLazy ? resolveLazy(root.value) : root.value;
2795
+ if ("object" === typeof _resolved && null !== _resolved && isArrayImpl(_resolved._debugInfo)) {
2796
+ ${debugInfoVar} = _resolved._debugInfo;
2797
+ }
2798
+ }`;
2799
+ }
2800
+ function patchRsdwClientDebugInfoRecovery(code) {
2801
+ const match = code.match(RSDW_PATCH_RE);
2802
+ if (!match) {
2803
+ return { code, debugInfoVar: null };
2804
+ }
2805
+ return {
2806
+ code: code.replace(match[1], buildPatchReplacement(match[1], match[2])),
2807
+ debugInfoVar: match[2]
2808
+ };
2809
+ }
2810
+ function performanceTracksOptimizeDepsPlugin() {
2811
+ return {
2812
+ name: "@rangojs/router:performance-tracks-optimize-deps",
2813
+ setup(build) {
2814
+ build.onLoad(
2815
+ {
2816
+ filter: /react-server-dom-webpack-client\.browser\.(development|production)\.js$/
2817
+ },
2818
+ async (args) => {
2819
+ const code = await readFile(args.path, "utf8");
2820
+ const patched = patchRsdwClientDebugInfoRecovery(code);
2821
+ return {
2822
+ contents: patched.code,
2823
+ loader: "js"
2824
+ };
2825
+ }
2826
+ );
2827
+ }
2828
+ };
2829
+ }
2830
+ function performanceTracksPlugin() {
2831
+ return {
2832
+ name: "@rangojs/router:performance-tracks",
2833
+ transform(code, id) {
2834
+ if (!id.includes("react-server-dom") || !id.includes("client")) return;
2835
+ const patched = patchRsdwClientDebugInfoRecovery(code);
2836
+ if (!patched.debugInfoVar) return;
2837
+ if (process.env.INTERNAL_RANGO_DEBUG)
2838
+ console.log(
2839
+ "[perf-tracks] patched RSDW client (var:",
2840
+ patched.debugInfoVar,
2841
+ ")"
2842
+ );
2843
+ return patched.code;
2844
+ }
2845
+ };
2846
+ }
2847
+
2848
+ // src/vite/utils/shared-utils.ts
2787
2849
  var versionEsbuildPlugin = {
2788
2850
  name: "@rangojs/router-version",
2789
2851
  setup(build) {
@@ -2801,7 +2863,7 @@ var versionEsbuildPlugin = {
2801
2863
  }
2802
2864
  };
2803
2865
  var sharedEsbuildOptions = {
2804
- plugins: [versionEsbuildPlugin]
2866
+ plugins: [versionEsbuildPlugin, performanceTracksOptimizeDepsPlugin()]
2805
2867
  };
2806
2868
  function createVirtualEntriesPlugin(entries, routerPathRef) {
2807
2869
  const virtualModules = {};
@@ -4868,7 +4930,16 @@ async function rango(options) {
4868
4930
  const showBanner = resolvedOptions.banner ?? true;
4869
4931
  const plugins = [];
4870
4932
  const rangoAliases = getPackageAliases();
4871
- const excludeDeps = getExcludeDeps();
4933
+ const excludeDeps = [
4934
+ ...getExcludeDeps(),
4935
+ // The public browser entry re-exports the RSDW browser client.
4936
+ // Excluding both keeps Vite from freezing the unpatched bundle into
4937
+ // .vite/deps before our source transforms run.
4938
+ "@vitejs/plugin-rsc/browser",
4939
+ // Keep the browser RSDW client out of Vite's dep optimizer so our
4940
+ // cjs-to-esm transform can patch the real file.
4941
+ "@vitejs/plugin-rsc/vendor/react-server-dom/client.browser"
4942
+ ];
4872
4943
  const routerRef = { path: void 0 };
4873
4944
  const prerenderEnabled = true;
4874
4945
  if (preset === "cloudflare") {
@@ -4964,6 +5035,7 @@ async function rango(options) {
4964
5035
  }
4965
5036
  });
4966
5037
  plugins.push(createVirtualEntriesPlugin(finalEntries));
5038
+ plugins.push(performanceTracksPlugin());
4967
5039
  plugins.push(
4968
5040
  rsc({
4969
5041
  entries: finalEntries,
@@ -5082,6 +5154,7 @@ ${list}`);
5082
5154
  }
5083
5155
  });
5084
5156
  plugins.push(createVirtualEntriesPlugin(finalEntries, routerRef));
5157
+ plugins.push(performanceTracksPlugin());
5085
5158
  plugins.push(
5086
5159
  rsc({
5087
5160
  entries: finalEntries
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.0.0-experimental.53",
3
+ "version": "0.0.0-experimental.54",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -142,7 +142,7 @@
142
142
  "test:unit:watch": "vitest"
143
143
  },
144
144
  "dependencies": {
145
- "@vitejs/plugin-rsc": "^0.5.14",
145
+ "@vitejs/plugin-rsc": "^0.5.19",
146
146
  "magic-string": "^0.30.17",
147
147
  "picomatch": "^4.0.3",
148
148
  "rsc-html-stream": "^0.0.7"
@@ -554,7 +554,7 @@ export const ProductLoader = createLoader(async (ctx) => {
554
554
  .first();
555
555
 
556
556
  if (!product) {
557
- throw new Response("Product not found", { status: 404 });
557
+ notFound("Product not found");
558
558
  }
559
559
 
560
560
  return { product };
@@ -84,10 +84,10 @@ interface RSCRouterOptions<TEnv> {
84
84
  // Default error boundary
85
85
  defaultErrorBoundary?: ReactNode | ErrorBoundaryHandler;
86
86
 
87
- // Default not-found boundary
87
+ // Default not-found boundary for notFound() thrown in handlers/loaders
88
88
  defaultNotFoundBoundary?: ReactNode | NotFoundBoundaryHandler;
89
89
 
90
- // Component for 404 routes
90
+ // Component for 404 (no route match, or notFound() without a boundary)
91
91
  notFound?: ReactNode | ((props: { pathname: string }) => ReactNode);
92
92
 
93
93
  // Error logging callback
@@ -290,6 +290,56 @@ const router = createRouter({
290
290
  export default router;
291
291
  ```
292
292
 
293
+ ## Not Found Handling
294
+
295
+ Two distinct 404 scenarios:
296
+
297
+ **1. No route matches the URL** — the router renders the `notFound` component from `createRouter()` config. This is automatic.
298
+
299
+ **2. A handler/loader calls `notFound()`** — signals that the route matched but the data doesn't exist (e.g., invalid product ID).
300
+
301
+ ```typescript
302
+ import { notFound } from "@rangojs/router";
303
+
304
+ // In a handler or loader
305
+ path("/product/:slug", async (ctx) => {
306
+ const product = await db.getProduct(ctx.params.slug);
307
+ if (!product) notFound("Product not found");
308
+ return <ProductPage product={product} />;
309
+ });
310
+ ```
311
+
312
+ ### Fallback chain for `notFound()`
313
+
314
+ When `notFound()` is thrown, the router looks for a fallback in this order:
315
+
316
+ 1. **`notFoundBoundary()`** — nearest boundary in the route tree (route-level)
317
+ 2. **`defaultNotFoundBoundary`** — from `createRouter()` config (app-level)
318
+ 3. **`notFound`** — from `createRouter()` config (same component used for no-route-match)
319
+ 4. **Default `<h1>Not Found</h1>`** — built-in fallback
320
+
321
+ All cases set HTTP 404 status.
322
+
323
+ ### notFoundBoundary
324
+
325
+ Wrap routes with `notFoundBoundary()` for route-specific not-found UI:
326
+
327
+ ```typescript
328
+ urls(({ path, layout }) => [
329
+ layout(ShopLayout, () => [
330
+ notFoundBoundary(({ notFound: info }) => (
331
+ <div>
332
+ <h1>Not Found</h1>
333
+ <p>{info.message}</p>
334
+ </div>
335
+ )),
336
+ path("/product/:slug", ProductPage),
337
+ ]),
338
+ ]);
339
+ ```
340
+
341
+ `notFoundBoundary` receives `{ notFound: NotFoundInfo }` where `NotFoundInfo` contains `message`, `segmentId`, `segmentType`, and `pathname`.
342
+
293
343
  ## Including Sub-patterns
294
344
 
295
345
  ```typescript
@@ -219,8 +219,8 @@ export function createNavigationClient(
219
219
  }
220
220
 
221
221
  try {
222
- // Deserialize RSC payload
223
222
  const payload = await deps.createFromFetch<RscPayload>(responsePromise);
223
+
224
224
  if (tx) {
225
225
  browserDebugLog(tx, "response received", {
226
226
  isPartial: payload.metadata?.isPartial,
@@ -139,7 +139,6 @@ export async function initBrowserApp(
139
139
  initialTheme,
140
140
  } = options;
141
141
 
142
- // Load initial payload from SSR-injected __FLIGHT_DATA__
143
142
  const initialPayload =
144
143
  await deps.createFromReadableStream<RscPayload>(rscStream);
145
144
 
@@ -206,7 +206,6 @@ export function createServerActionBridge(
206
206
  "rsc-action": id,
207
207
  "X-RSC-Router-Client-Path": segmentState.currentUrl,
208
208
  ...(tx && { "X-RSC-Router-Request-Id": tx.requestId }),
209
- // Send intercept source URL so server can maintain intercept context
210
209
  ...(interceptSourceUrl && {
211
210
  "X-RSC-Router-Intercept-Source": interceptSourceUrl,
212
211
  }),
@@ -309,7 +308,6 @@ export function createServerActionBridge(
309
308
  matchedCount: payload.metadata?.matched?.length ?? 0,
310
309
  diffCount: payload.metadata?.diff?.length ?? 0,
311
310
  });
312
-
313
311
  // Guard: if the action was aborted while streaming (e.g., user navigated
314
312
  // away or abortAllActions fired), bail out before any reconcile/render/cache
315
313
  // writes to avoid overwriting the current UI with stale action results.
@@ -341,7 +341,13 @@ export type ReadonlyURLSearchParams = Omit<
341
341
  export interface RscBrowserDependencies {
342
342
  createFromFetch: <T>(
343
343
  response: Promise<Response>,
344
- options?: { temporaryReferences?: any },
344
+ options?: {
345
+ temporaryReferences?: any;
346
+ findSourceMapURL?: (
347
+ filename: string,
348
+ environmentName: string,
349
+ ) => string | null;
350
+ },
345
351
  ) => Promise<T>;
346
352
  createFromReadableStream: <T>(stream: ReadableStream) => Promise<T>;
347
353
  encodeReply: (
@@ -20,6 +20,7 @@ import { getGlobalRouteMap } from "../route-map-builder.js";
20
20
  import { handleHandlerResult } from "./segment-resolution.js";
21
21
  import type { SegmentResolutionDeps } from "./types.js";
22
22
  import { debugLog } from "./logging.js";
23
+ import { runInsideLoaderScope } from "../server/context.js";
23
24
 
24
25
  /**
25
26
  * Check if an intercept's when conditions are satisfied.
@@ -207,7 +208,7 @@ export async function resolveInterceptEntry<TEnv>(
207
208
  loaderIds.push(loader.$$id);
208
209
  loaderPromises.push(
209
210
  deps.wrapLoaderPromise(
210
- context.use(loader),
211
+ runInsideLoaderScope(() => context.use(loader)),
211
212
  parentEntry,
212
213
  segmentId,
213
214
  context.pathname,
@@ -374,7 +375,7 @@ export async function resolveInterceptLoadersOnly<TEnv>(
374
375
  loaderIds.push(loader.$$id);
375
376
  loaderPromises.push(
376
377
  deps.wrapLoaderPromise(
377
- context.use(loader),
378
+ runInsideLoaderScope(() => context.use(loader)),
378
379
  parentEntry,
379
380
  segmentId,
380
381
  context.pathname,
@@ -30,7 +30,11 @@ import {
30
30
  } from "./helpers.js";
31
31
  import { getRouterContext } from "../router-context.js";
32
32
  import { resolveSink, safeEmit } from "../telemetry.js";
33
- import { track, RSCRouterContext } from "../../server/context.js";
33
+ import {
34
+ track,
35
+ RSCRouterContext,
36
+ runInsideLoaderScope,
37
+ } from "../../server/context.js";
34
38
 
35
39
  // ---------------------------------------------------------------------------
36
40
  // Streamed handler telemetry
@@ -112,7 +116,9 @@ export async function resolveLoaders<TEnv>(
112
116
  params: ctx.params,
113
117
  loaderId: loader.$$id,
114
118
  loaderData: deps.wrapLoaderPromise(
115
- resolveLoaderData(loaderEntry, ctx, ctx.pathname),
119
+ runInsideLoaderScope(() =>
120
+ resolveLoaderData(loaderEntry, ctx, ctx.pathname),
121
+ ),
116
122
  entry,
117
123
  segmentId,
118
124
  ctx.pathname,
@@ -128,7 +134,9 @@ export async function resolveLoaders<TEnv>(
128
134
  // settled promises so handlers don't stream loading placeholders.
129
135
  const pendingLoaderData = loaderEntries.map((loaderEntry) => {
130
136
  const start = performance.now();
131
- const promise = resolveLoaderData(loaderEntry, ctx, ctx.pathname);
137
+ const promise = runInsideLoaderScope(() =>
138
+ resolveLoaderData(loaderEntry, ctx, ctx.pathname),
139
+ );
132
140
  return { promise, start, loaderId: loaderEntry.loader.$$id };
133
141
  });
134
142
  await Promise.all(pendingLoaderData.map((p) => p.promise));
@@ -8,7 +8,7 @@
8
8
  * - Error boundary segment creation
9
9
  */
10
10
 
11
- import type { ReactNode } from "react";
11
+ import { createElement, type ReactNode } from "react";
12
12
  import { DataNotFoundError } from "../../errors";
13
13
  import {
14
14
  createErrorInfo,
@@ -180,34 +180,39 @@ export function catchSegmentError<TEnv>(
180
180
 
181
181
  if (error instanceof DataNotFoundError) {
182
182
  const notFoundFallback = deps.findNearestNotFoundBoundary(entry);
183
+ // Fall back to router's notFound component, then a plain default
184
+ const notFoundOption = deps.notFoundComponent;
185
+ const defaultFallback =
186
+ typeof notFoundOption === "function"
187
+ ? notFoundOption({ pathname: pathname ?? "" })
188
+ : (notFoundOption ?? createElement("h1", null, "Not Found"));
189
+ const effectiveNotFoundFallback = notFoundFallback ?? defaultFallback;
183
190
 
184
- if (notFoundFallback) {
185
- const notFoundInfo = createNotFoundInfo(
186
- error,
187
- entry.shortCode,
188
- entry.type,
189
- pathname,
190
- );
191
+ const notFoundInfo = createNotFoundInfo(
192
+ error,
193
+ entry.shortCode,
194
+ entry.type,
195
+ pathname,
196
+ );
191
197
 
192
- reportError(true, {
193
- notFound: true,
194
- message: notFoundInfo.message,
195
- });
198
+ reportError(true, {
199
+ notFound: true,
200
+ message: notFoundInfo.message,
201
+ });
196
202
 
197
- debugLog("segment", "notFound boundary handled error", {
198
- segmentId: entry.shortCode,
199
- message: notFoundInfo.message,
200
- });
203
+ debugLog("segment", "notFound boundary handled error", {
204
+ segmentId: entry.shortCode,
205
+ message: notFoundInfo.message,
206
+ });
201
207
 
202
- setResponseStatus(404);
208
+ setResponseStatus(404);
203
209
 
204
- return createNotFoundSegment(
205
- notFoundInfo,
206
- notFoundFallback,
207
- entry,
208
- params,
209
- );
210
- }
210
+ return createNotFoundSegment(
211
+ notFoundInfo,
212
+ effectiveNotFoundFallback,
213
+ entry,
214
+ params,
215
+ );
211
216
  }
212
217
 
213
218
  const fallback = deps.findNearestErrorBoundary(entry);
@@ -41,8 +41,11 @@ import {
41
41
  } from "./helpers.js";
42
42
  import { getRouterContext } from "../router-context.js";
43
43
  import { resolveSink, safeEmit } from "../telemetry.js";
44
- import { track } from "../../server/context.js";
45
- import { RSCRouterContext } from "../../server/context.js";
44
+ import {
45
+ track,
46
+ RSCRouterContext,
47
+ runInsideLoaderScope,
48
+ } from "../../server/context.js";
46
49
 
47
50
  // ---------------------------------------------------------------------------
48
51
  // Telemetry helpers
@@ -233,7 +236,9 @@ export async function resolveLoadersWithRevalidation<TEnv>(
233
236
  params: ctx.params,
234
237
  loaderId: loader.$$id,
235
238
  loaderData: deps.wrapLoaderPromise(
236
- resolveLoaderData(loaderEntry, ctx, ctx.pathname),
239
+ runInsideLoaderScope(() =>
240
+ resolveLoaderData(loaderEntry, ctx, ctx.pathname),
241
+ ),
237
242
  entry,
238
243
  segmentId,
239
244
  ctx.pathname,
@@ -96,6 +96,7 @@ export interface SegmentResolutionDeps<TEnv = any> {
96
96
  findNearestNotFoundBoundary: (
97
97
  entry: EntryData | null,
98
98
  ) => ReactNode | NotFoundBoundaryHandler | null;
99
+ notFoundComponent?: ReactNode | ((props: { pathname: string }) => ReactNode);
99
100
  callOnError: (error: unknown, phase: ErrorPhase, context: any) => void;
100
101
  }
101
102
 
package/src/router.ts CHANGED
@@ -526,6 +526,7 @@ export function createRouter<TEnv = any>(
526
526
  trackHandler,
527
527
  findNearestErrorBoundary,
528
528
  findNearestNotFoundBoundary,
529
+ notFoundComponent: notFound,
529
530
  callOnError,
530
531
  };
531
532
 
@@ -14,10 +14,10 @@ import {
14
14
  runWithRequestContext,
15
15
  setRequestContextParams,
16
16
  requireRequestContext,
17
+ getRequestContext,
17
18
  createRequestContext,
18
19
  } from "../server/request-context.js";
19
20
  import * as rscDeps from "@vitejs/plugin-rsc/rsc";
20
-
21
21
  import type {
22
22
  RscPayload,
23
23
  CreateRSCHandlerOptions,
package/src/rsc/types.ts CHANGED
@@ -63,7 +63,9 @@ export interface RSCDependencies {
63
63
  */
64
64
  renderToReadableStream: <T>(
65
65
  payload: T,
66
- options?: { temporaryReferences?: unknown },
66
+ options?: {
67
+ temporaryReferences?: unknown;
68
+ },
67
69
  ) => ReadableStream<Uint8Array>;
68
70
 
69
71
  /**
@@ -670,11 +670,35 @@ export function track(label: string, depth?: number): () => void {
670
670
  };
671
671
  }
672
672
 
673
+ /**
674
+ * Separate ALS for tracking loader execution scope.
675
+ * Uses a dedicated ALS (not RSCRouterContext) to avoid issues with
676
+ * nested RSCRouterContext.run() calls in Vite's module runner.
677
+ */
678
+ const LOADER_SCOPE_KEY = Symbol.for("rangojs-router:loader-scope");
679
+ const loaderScopeALS: AsyncLocalStorage<{ active: true }> = ((
680
+ globalThis as any
681
+ )[LOADER_SCOPE_KEY] ??= new AsyncLocalStorage<{ active: true }>());
682
+
673
683
  /**
674
684
  * Check if the current execution is inside a cache() DSL boundary.
675
685
  * Returns false inside loader execution — loaders are always fresh
676
686
  * (never cached), so non-cacheable reads are safe.
677
687
  */
678
688
  export function isInsideCacheScope(): boolean {
679
- return RSCRouterContext.getStore()?.insideCacheScope === true;
689
+ if (RSCRouterContext.getStore()?.insideCacheScope !== true) return false;
690
+ // Loaders are always fresh — even inside a cache() boundary, the loader
691
+ // function re-executes on every request. Skip the guard when running
692
+ // inside a loader.
693
+ if (loaderScopeALS.getStore()?.active) return false;
694
+ return true;
695
+ }
696
+
697
+ /**
698
+ * Run `fn` inside a loader scope. While active, cache-scope guards
699
+ * are bypassed because loaders are always fresh (never cached) and
700
+ * their side effects (setCookie, header, etc.) are safe.
701
+ */
702
+ export function runInsideLoaderScope<T>(fn: () => T): T {
703
+ return loaderScopeALS.run({ active: true }, fn);
680
704
  }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * React Performance Tracks — RSDW client patch
3
+ *
4
+ * Patches the RSDW client so _debugInfo recovery works for plain-object
5
+ * payloads (our RscPayload shape). Without this, the Server Components
6
+ * track in Chrome DevTools stays empty.
7
+ *
8
+ * React's flushComponentPerformance uses splice(0) to empty _debugInfo
9
+ * after resolution, then recovers it from the resolved value — but only
10
+ * for arrays, async iterables, React elements, and lazy types. Since our
11
+ * RscPayload is a plain object, _debugInfo is lost. This patch relaxes
12
+ * the check so _debugInfo is recovered from any object.
13
+ */
14
+
15
+ import type { Plugin } from "vite";
16
+ import { readFile } from "node:fs/promises";
17
+
18
+ const RSDW_PATCH_RE =
19
+ /((?:var|let|const)\s+\w+\s*=\s*root\._children\s*,\s*(\w+)\s*=\s*root\._debugInfo\s*[;,])/;
20
+
21
+ function buildPatchReplacement(match: string, debugInfoVar: string): string {
22
+ return `${match}
23
+ if (${debugInfoVar} && 0 === ${debugInfoVar}.length && "fulfilled" === root.status) {
24
+ var _resolved = "function" === typeof resolveLazy ? resolveLazy(root.value) : root.value;
25
+ if ("object" === typeof _resolved && null !== _resolved && isArrayImpl(_resolved._debugInfo)) {
26
+ ${debugInfoVar} = _resolved._debugInfo;
27
+ }
28
+ }`;
29
+ }
30
+
31
+ export function patchRsdwClientDebugInfoRecovery(code: string): {
32
+ code: string;
33
+ debugInfoVar: string | null;
34
+ } {
35
+ const match = code.match(RSDW_PATCH_RE);
36
+ if (!match) {
37
+ return { code, debugInfoVar: null };
38
+ }
39
+
40
+ return {
41
+ code: code.replace(match[1]!, buildPatchReplacement(match[1]!, match[2]!)),
42
+ debugInfoVar: match[2]!,
43
+ };
44
+ }
45
+
46
+ export function performanceTracksOptimizeDepsPlugin(): {
47
+ name: string;
48
+ setup(build: any): void;
49
+ } {
50
+ return {
51
+ name: "@rangojs/router:performance-tracks-optimize-deps",
52
+ setup(build: any): void {
53
+ build.onLoad(
54
+ {
55
+ filter:
56
+ /react-server-dom-webpack-client\.browser\.(development|production)\.js$/,
57
+ },
58
+ async (args: { path: string }) => {
59
+ const code = await readFile(args.path, "utf8");
60
+ const patched = patchRsdwClientDebugInfoRecovery(code);
61
+ return {
62
+ contents: patched.code,
63
+ loader: "js",
64
+ };
65
+ },
66
+ );
67
+ },
68
+ };
69
+ }
70
+
71
+ export function performanceTracksPlugin(): Plugin {
72
+ return {
73
+ name: "@rangojs/router:performance-tracks",
74
+
75
+ transform(code, id) {
76
+ if (!id.includes("react-server-dom") || !id.includes("client")) return;
77
+ const patched = patchRsdwClientDebugInfoRecovery(code);
78
+ if (!patched.debugInfoVar) return;
79
+ if (process.env.INTERNAL_RANGO_DEBUG)
80
+ console.log(
81
+ "[perf-tracks] patched RSDW client (var:",
82
+ patched.debugInfoVar,
83
+ ")",
84
+ );
85
+ return patched.code;
86
+ },
87
+ };
88
+ }
package/src/vite/rango.ts CHANGED
@@ -26,6 +26,7 @@ import { printBanner, rangoVersion } from "./utils/banner.js";
26
26
  import { createVersionInjectorPlugin } from "./plugins/version-injector.js";
27
27
  import { createCjsToEsmPlugin } from "./plugins/cjs-to-esm.js";
28
28
  import { createRouterDiscoveryPlugin } from "./router-discovery.js";
29
+ import { performanceTracksPlugin } from "./plugins/performance-tracks.js";
29
30
 
30
31
  /**
31
32
  * Vite plugin for @rangojs/router.
@@ -60,7 +61,16 @@ export async function rango(options?: RangoOptions): Promise<PluginOption[]> {
60
61
 
61
62
  // Get package resolution info (workspace vs npm install)
62
63
  const rangoAliases = getPackageAliases();
63
- const excludeDeps = getExcludeDeps();
64
+ const excludeDeps = [
65
+ ...getExcludeDeps(),
66
+ // The public browser entry re-exports the RSDW browser client.
67
+ // Excluding both keeps Vite from freezing the unpatched bundle into
68
+ // .vite/deps before our source transforms run.
69
+ "@vitejs/plugin-rsc/browser",
70
+ // Keep the browser RSDW client out of Vite's dep optimizer so our
71
+ // cjs-to-esm transform can patch the real file.
72
+ "@vitejs/plugin-rsc/vendor/react-server-dom/client.browser",
73
+ ];
64
74
 
65
75
  // Mutable ref for router path (node preset only).
66
76
  // Set immediately when user-specified, or populated by the auto-discover
@@ -182,6 +192,9 @@ export async function rango(options?: RangoOptions): Promise<PluginOption[]> {
182
192
 
183
193
  plugins.push(createVirtualEntriesPlugin(finalEntries));
184
194
 
195
+ // Dev-only: RSDW client patch for React Performance Tracks
196
+ plugins.push(performanceTracksPlugin());
197
+
185
198
  // Add RSC plugin with cloudflare-specific options
186
199
  // Note: loadModuleDevProxy should NOT be used with childEnvironments
187
200
  // since SSR runs in workerd alongside RSC
@@ -334,6 +347,9 @@ export async function rango(options?: RangoOptions): Promise<PluginOption[]> {
334
347
  // Add virtual entries plugin (RSC entry generated lazily from routerRef)
335
348
  plugins.push(createVirtualEntriesPlugin(finalEntries, routerRef));
336
349
 
350
+ // Dev-only: RSDW client patch for React Performance Tracks
351
+ plugins.push(performanceTracksPlugin());
352
+
337
353
  plugins.push(
338
354
  rsc({
339
355
  entries: finalEntries,
@@ -1,6 +1,7 @@
1
1
  import type { Plugin } from "vite";
2
2
  import * as Vite from "vite";
3
3
  import { getPublishedPackageName } from "./package-resolution.js";
4
+ import { performanceTracksOptimizeDepsPlugin } from "../plugins/performance-tracks.js";
4
5
  import {
5
6
  VIRTUAL_ENTRY_BROWSER,
6
7
  VIRTUAL_ENTRY_SSR,
@@ -35,9 +36,9 @@ const versionEsbuildPlugin = {
35
36
  * Includes the version stub plugin for all environments.
36
37
  */
37
38
  export const sharedEsbuildOptions: {
38
- plugins: (typeof versionEsbuildPlugin)[];
39
+ plugins: any[];
39
40
  } = {
40
- plugins: [versionEsbuildPlugin],
41
+ plugins: [versionEsbuildPlugin, performanceTracksOptimizeDepsPlugin()],
41
42
  };
42
43
 
43
44
  /**