@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.
- package/dist/vite/index.js +77 -4
- package/package.json +2 -2
- package/skills/loader/SKILL.md +1 -1
- package/skills/router-setup/SKILL.md +52 -2
- package/src/browser/navigation-client.ts +1 -1
- package/src/browser/rsc-router.tsx +0 -1
- package/src/browser/server-action-bridge.ts +0 -2
- package/src/browser/types.ts +7 -1
- package/src/router/intercept-resolution.ts +3 -2
- package/src/router/segment-resolution/fresh.ts +11 -3
- package/src/router/segment-resolution/helpers.ts +29 -24
- package/src/router/segment-resolution/revalidation.ts +8 -3
- package/src/router/types.ts +1 -0
- package/src/router.ts +1 -0
- package/src/rsc/handler.ts +1 -1
- package/src/rsc/types.ts +3 -1
- package/src/server/context.ts +25 -1
- package/src/vite/plugins/performance-tracks.ts +88 -0
- package/src/vite/rango.ts +17 -1
- package/src/vite/utils/shared-utils.ts +3 -2
package/dist/vite/index.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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 =
|
|
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.
|
|
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.
|
|
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"
|
package/skills/loader/SKILL.md
CHANGED
|
@@ -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
|
|
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,
|
|
@@ -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.
|
package/src/browser/types.ts
CHANGED
|
@@ -341,7 +341,13 @@ export type ReadonlyURLSearchParams = Omit<
|
|
|
341
341
|
export interface RscBrowserDependencies {
|
|
342
342
|
createFromFetch: <T>(
|
|
343
343
|
response: Promise<Response>,
|
|
344
|
-
options?: {
|
|
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 {
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
);
|
|
191
|
+
const notFoundInfo = createNotFoundInfo(
|
|
192
|
+
error,
|
|
193
|
+
entry.shortCode,
|
|
194
|
+
entry.type,
|
|
195
|
+
pathname,
|
|
196
|
+
);
|
|
191
197
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
198
|
+
reportError(true, {
|
|
199
|
+
notFound: true,
|
|
200
|
+
message: notFoundInfo.message,
|
|
201
|
+
});
|
|
196
202
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
203
|
+
debugLog("segment", "notFound boundary handled error", {
|
|
204
|
+
segmentId: entry.shortCode,
|
|
205
|
+
message: notFoundInfo.message,
|
|
206
|
+
});
|
|
201
207
|
|
|
202
|
-
|
|
208
|
+
setResponseStatus(404);
|
|
203
209
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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 {
|
|
45
|
-
|
|
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
|
-
|
|
239
|
+
runInsideLoaderScope(() =>
|
|
240
|
+
resolveLoaderData(loaderEntry, ctx, ctx.pathname),
|
|
241
|
+
),
|
|
237
242
|
entry,
|
|
238
243
|
segmentId,
|
|
239
244
|
ctx.pathname,
|
package/src/router/types.ts
CHANGED
|
@@ -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
package/src/rsc/handler.ts
CHANGED
|
@@ -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
package/src/server/context.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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:
|
|
39
|
+
plugins: any[];
|
|
39
40
|
} = {
|
|
40
|
-
plugins: [versionEsbuildPlugin],
|
|
41
|
+
plugins: [versionEsbuildPlugin, performanceTracksOptimizeDepsPlugin()],
|
|
41
42
|
};
|
|
42
43
|
|
|
43
44
|
/**
|