@rangojs/router 0.0.0-experimental.ffbe1b7f → 0.0.0-experimental.preview.1774275339

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.ffbe1b7f",
1748
+ version: "0.0.0-experimental.preview.1774275339",
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,258 @@ 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 DEBUG_S2C_EVENT = "rango:perf-s2c";
2791
+ var DEBUG_C2S_EVENT = "rango:perf-c2s";
2792
+ var DEBUG_RELAY_PREFIX = "/__rango_devtools__/perf";
2793
+ function getRegistry() {
2794
+ return globalThis.__RANGO_DEBUG_CHANNELS__ ??= {
2795
+ sessions: /* @__PURE__ */ new Map()
2796
+ };
2797
+ }
2798
+ var bytesToBase64 = (bytes) => Buffer.from(bytes).toString("base64");
2799
+ var base64ToBytes = (base64) => new Uint8Array(Buffer.from(base64, "base64"));
2800
+ var RSDW_PATCH_RE = /((?:var|let|const)\s+\w+\s*=\s*root\._children\s*,\s*(\w+)\s*=\s*root\._debugInfo\s*[;,])/;
2801
+ function buildPatchReplacement(match, debugInfoVar) {
2802
+ return `${match}
2803
+ if (${debugInfoVar} && 0 === ${debugInfoVar}.length && "fulfilled" === root.status) {
2804
+ var _resolved = "function" === typeof resolveLazy ? resolveLazy(root.value) : root.value;
2805
+ if ("object" === typeof _resolved && null !== _resolved && isArrayImpl(_resolved._debugInfo)) {
2806
+ ${debugInfoVar} = _resolved._debugInfo;
2807
+ }
2808
+ }`;
2809
+ }
2810
+ function patchRsdwClientDebugInfoRecovery(code) {
2811
+ const match = code.match(RSDW_PATCH_RE);
2812
+ if (!match) {
2813
+ return { code, debugInfoVar: null };
2814
+ }
2815
+ return {
2816
+ code: code.replace(
2817
+ match[1],
2818
+ buildPatchReplacement(match[1], match[2])
2819
+ ),
2820
+ debugInfoVar: match[2]
2821
+ };
2822
+ }
2823
+ function performanceTracksOptimizeDepsPlugin() {
2824
+ return {
2825
+ name: "@rangojs/router:performance-tracks-optimize-deps",
2826
+ setup(build) {
2827
+ build.onLoad(
2828
+ {
2829
+ filter: /react-server-dom-webpack-client\.browser\.(development|production)\.js$/
2830
+ },
2831
+ async (args) => {
2832
+ const code = await readFile(args.path, "utf8");
2833
+ const patched = patchRsdwClientDebugInfoRecovery(code);
2834
+ return {
2835
+ contents: patched.code,
2836
+ loader: "js"
2837
+ };
2838
+ }
2839
+ );
2840
+ }
2841
+ };
2842
+ }
2843
+ function performanceTracksPlugin() {
2844
+ return {
2845
+ name: "@rangojs/router:performance-tracks",
2846
+ // configureServer + transform — naturally dev-only
2847
+ transform(code, id) {
2848
+ if (!id.includes("react-server-dom") || !id.includes("client")) return;
2849
+ const patched = patchRsdwClientDebugInfoRecovery(code);
2850
+ if (!patched.debugInfoVar) return;
2851
+ console.log(
2852
+ "[perf-tracks] patched RSDW client for plain-object _debugInfo recovery (var:",
2853
+ patched.debugInfoVar,
2854
+ ")"
2855
+ );
2856
+ return patched.code;
2857
+ },
2858
+ configureServer(server) {
2859
+ console.log("[perf-tracks] plugin loaded, configureServer called");
2860
+ const hot = server.environments.client.hot;
2861
+ const registry = getRegistry();
2862
+ const sessions = registry.sessions;
2863
+ const getSession = (debugId) => {
2864
+ let session = sessions.get(debugId);
2865
+ if (!session) {
2866
+ session = {
2867
+ pendingClientChunks: [],
2868
+ pendingServerMessages: [],
2869
+ waiters: [],
2870
+ ready: false,
2871
+ ended: false,
2872
+ browserDoneSent: false
2873
+ };
2874
+ sessions.set(debugId, session);
2875
+ }
2876
+ return session;
2877
+ };
2878
+ const sendChunk = (debugId, chunk) => {
2879
+ hot.send(DEBUG_S2C_EVENT, {
2880
+ i: debugId,
2881
+ b: bytesToBase64(chunk)
2882
+ });
2883
+ };
2884
+ const flushBrowserDoneIfReady = (debugId, session) => {
2885
+ if (!session.ended || !session.ready || session.browserDoneSent) return;
2886
+ session.browserDoneSent = true;
2887
+ hot.send(DEBUG_S2C_EVENT, {
2888
+ i: debugId,
2889
+ d: true
2890
+ });
2891
+ };
2892
+ const closeRuntimeWaiters = (session) => {
2893
+ const waiters = session.waiters.splice(0);
2894
+ for (const waiter of waiters) {
2895
+ waiter({ type: "done" });
2896
+ }
2897
+ };
2898
+ const cleanupIfSettled = (debugId, session) => {
2899
+ if (!session.ended || !session.browserDoneSent || session.waiters.length > 0 || session.pendingServerMessages.length > 0) {
2900
+ return;
2901
+ }
2902
+ sessions.delete(debugId);
2903
+ };
2904
+ const readRequestBody = async (req) => {
2905
+ const chunks = [];
2906
+ for await (const chunk of req) {
2907
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
2908
+ }
2909
+ return new Uint8Array(Buffer.concat(chunks));
2910
+ };
2911
+ server.middlewares.use(async (req, res, next) => {
2912
+ const url = new URL(req.url || "/", "http://localhost");
2913
+ if (!url.pathname.startsWith(DEBUG_RELAY_PREFIX)) {
2914
+ next();
2915
+ return;
2916
+ }
2917
+ const debugId = url.searchParams.get("i");
2918
+ if (!debugId) {
2919
+ res.statusCode = 400;
2920
+ res.end("missing debugId");
2921
+ return;
2922
+ }
2923
+ const session = getSession(debugId);
2924
+ if (url.pathname === `${DEBUG_RELAY_PREFIX}/s2c`) {
2925
+ if (url.searchParams.get("done") === "1") {
2926
+ session.ended = true;
2927
+ closeRuntimeWaiters(session);
2928
+ flushBrowserDoneIfReady(debugId, session);
2929
+ cleanupIfSettled(debugId, session);
2930
+ res.statusCode = 204;
2931
+ res.end();
2932
+ return;
2933
+ }
2934
+ const chunk = await readRequestBody(req);
2935
+ if (chunk.byteLength <= 3) {
2936
+ console.log(
2937
+ "[perf-tracks] writable: chunk size:",
2938
+ chunk.byteLength,
2939
+ "for",
2940
+ debugId.slice(0, 8),
2941
+ session.ready ? "(sent)" : "(buffered)"
2942
+ );
2943
+ }
2944
+ if (session.ready) {
2945
+ sendChunk(debugId, chunk);
2946
+ } else {
2947
+ session.pendingClientChunks.push(chunk);
2948
+ }
2949
+ res.statusCode = 204;
2950
+ res.end();
2951
+ return;
2952
+ }
2953
+ if (url.pathname === `${DEBUG_RELAY_PREFIX}/c2s`) {
2954
+ const nextMessage = session.pendingServerMessages.shift();
2955
+ if (nextMessage) {
2956
+ res.setHeader("Content-Type", "application/json");
2957
+ res.end(
2958
+ JSON.stringify(
2959
+ nextMessage.type === "done" ? { d: true } : { b: bytesToBase64(nextMessage.chunk) }
2960
+ )
2961
+ );
2962
+ cleanupIfSettled(debugId, session);
2963
+ return;
2964
+ }
2965
+ if (session.ended) {
2966
+ res.setHeader("Content-Type", "application/json");
2967
+ res.end(JSON.stringify({ d: true }));
2968
+ cleanupIfSettled(debugId, session);
2969
+ return;
2970
+ }
2971
+ const timeout = setTimeout(() => {
2972
+ const index = session.waiters.indexOf(waiter);
2973
+ if (index >= 0) session.waiters.splice(index, 1);
2974
+ res.statusCode = 204;
2975
+ res.end();
2976
+ }, 3e4);
2977
+ const waiter = (message) => {
2978
+ clearTimeout(timeout);
2979
+ if (!message) {
2980
+ res.statusCode = 204;
2981
+ res.end();
2982
+ return;
2983
+ }
2984
+ res.setHeader("Content-Type", "application/json");
2985
+ res.end(
2986
+ JSON.stringify(
2987
+ message.type === "done" ? { d: true } : { b: bytesToBase64(message.chunk) }
2988
+ )
2989
+ );
2990
+ };
2991
+ session.waiters.push(waiter);
2992
+ return;
2993
+ }
2994
+ res.statusCode = 404;
2995
+ res.end();
2996
+ });
2997
+ hot.on(DEBUG_C2S_EVENT, (raw) => {
2998
+ const payload = raw;
2999
+ const session = getSession(payload.i);
3000
+ const pushServerMessage = (message) => {
3001
+ const waiter = session.waiters.shift();
3002
+ if (waiter) {
3003
+ waiter(message);
3004
+ } else {
3005
+ session.pendingServerMessages.push(message);
3006
+ }
3007
+ };
3008
+ if (payload.d) {
3009
+ pushServerMessage({ type: "done" });
3010
+ cleanupIfSettled(payload.i, session);
3011
+ return;
3012
+ }
3013
+ if (payload.b) {
3014
+ pushServerMessage({ type: "chunk", chunk: base64ToBytes(payload.b) });
3015
+ return;
3016
+ }
3017
+ session.ready = true;
3018
+ const pending = session.pendingClientChunks.length;
3019
+ console.log(
3020
+ "[perf-tracks] ready signal for",
3021
+ payload.i.slice(0, 8),
3022
+ "pending:",
3023
+ pending,
3024
+ "ended:",
3025
+ session.ended
3026
+ );
3027
+ for (const chunk of session.pendingClientChunks) {
3028
+ sendChunk(payload.i, chunk);
3029
+ }
3030
+ session.pendingClientChunks = [];
3031
+ flushBrowserDoneIfReady(payload.i, session);
3032
+ cleanupIfSettled(payload.i, session);
3033
+ });
3034
+ }
3035
+ };
3036
+ }
3037
+
3038
+ // src/vite/utils/shared-utils.ts
2787
3039
  var versionEsbuildPlugin = {
2788
3040
  name: "@rangojs/router-version",
2789
3041
  setup(build) {
@@ -2801,7 +3053,7 @@ var versionEsbuildPlugin = {
2801
3053
  }
2802
3054
  };
2803
3055
  var sharedEsbuildOptions = {
2804
- plugins: [versionEsbuildPlugin]
3056
+ plugins: [versionEsbuildPlugin, performanceTracksOptimizeDepsPlugin()]
2805
3057
  };
2806
3058
  function createVirtualEntriesPlugin(entries, routerPathRef) {
2807
3059
  const virtualModules = {};
@@ -4865,10 +5117,20 @@ ${details}`
4865
5117
  async function rango(options) {
4866
5118
  const resolvedOptions = options ?? { preset: "node" };
4867
5119
  const preset = resolvedOptions.preset ?? "node";
5120
+ console.log("[perf-tracks] rango() called, preset:", preset);
4868
5121
  const showBanner = resolvedOptions.banner ?? true;
4869
5122
  const plugins = [];
4870
5123
  const rangoAliases = getPackageAliases();
4871
- const excludeDeps = getExcludeDeps();
5124
+ const excludeDeps = [
5125
+ ...getExcludeDeps(),
5126
+ // The public browser entry re-exports the RSDW browser client.
5127
+ // Excluding both keeps Vite from freezing the unpatched bundle into
5128
+ // .vite/deps before our source transforms run.
5129
+ "@vitejs/plugin-rsc/browser",
5130
+ // Keep the browser RSDW client out of Vite's dep optimizer so our
5131
+ // cjs-to-esm and performance-tracks transforms can patch the real file.
5132
+ "@vitejs/plugin-rsc/vendor/react-server-dom/client.browser"
5133
+ ];
4872
5134
  const routerRef = { path: void 0 };
4873
5135
  const prerenderEnabled = true;
4874
5136
  if (preset === "cloudflare") {
@@ -4964,6 +5226,12 @@ async function rango(options) {
4964
5226
  }
4965
5227
  });
4966
5228
  plugins.push(createVirtualEntriesPlugin(finalEntries));
5229
+ const perfPlugin = performanceTracksPlugin();
5230
+ console.log(
5231
+ "[perf-tracks] rango: plugin created, has configureServer:",
5232
+ !!perfPlugin.configureServer
5233
+ );
5234
+ plugins.push(perfPlugin);
4967
5235
  plugins.push(
4968
5236
  rsc({
4969
5237
  entries: finalEntries,
@@ -5082,6 +5350,12 @@ ${list}`);
5082
5350
  }
5083
5351
  });
5084
5352
  plugins.push(createVirtualEntriesPlugin(finalEntries, routerRef));
5353
+ const perfPlugin = performanceTracksPlugin();
5354
+ console.log(
5355
+ "[perf-tracks] rango: plugin created, has configureServer:",
5356
+ !!perfPlugin.configureServer
5357
+ );
5358
+ plugins.push(perfPlugin);
5085
5359
  plugins.push(
5086
5360
  rsc({
5087
5361
  entries: finalEntries
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.0.0-experimental.ffbe1b7f",
3
+ "version": "0.0.0-experimental.preview.1774275339",
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
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Client-side debug channel for React Performance Tracks.
3
+ *
4
+ * Creates a bidirectional channel that communicates with the server-side
5
+ * debug channel via Vite's HMR WebSocket. Used with createFromFetch()
6
+ * so Chrome DevTools can display Server Components in the Performance tab.
7
+ *
8
+ * Dev-only — gated behind import.meta.hot.
9
+ */
10
+
11
+ export const DEBUG_ID_HEADER = "X-RSC-Debug-Id";
12
+ const DEBUG_S2C_EVENT = "rango:perf-s2c";
13
+ const DEBUG_C2S_EVENT = "rango:perf-c2s";
14
+
15
+ type DebugPayload =
16
+ | { i: string; b: string } // chunk (base64)
17
+ | { i: string; d: true }; // done
18
+
19
+ const bytesToBase64 = (bytes: Uint8Array) => {
20
+ let binary = "";
21
+ for (let i = 0; i < bytes.length; i++) {
22
+ binary += String.fromCharCode(bytes[i]!);
23
+ }
24
+ return btoa(binary);
25
+ };
26
+
27
+ const base64ToBytes = (base64: string) =>
28
+ Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
29
+
30
+ /**
31
+ * Create a client-side debug channel for the given debugId.
32
+ * The channel communicates with the server via Vite's HMR WebSocket.
33
+ */
34
+ export function createClientDebugChannel(debugId: string): {
35
+ readable: ReadableStream<Uint8Array>;
36
+ writable: WritableStream<Uint8Array>;
37
+ } | null {
38
+ const hot = (import.meta as any).hot;
39
+ if (!hot) return null;
40
+
41
+ let closed = false;
42
+ let onServerData: ((payload: DebugPayload) => void) | undefined;
43
+
44
+ const cleanup = (notify?: boolean) => {
45
+ if (closed) return;
46
+ closed = true;
47
+ if (onServerData) {
48
+ hot.off(DEBUG_S2C_EVENT, onServerData);
49
+ }
50
+ if (notify) {
51
+ hot.send(DEBUG_C2S_EVENT, { i: debugId, d: true } satisfies DebugPayload);
52
+ }
53
+ };
54
+
55
+ // Readable: receives server-to-client debug data via HMR WS
56
+ const readable = new ReadableStream<Uint8Array>({
57
+ start(controller) {
58
+ let chunks = 0;
59
+ onServerData = (payload: DebugPayload) => {
60
+ if (closed || payload.i !== debugId) return;
61
+ if ("b" in payload) {
62
+ chunks++;
63
+ if (chunks <= 3)
64
+ console.log(
65
+ "[perf-tracks] client readable: chunk #" + chunks,
66
+ "size:",
67
+ base64ToBytes(payload.b).byteLength,
68
+ );
69
+ controller.enqueue(base64ToBytes(payload.b));
70
+ }
71
+ if ("d" in payload) {
72
+ console.log(
73
+ "[perf-tracks] client readable: done after",
74
+ chunks,
75
+ "chunks",
76
+ );
77
+ cleanup();
78
+ controller.close();
79
+ }
80
+ };
81
+ hot.on(DEBUG_S2C_EVENT, onServerData);
82
+ // Send "ready" signal so the server flushes buffered debug data
83
+ console.log(
84
+ "[perf-tracks] client: sending ready signal for",
85
+ debugId.slice(0, 8),
86
+ );
87
+ hot.send(DEBUG_C2S_EVENT, { i: debugId });
88
+ },
89
+ cancel() {
90
+ cleanup(true);
91
+ },
92
+ });
93
+
94
+ // Writable: sends client-to-server commands via HMR WS
95
+ const writable = new WritableStream<Uint8Array>({
96
+ write(chunk) {
97
+ if (closed) throw new TypeError("Channel is closed");
98
+ hot.send(DEBUG_C2S_EVENT, {
99
+ i: debugId,
100
+ b: bytesToBase64(chunk),
101
+ } satisfies DebugPayload);
102
+ },
103
+ close() {
104
+ cleanup(true);
105
+ },
106
+ abort() {
107
+ cleanup(true);
108
+ },
109
+ });
110
+
111
+ return { readable, writable };
112
+ }
@@ -12,6 +12,8 @@ import {
12
12
  startBrowserTransaction,
13
13
  } from "./logging.js";
14
14
  import { getRangoState } from "./rango-state.js";
15
+ import { createClientDebugChannel, DEBUG_ID_HEADER } from "./debug-channel.js";
16
+ import { findSourceMapURL } from "../deps/browser.js";
15
17
  import {
16
18
  extractRscHeaderUrl,
17
19
  emptyResponse,
@@ -107,8 +109,25 @@ export function createNavigationClient(
107
109
  resolveStreamComplete = resolve;
108
110
  });
109
111
 
112
+ // Dev-only: debug channel is created lazily only for fresh fetches.
113
+ // Cached/inflight responses don't have a server-side channel, so passing
114
+ // debugChannel to createFromFetch would hang waiting for data that never comes.
115
+ let debugChannel: {
116
+ readable: ReadableStream<Uint8Array>;
117
+ writable: WritableStream<Uint8Array>;
118
+ } | null = null;
119
+
110
120
  /** Start a fresh navigation fetch (no cache / inflight hit). */
111
121
  const doFreshFetch = (): Promise<Response> => {
122
+ // Dev-only: create debug channel for React Performance Tracks
123
+ const debugId = (import.meta as any).hot
124
+ ? crypto.randomUUID()
125
+ : undefined;
126
+ if (debugId) {
127
+ debugChannel = createClientDebugChannel(debugId);
128
+ console.log("[perf-tracks] client: fresh fetch, debugId =", debugId);
129
+ }
130
+
112
131
  if (tx) {
113
132
  browserDebugLog(tx, "fetching", {
114
133
  path: `${fetchUrl.pathname}${fetchUrl.search}`,
@@ -124,6 +143,7 @@ export function createNavigationClient(
124
143
  "X-RSC-Router-Intercept-Source": interceptSourceUrl,
125
144
  }),
126
145
  ...(hmr && { "X-RSC-HMR": "1" }),
146
+ ...(debugId && { [DEBUG_ID_HEADER]: debugId }),
127
147
  },
128
148
  signal,
129
149
  }).then((response) => {
@@ -220,7 +240,10 @@ export function createNavigationClient(
220
240
 
221
241
  try {
222
242
  // Deserialize RSC payload
223
- const payload = await deps.createFromFetch<RscPayload>(responsePromise);
243
+ const payload = await deps.createFromFetch<RscPayload>(
244
+ responsePromise,
245
+ debugChannel ? { debugChannel, findSourceMapURL } : undefined,
246
+ );
224
247
  if (tx) {
225
248
  browserDebugLog(tx, "response received", {
226
249
  isPartial: payload.metadata?.isPartial,
@@ -11,6 +11,8 @@ import { createEventController } from "./event-controller.js";
11
11
  import { createNavigationClient } from "./navigation-client.js";
12
12
  import { createServerActionBridge } from "./server-action-bridge.js";
13
13
  import { createNavigationBridge } from "./navigation-bridge.js";
14
+ import { createClientDebugChannel } from "./debug-channel.js";
15
+ import { findSourceMapURL } from "../deps/browser.js";
14
16
  import { NavigationProvider } from "./react/index.js";
15
17
  import type {
16
18
  RscPayload,
@@ -139,9 +141,24 @@ export async function initBrowserApp(
139
141
  initialTheme,
140
142
  } = options;
141
143
 
144
+ // Dev-only: create debug channel for React Performance Tracks.
145
+ // The debugId was injected into the HTML bootstrap by the SSR handler.
146
+ // The client sends a "ready" signal so the server flushes buffered data.
147
+ const ssrDebugId = (globalThis as any).__RANGO_DEBUG_ID__ as
148
+ | string
149
+ | undefined;
150
+ const ssrDebugChannel =
151
+ ssrDebugId && (import.meta as any).hot
152
+ ? createClientDebugChannel(ssrDebugId)
153
+ : undefined;
154
+
142
155
  // Load initial payload from SSR-injected __FLIGHT_DATA__
143
- const initialPayload =
144
- await deps.createFromReadableStream<RscPayload>(rscStream);
156
+ const initialPayload = await deps.createFromReadableStream<RscPayload>(
157
+ rscStream,
158
+ ssrDebugChannel
159
+ ? { debugChannel: ssrDebugChannel, findSourceMapURL }
160
+ : undefined,
161
+ );
145
162
 
146
163
  // Extract themeConfig and initialTheme from payload if not explicitly provided
147
164
  // This allows virtual entries to work without importing the router
@@ -4,6 +4,8 @@ import type {
4
4
  RscPayload,
5
5
  } from "./types.js";
6
6
  import { createPartialUpdater } from "./partial-update.js";
7
+ import { createClientDebugChannel, DEBUG_ID_HEADER } from "./debug-channel.js";
8
+ import { findSourceMapURL } from "../deps/browser.js";
7
9
  import { createNavigationTransaction } from "./navigation-transaction.js";
8
10
  import {
9
11
  reconcileSegments,
@@ -199,6 +201,14 @@ export function createServerActionBridge(
199
201
  const onHandleAbort = () => fetchAbort.abort();
200
202
  handle.signal.addEventListener("abort", onHandleAbort, { once: true });
201
203
 
204
+ // Dev-only: create debug channel for React Performance Tracks
205
+ const debugId = (import.meta as any).hot
206
+ ? crypto.randomUUID()
207
+ : undefined;
208
+ const debugChannel = debugId
209
+ ? createClientDebugChannel(debugId)
210
+ : undefined;
211
+
202
212
  // Send action request with stream tracking
203
213
  const responsePromise = fetch(url, {
204
214
  method: "POST",
@@ -210,6 +220,7 @@ export function createServerActionBridge(
210
220
  ...(interceptSourceUrl && {
211
221
  "X-RSC-Router-Intercept-Source": interceptSourceUrl,
212
222
  }),
223
+ ...(debugId && { [DEBUG_ID_HEADER]: debugId }),
213
224
  },
214
225
  body: encodedBody,
215
226
  signal: fetchAbort.signal,
@@ -272,6 +283,7 @@ export function createServerActionBridge(
272
283
  try {
273
284
  payload = await deps.createFromFetch<RscPayload>(responsePromise, {
274
285
  temporaryReferences,
286
+ ...(debugChannel && { debugChannel, findSourceMapURL }),
275
287
  });
276
288
  } catch (error) {
277
289
  // Clean up streaming token on error (may be null if fetch failed before .then() ran)