@rangojs/router 0.0.0-experimental.69 → 0.0.0-experimental.6c70a2ab
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/README.md +112 -17
- package/dist/vite/index.js +1456 -467
- package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/package.json +7 -5
- package/skills/breadcrumbs/SKILL.md +3 -1
- package/skills/handler-use/SKILL.md +364 -0
- package/skills/hooks/SKILL.md +54 -20
- package/skills/i18n/SKILL.md +276 -0
- package/skills/intercept/SKILL.md +45 -0
- package/skills/layout/SKILL.md +24 -0
- package/skills/links/SKILL.md +234 -16
- package/skills/loader/SKILL.md +70 -3
- package/skills/middleware/SKILL.md +34 -3
- package/skills/migrate-nextjs/SKILL.md +562 -0
- package/skills/migrate-react-router/SKILL.md +769 -0
- package/skills/parallel/SKILL.md +68 -0
- package/skills/rango/SKILL.md +26 -22
- package/skills/response-routes/SKILL.md +8 -0
- package/skills/route/SKILL.md +48 -0
- package/skills/server-actions/SKILL.md +739 -0
- package/skills/streams-and-websockets/SKILL.md +283 -0
- package/skills/typesafety/SKILL.md +9 -1
- package/skills/view-transitions/SKILL.md +212 -0
- package/src/browser/app-shell.ts +52 -0
- package/src/browser/event-controller.ts +44 -4
- package/src/browser/navigation-bridge.ts +80 -5
- package/src/browser/navigation-client.ts +64 -13
- package/src/browser/navigation-store.ts +25 -1
- package/src/browser/partial-update.ts +58 -12
- package/src/browser/prefetch/cache.ts +129 -21
- package/src/browser/prefetch/fetch.ts +148 -16
- package/src/browser/prefetch/queue.ts +36 -5
- package/src/browser/rango-state.ts +53 -13
- package/src/browser/react/Link.tsx +30 -2
- package/src/browser/react/NavigationProvider.tsx +70 -18
- package/src/browser/react/filter-segment-order.ts +51 -7
- package/src/browser/react/index.ts +3 -0
- package/src/browser/react/use-navigation.ts +22 -2
- package/src/browser/react/use-params.ts +17 -4
- package/src/browser/react/use-reverse.ts +99 -0
- package/src/browser/react/use-router.ts +8 -1
- package/src/browser/react/use-segments.ts +11 -8
- package/src/browser/rsc-router.tsx +34 -6
- package/src/browser/scroll-restoration.ts +22 -14
- package/src/browser/segment-reconciler.ts +36 -14
- package/src/browser/types.ts +19 -0
- package/src/build/route-trie.ts +52 -25
- package/src/cache/cf/cf-cache-store.ts +5 -7
- package/src/client.rsc.tsx +3 -0
- package/src/client.tsx +87 -175
- package/src/href-client.ts +4 -1
- package/src/index.rsc.ts +3 -0
- package/src/index.ts +40 -9
- package/src/outlet-context.ts +1 -1
- package/src/response-utils.ts +28 -0
- package/src/reverse.ts +62 -36
- package/src/route-definition/dsl-helpers.ts +175 -23
- package/src/route-definition/helpers-types.ts +63 -14
- package/src/route-definition/resolve-handler-use.ts +6 -0
- package/src/route-types.ts +7 -0
- package/src/router/handler-context.ts +21 -38
- package/src/router/lazy-includes.ts +6 -6
- package/src/router/loader-resolution.ts +3 -0
- package/src/router/manifest.ts +22 -13
- package/src/router/match-api.ts +4 -3
- package/src/router/match-handlers.ts +1 -0
- package/src/router/match-middleware/cache-lookup.ts +2 -1
- package/src/router/match-result.ts +101 -4
- package/src/router/middleware-types.ts +14 -25
- package/src/router/middleware.ts +54 -7
- package/src/router/pattern-matching.ts +101 -17
- package/src/router/revalidation.ts +15 -1
- package/src/router/segment-resolution/fresh.ts +13 -0
- package/src/router/segment-resolution/revalidation.ts +135 -101
- package/src/router/substitute-pattern-params.ts +56 -0
- package/src/router/trie-matching.ts +18 -13
- package/src/router/url-params.ts +49 -0
- package/src/router.ts +1 -2
- package/src/rsc/handler.ts +16 -8
- package/src/rsc/helpers.ts +69 -41
- package/src/rsc/progressive-enhancement.ts +4 -0
- package/src/rsc/response-route-handler.ts +14 -1
- package/src/rsc/rsc-rendering.ts +10 -0
- package/src/rsc/server-action.ts +4 -0
- package/src/rsc/types.ts +6 -0
- package/src/segment-content-promise.ts +67 -0
- package/src/segment-loader-promise.ts +122 -0
- package/src/segment-system.tsx +71 -70
- package/src/server/context.ts +26 -3
- package/src/server/request-context.ts +10 -42
- package/src/ssr/index.tsx +5 -1
- package/src/types/handler-context.ts +12 -39
- package/src/types/loader-types.ts +5 -6
- package/src/types/request-scope.ts +126 -0
- package/src/types/route-entry.ts +11 -0
- package/src/types/segments.ts +18 -1
- package/src/urls/include-helper.ts +24 -14
- package/src/urls/path-helper-types.ts +30 -4
- package/src/urls/response-types.ts +2 -10
- package/src/use-loader.tsx +4 -1
- package/src/vite/debug.ts +184 -0
- package/src/vite/discovery/discover-routers.ts +31 -3
- package/src/vite/discovery/gate-state.ts +171 -0
- package/src/vite/discovery/prerender-collection.ts +172 -84
- package/src/vite/discovery/self-gen-tracking.ts +27 -1
- package/src/vite/plugins/cjs-to-esm.ts +5 -0
- package/src/vite/plugins/client-ref-dedup.ts +16 -0
- package/src/vite/plugins/client-ref-hashing.ts +16 -4
- package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
- package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
- package/src/vite/plugins/expose-action-id.ts +52 -28
- package/src/vite/plugins/expose-id-utils.ts +12 -0
- package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
- package/src/vite/plugins/expose-internal-ids.ts +540 -376
- package/src/vite/plugins/performance-tracks.ts +17 -9
- package/src/vite/plugins/use-cache-transform.ts +56 -43
- package/src/vite/plugins/version-injector.ts +37 -11
- package/src/vite/rango.ts +49 -14
- package/src/vite/router-discovery.ts +558 -53
- package/src/vite/utils/banner.ts +1 -1
- package/src/vite/utils/package-resolution.ts +41 -1
- package/src/vite/utils/prerender-utils.ts +21 -6
package/src/rsc/handler.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
setRequestContextParams,
|
|
16
16
|
requireRequestContext,
|
|
17
17
|
getRequestContext,
|
|
18
|
+
_getRequestContext,
|
|
18
19
|
createRequestContext,
|
|
19
20
|
} from "../server/request-context.js";
|
|
20
21
|
import * as rscDeps from "@vitejs/plugin-rsc/rsc";
|
|
@@ -30,6 +31,7 @@ import {
|
|
|
30
31
|
interceptRedirectForPartial,
|
|
31
32
|
buildRouteMiddlewareEntries,
|
|
32
33
|
} from "./helpers.js";
|
|
34
|
+
import { isWebSocketUpgradeResponse } from "../response-utils.js";
|
|
33
35
|
import {
|
|
34
36
|
handleResponseRoute,
|
|
35
37
|
type ResponseRouteMatch,
|
|
@@ -55,6 +57,7 @@ import {
|
|
|
55
57
|
getRouterTrie,
|
|
56
58
|
} from "../route-map-builder.js";
|
|
57
59
|
import type { HandlerContext } from "./handler-context.js";
|
|
60
|
+
import type { SegmentCacheStore } from "../cache/types.js";
|
|
58
61
|
import { buildRouterTrieFromUrlpatterns } from "./manifest-init.js";
|
|
59
62
|
import { handleProgressiveEnhancement } from "./progressive-enhancement.js";
|
|
60
63
|
import {
|
|
@@ -166,10 +169,13 @@ export function createRSCHandler<
|
|
|
166
169
|
phase: ErrorPhase,
|
|
167
170
|
context: Parameters<typeof invokeOnError<TEnv>>[3],
|
|
168
171
|
): void {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
172
|
+
// Guard: abort signal handlers fire asynchronously outside the ALS
|
|
173
|
+
// request scope, so the context may be gone. Skip dedup in that
|
|
174
|
+
// case — the error is from a cancelled stream, not a real failure.
|
|
175
|
+
const reqCtx = _getRequestContext();
|
|
176
|
+
if (error != null && typeof error === "object" && reqCtx) {
|
|
177
|
+
if (reqCtx._reportedErrors.has(error)) return;
|
|
178
|
+
reqCtx._reportedErrors.add(error);
|
|
173
179
|
}
|
|
174
180
|
invokeOnError(router.onError, error, phase, context, "RSC");
|
|
175
181
|
}
|
|
@@ -348,7 +354,7 @@ export function createRSCHandler<
|
|
|
348
354
|
// Resolve cache store configuration
|
|
349
355
|
// Priority: options.cache (handler override) > router.cache (router default)
|
|
350
356
|
// Store is enabled only if: config provided, enabled, and no ?__no_cache query param
|
|
351
|
-
let cacheStore
|
|
357
|
+
let cacheStore: SegmentCacheStore | undefined;
|
|
352
358
|
const cacheOption = options.cache ?? router.cache;
|
|
353
359
|
if (cacheOption && !url.searchParams.has("__no_cache")) {
|
|
354
360
|
const cacheConfig =
|
|
@@ -529,7 +535,9 @@ export function createRSCHandler<
|
|
|
529
535
|
}
|
|
530
536
|
|
|
531
537
|
const fullTiming = timingParts.join(", ");
|
|
532
|
-
if (fullTiming
|
|
538
|
+
if (fullTiming && !isWebSocketUpgradeResponse(response)) {
|
|
539
|
+
response.headers.set("Server-Timing", fullTiming);
|
|
540
|
+
}
|
|
533
541
|
|
|
534
542
|
return response;
|
|
535
543
|
});
|
|
@@ -800,7 +808,7 @@ export function createRSCHandler<
|
|
|
800
808
|
);
|
|
801
809
|
}
|
|
802
810
|
const response = responseOutcome.result;
|
|
803
|
-
if (plan.negotiated) {
|
|
811
|
+
if (plan.negotiated && !isWebSocketUpgradeResponse(response)) {
|
|
804
812
|
response.headers.append("Vary", "Accept");
|
|
805
813
|
}
|
|
806
814
|
return response;
|
|
@@ -1010,7 +1018,7 @@ export function createRSCHandler<
|
|
|
1010
1018
|
nonce,
|
|
1011
1019
|
);
|
|
1012
1020
|
}
|
|
1013
|
-
if (negotiated) {
|
|
1021
|
+
if (negotiated && !isWebSocketUpgradeResponse(response)) {
|
|
1014
1022
|
response.headers.append("Vary", "Accept");
|
|
1015
1023
|
}
|
|
1016
1024
|
return response;
|
package/src/rsc/helpers.ts
CHANGED
|
@@ -8,9 +8,49 @@ import {
|
|
|
8
8
|
_getRequestContext,
|
|
9
9
|
getLocationState,
|
|
10
10
|
} from "../server/request-context.js";
|
|
11
|
+
import type { RequestContext } from "../server/request-context.js";
|
|
11
12
|
import { resolveLocationStateEntries } from "../browser/react/location-state-shared.js";
|
|
12
13
|
import type { MiddlewareEntry, MiddlewareFn } from "../router/middleware.js";
|
|
13
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Copy stub headers from the request context onto a target Headers instance:
|
|
17
|
+
* append Set-Cookie entries, set everything else only if absent. Header
|
|
18
|
+
* mutation failures are swallowed so the same logic works against Response
|
|
19
|
+
* headers that may be immutable (e.g. Cloudflare protocol-switch responses).
|
|
20
|
+
*/
|
|
21
|
+
function applyStubHeaders(target: Headers, stub: Headers): void {
|
|
22
|
+
stub.forEach((value, name) => {
|
|
23
|
+
try {
|
|
24
|
+
if (name.toLowerCase() === "set-cookie") {
|
|
25
|
+
target.append(name, value);
|
|
26
|
+
} else if (!target.has(name)) {
|
|
27
|
+
target.set(name, value);
|
|
28
|
+
}
|
|
29
|
+
} catch {
|
|
30
|
+
// Headers immutable — skip.
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Drain ctx._onResponseCallbacks onto a response. Swapping the array before
|
|
37
|
+
* iteration prevents re-entrant registrations from double-firing and matches
|
|
38
|
+
* the contract that each callback runs at most once per request.
|
|
39
|
+
*/
|
|
40
|
+
function drainOnResponseCallbacks(
|
|
41
|
+
ctx: RequestContext,
|
|
42
|
+
response: Response,
|
|
43
|
+
): Response {
|
|
44
|
+
const callbacks = ctx._onResponseCallbacks;
|
|
45
|
+
if (callbacks.length === 0) return response;
|
|
46
|
+
ctx._onResponseCallbacks = [];
|
|
47
|
+
let result = response;
|
|
48
|
+
for (const callback of callbacks) {
|
|
49
|
+
result = callback(result) ?? result;
|
|
50
|
+
}
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
|
|
14
54
|
/**
|
|
15
55
|
* Check if a request body has content to decode
|
|
16
56
|
*/
|
|
@@ -39,40 +79,23 @@ export function createResponseWithMergedHeaders(
|
|
|
39
79
|
return new Response(body, init);
|
|
40
80
|
}
|
|
41
81
|
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
// merge points (e.g. executeMiddleware) do not duplicate them.
|
|
82
|
+
// Delete Set-Cookie from the stub after consuming so downstream merge
|
|
83
|
+
// points (e.g. executeMiddleware) don't duplicate them.
|
|
45
84
|
const mergedHeaders = new Headers(init.headers);
|
|
46
|
-
ctx.res.headers
|
|
47
|
-
if (name.toLowerCase() === "set-cookie") {
|
|
48
|
-
mergedHeaders.append(name, value);
|
|
49
|
-
} else if (!mergedHeaders.has(name)) {
|
|
50
|
-
// Only set if not already present in init.headers
|
|
51
|
-
mergedHeaders.set(name, value);
|
|
52
|
-
}
|
|
53
|
-
});
|
|
85
|
+
applyStubHeaders(mergedHeaders, ctx.res.headers);
|
|
54
86
|
ctx.res.headers.delete("set-cookie");
|
|
55
87
|
|
|
56
|
-
//
|
|
57
|
-
//
|
|
88
|
+
// ctx.res.status overrides init.status when explicitly set (e.g. 404 for
|
|
89
|
+
// notFound, 500 for error). Default ctx.res.status is 200.
|
|
58
90
|
const status = ctx.res.status !== 200 ? ctx.res.status : init.status;
|
|
59
91
|
|
|
60
|
-
|
|
92
|
+
const response = new Response(body, {
|
|
61
93
|
...init,
|
|
62
94
|
status,
|
|
63
95
|
headers: mergedHeaders,
|
|
64
96
|
});
|
|
65
97
|
|
|
66
|
-
|
|
67
|
-
// Drain the array so that downstream callers (e.g. finalizeResponse)
|
|
68
|
-
// do not re-execute the same callbacks on this response.
|
|
69
|
-
const callbacks = ctx._onResponseCallbacks;
|
|
70
|
-
ctx._onResponseCallbacks = [];
|
|
71
|
-
for (const callback of callbacks) {
|
|
72
|
-
response = callback(response) ?? response;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
return response;
|
|
98
|
+
return drainOnResponseCallbacks(ctx, response);
|
|
76
99
|
}
|
|
77
100
|
|
|
78
101
|
/**
|
|
@@ -175,24 +198,29 @@ export function buildRouteMiddlewareEntries<TEnv>(
|
|
|
175
198
|
}
|
|
176
199
|
|
|
177
200
|
/**
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
201
|
+
* Merge stub headers from the request context onto an existing Response in
|
|
202
|
+
* place, then drain onResponse callbacks. Used when a Response cannot flow
|
|
203
|
+
* through `new Response()` — status 101 is outside the constructor's
|
|
204
|
+
* 200-599 range, and the Cloudflare-specific `webSocket` property would be
|
|
205
|
+
* lost on reconstruction.
|
|
183
206
|
*/
|
|
184
|
-
export function
|
|
207
|
+
export function mergeStubHeadersAndFinalize(response: Response): Response {
|
|
185
208
|
const ctx = _getRequestContext();
|
|
186
|
-
if (!ctx
|
|
187
|
-
return response;
|
|
188
|
-
}
|
|
209
|
+
if (!ctx) return response;
|
|
189
210
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
211
|
+
applyStubHeaders(response.headers, ctx.res.headers);
|
|
212
|
+
ctx.res.headers.delete("set-cookie");
|
|
213
|
+
|
|
214
|
+
return drainOnResponseCallbacks(ctx, response);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Run onResponse callbacks on an existing Response. Used by code paths that
|
|
219
|
+
* bypass createResponseWithMergedHeaders (e.g. middleware short-circuits)
|
|
220
|
+
* but still need ctx.onResponse() callbacks to fire.
|
|
221
|
+
*/
|
|
222
|
+
export function finalizeResponse(response: Response): Response {
|
|
223
|
+
const ctx = _getRequestContext();
|
|
224
|
+
if (!ctx) return response;
|
|
225
|
+
return drainOnResponseCallbacks(ctx, response);
|
|
198
226
|
}
|
|
@@ -248,6 +248,8 @@ export async function handleProgressiveEnhancement<TEnv>(
|
|
|
248
248
|
segments: match.segments,
|
|
249
249
|
matched: match.matched,
|
|
250
250
|
diff: match.diff,
|
|
251
|
+
resolvedIds: match.resolvedIds,
|
|
252
|
+
params: match.params,
|
|
251
253
|
isPartial: false,
|
|
252
254
|
rootLayout: ctx.router.rootLayout,
|
|
253
255
|
handles: handleStore.stream(),
|
|
@@ -353,6 +355,8 @@ async function renderPeErrorBoundary<TEnv>(
|
|
|
353
355
|
segments: errorResult.segments,
|
|
354
356
|
matched: errorResult.matched,
|
|
355
357
|
diff: errorResult.diff,
|
|
358
|
+
resolvedIds: errorResult.resolvedIds,
|
|
359
|
+
params: errorResult.params,
|
|
356
360
|
isPartial: false,
|
|
357
361
|
isError: true,
|
|
358
362
|
rootLayout: ctx.router.rootLayout,
|
|
@@ -26,7 +26,9 @@ import {
|
|
|
26
26
|
finalizeResponse,
|
|
27
27
|
isCacheableStatus,
|
|
28
28
|
buildRouteMiddlewareEntries,
|
|
29
|
+
mergeStubHeadersAndFinalize,
|
|
29
30
|
} from "./helpers.js";
|
|
31
|
+
import { isWebSocketUpgradeResponse } from "../response-utils.js";
|
|
30
32
|
|
|
31
33
|
export interface ResponseRouteMatch {
|
|
32
34
|
responseType: string;
|
|
@@ -78,10 +80,13 @@ export async function handleResponseRoute<TEnv>(
|
|
|
78
80
|
env,
|
|
79
81
|
searchParams: cleanUrl.searchParams,
|
|
80
82
|
url: cleanUrl,
|
|
83
|
+
originalUrl: reqCtx.originalUrl,
|
|
81
84
|
pathname: url.pathname,
|
|
82
85
|
reverse: createReverseFunction(handlerCtx.getRequiredRouteMap()),
|
|
83
86
|
get: ((keyOrVar: any) => contextGet(variables, keyOrVar)) as any,
|
|
84
87
|
header: (name: string, value: string) => reqCtx.header(name, value),
|
|
88
|
+
waitUntil: reqCtx.waitUntil.bind(reqCtx),
|
|
89
|
+
executionContext: reqCtx.executionContext,
|
|
85
90
|
_responseType: preview.responseType,
|
|
86
91
|
};
|
|
87
92
|
// Brand with taint symbol so "use cache" detects it as request-scoped
|
|
@@ -96,6 +101,12 @@ export async function handleResponseRoute<TEnv>(
|
|
|
96
101
|
// so that stub headers (cookies, custom headers set via ctx.header()) are included.
|
|
97
102
|
// Use Headers (not Record<string, string>) to preserve duplicate entries like Set-Cookie.
|
|
98
103
|
const rewrapResponse = (result: Response) => {
|
|
104
|
+
// 204/205/304 are NOT short-circuited — they're valid for the Response
|
|
105
|
+
// constructor and must honor ctx.setStatus() overrides. Only upgrade
|
|
106
|
+
// responses (status 101 / `webSocket` property) bypass reconstruction.
|
|
107
|
+
if (isWebSocketUpgradeResponse(result)) {
|
|
108
|
+
return mergeStubHeadersAndFinalize(result);
|
|
109
|
+
}
|
|
99
110
|
const headers = new Headers();
|
|
100
111
|
result.headers.forEach((value, key) => {
|
|
101
112
|
if (key.toLowerCase() === "set-cookie") {
|
|
@@ -196,7 +207,9 @@ export async function handleResponseRoute<TEnv>(
|
|
|
196
207
|
// Wrap callHandler to append Vary: Accept on content-negotiated responses
|
|
197
208
|
const callHandlerWithVary = async () => {
|
|
198
209
|
const response = await callHandler();
|
|
199
|
-
if (preview.negotiated) {
|
|
210
|
+
if (preview.negotiated && !isWebSocketUpgradeResponse(response)) {
|
|
211
|
+
// Skip Vary on upgrade responses: headers are semantically immutable
|
|
212
|
+
// on some runtimes, and Vary is meaningless for a 101 response.
|
|
200
213
|
response.headers.append("Vary", "Accept");
|
|
201
214
|
}
|
|
202
215
|
return response;
|
package/src/rsc/rsc-rendering.ts
CHANGED
|
@@ -59,6 +59,7 @@ export async function handleRscRendering<TEnv>(
|
|
|
59
59
|
segments: match.segments,
|
|
60
60
|
matched: match.matched,
|
|
61
61
|
diff: match.diff,
|
|
62
|
+
resolvedIds: match.resolvedIds,
|
|
62
63
|
params: match.params,
|
|
63
64
|
isPartial: false,
|
|
64
65
|
rootLayout: ctx.router.rootLayout,
|
|
@@ -81,6 +82,7 @@ export async function handleRscRendering<TEnv>(
|
|
|
81
82
|
segments: result.segments,
|
|
82
83
|
matched: result.matched,
|
|
83
84
|
diff: result.diff,
|
|
85
|
+
resolvedIds: result.resolvedIds,
|
|
84
86
|
params: result.params,
|
|
85
87
|
isPartial: true,
|
|
86
88
|
slots: result.slots,
|
|
@@ -144,6 +146,7 @@ export async function handleRscRendering<TEnv>(
|
|
|
144
146
|
segments: match.segments,
|
|
145
147
|
matched: match.matched,
|
|
146
148
|
diff: match.diff,
|
|
149
|
+
resolvedIds: match.resolvedIds,
|
|
147
150
|
params: match.params,
|
|
148
151
|
isPartial: false,
|
|
149
152
|
rootLayout: ctx.router.rootLayout,
|
|
@@ -204,6 +207,13 @@ export async function handleRscRendering<TEnv>(
|
|
|
204
207
|
"content-type": "text/x-component;charset=utf-8",
|
|
205
208
|
vary: "accept, X-Rango-State, X-RSC-Router-Client-Path",
|
|
206
209
|
};
|
|
210
|
+
// Tell the client's prefetch cache to scope this response to its source
|
|
211
|
+
// URL (instead of the default source-agnostic wildcard). Intercept
|
|
212
|
+
// responses depend on the source page matching an intercept rule, so
|
|
213
|
+
// they must not be reused for navigations from other sources.
|
|
214
|
+
if (hasInterceptSlots) {
|
|
215
|
+
rscHeaders["x-rsc-prefetch-scope"] = "source";
|
|
216
|
+
}
|
|
207
217
|
// Enable browser HTTP caching for prefetch responses only.
|
|
208
218
|
// Requires X-Rango-Prefetch header (sent by Link prefetch fetch),
|
|
209
219
|
// non-intercept context (intercept responses depend on source page),
|
package/src/rsc/server-action.ts
CHANGED
|
@@ -213,6 +213,8 @@ export async function executeServerAction<TEnv>(
|
|
|
213
213
|
isPartial: true,
|
|
214
214
|
matched: errorResult.matched,
|
|
215
215
|
diff: errorResult.diff,
|
|
216
|
+
resolvedIds: errorResult.resolvedIds,
|
|
217
|
+
params: errorResult.params,
|
|
216
218
|
isError: true,
|
|
217
219
|
handles: handleStore.stream(),
|
|
218
220
|
version: ctx.version,
|
|
@@ -323,6 +325,8 @@ export async function revalidateAfterAction<TEnv>(
|
|
|
323
325
|
isPartial: true,
|
|
324
326
|
matched: matchResult.matched,
|
|
325
327
|
diff: matchResult.diff,
|
|
328
|
+
resolvedIds: matchResult.resolvedIds,
|
|
329
|
+
params: matchResult.params,
|
|
326
330
|
slots: matchResult.slots,
|
|
327
331
|
handles: handleStore.stream(),
|
|
328
332
|
version: ctx.version,
|
package/src/rsc/types.ts
CHANGED
|
@@ -26,6 +26,12 @@ export interface RscPayload {
|
|
|
26
26
|
isError?: boolean;
|
|
27
27
|
matched?: string[];
|
|
28
28
|
diff?: string[];
|
|
29
|
+
/**
|
|
30
|
+
* All segment ids re-resolved on the server, including null-component
|
|
31
|
+
* ones excluded from `segments`/`diff`. Drives client-side handle-bucket
|
|
32
|
+
* cleanup. Superset of `diff`. See MatchResult.resolvedIds.
|
|
33
|
+
*/
|
|
34
|
+
resolvedIds?: string[];
|
|
29
35
|
/** Merged route params from the matched route */
|
|
30
36
|
params?: Record<string, string>;
|
|
31
37
|
slots?: Record<string, SlotState>;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Stable Promise wrappers keyed on the component itself. Objects (React
|
|
5
|
+
* elements, functions, lazy payloads) land in a WeakMap so entries GC when
|
|
6
|
+
* the underlying component is released; primitives (string, number, boolean,
|
|
7
|
+
* null) land in a Map so memoization still applies to text-/null-backed
|
|
8
|
+
* segments like those in partial-update flows. Keeping this cache outside
|
|
9
|
+
* the segment eliminates preservation fields on ResolvedSegment — it survives
|
|
10
|
+
* reconciliation naturally because the component ref is what's stable.
|
|
11
|
+
*
|
|
12
|
+
* Browser-only. On the server each SSR render needs a fresh pending promise
|
|
13
|
+
* so Suspense can emit the loading fallback HTML before content streams. A
|
|
14
|
+
* shared already-resolved promise has `.status === "fulfilled"` attached by
|
|
15
|
+
* React on its first observation — subsequent `use()` calls return
|
|
16
|
+
* synchronously without suspending, so the Suspense fallback never makes it
|
|
17
|
+
* into the initial HTML. Route-definition components share refs across
|
|
18
|
+
* requests, so a global cache would leak tracked state between renders.
|
|
19
|
+
*/
|
|
20
|
+
const IS_BROWSER = typeof window !== "undefined";
|
|
21
|
+
const objectContentCache = IS_BROWSER
|
|
22
|
+
? new WeakMap<object, Promise<ReactNode>>()
|
|
23
|
+
: null;
|
|
24
|
+
const primitiveContentCache = IS_BROWSER
|
|
25
|
+
? new Map<unknown, Promise<ReactNode>>()
|
|
26
|
+
: null;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Return a stable Promise wrapping `component`, memoized on the component ref.
|
|
30
|
+
*
|
|
31
|
+
* A fresh `Promise.resolve(component)` each render would suspend for one
|
|
32
|
+
* microtask and briefly commit the loading fallback inside Suspender — the
|
|
33
|
+
* intercept / parallel-slot flicker this indirection prevents. Reusing the
|
|
34
|
+
* same Promise ref keeps React's `use()` in "known fulfilled" state after
|
|
35
|
+
* the first observation.
|
|
36
|
+
*
|
|
37
|
+
* @internal
|
|
38
|
+
*/
|
|
39
|
+
export function getMemoizedContentPromise(
|
|
40
|
+
component: ReactNode,
|
|
41
|
+
): Promise<ReactNode> {
|
|
42
|
+
if (component instanceof Promise) {
|
|
43
|
+
return component as Promise<ReactNode>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!objectContentCache || !primitiveContentCache) {
|
|
47
|
+
return Promise.resolve(component);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (component !== null && typeof component === "object") {
|
|
51
|
+
const cached = objectContentCache.get(component);
|
|
52
|
+
if (cached) {
|
|
53
|
+
return cached;
|
|
54
|
+
}
|
|
55
|
+
const promise = Promise.resolve(component);
|
|
56
|
+
objectContentCache.set(component, promise);
|
|
57
|
+
return promise;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const cached = primitiveContentCache.get(component);
|
|
61
|
+
if (cached) {
|
|
62
|
+
return cached;
|
|
63
|
+
}
|
|
64
|
+
const promise = Promise.resolve(component);
|
|
65
|
+
primitiveContentCache.set(component, promise);
|
|
66
|
+
return promise;
|
|
67
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { ResolvedSegment } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cache of aggregate Promise.all results keyed on the first loader's
|
|
5
|
+
* `loaderData` reference. Each entry holds the source refs it was built from
|
|
6
|
+
* plus the resulting Promise/array; lookup scans entries for the matching
|
|
7
|
+
* source array (typically a single entry, since distinct loader groups rarely
|
|
8
|
+
* share a first source). Object first-refs live in a WeakMap (auto-GC);
|
|
9
|
+
* primitive first-refs (strings/numbers/booleans/null) live in a Map so
|
|
10
|
+
* loaders that resolve to primitive data are memoized too — bounded in
|
|
11
|
+
* practice by the application's loader set.
|
|
12
|
+
*
|
|
13
|
+
* Keying externally means reconciliation's fresh segment objects no longer
|
|
14
|
+
* drop memoization — the cache survives as long as the underlying loader
|
|
15
|
+
* segments do, and GC collects entries when those loaders are released
|
|
16
|
+
* (object keys only).
|
|
17
|
+
*
|
|
18
|
+
* Browser-only. On the server each SSR render needs a fresh Promise so
|
|
19
|
+
* Suspense can actually suspend and emit the loading fallback HTML before
|
|
20
|
+
* content streams. A shared already-resolved promise has `.status` attached
|
|
21
|
+
* by React on first `use()`; subsequent observations return synchronously
|
|
22
|
+
* and skip the fallback. The zero-loader case is especially prone because
|
|
23
|
+
* every empty-loader site would otherwise share one promise across requests.
|
|
24
|
+
*/
|
|
25
|
+
const IS_BROWSER = typeof window !== "undefined";
|
|
26
|
+
|
|
27
|
+
interface LoaderCacheEntry {
|
|
28
|
+
sources: any[];
|
|
29
|
+
promise: Promise<any[]> | any[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const objectLoaderCache = IS_BROWSER
|
|
33
|
+
? new WeakMap<object, LoaderCacheEntry[]>()
|
|
34
|
+
: null;
|
|
35
|
+
const primitiveLoaderCache = IS_BROWSER
|
|
36
|
+
? new Map<unknown, LoaderCacheEntry[]>()
|
|
37
|
+
: null;
|
|
38
|
+
|
|
39
|
+
// In the browser, a single shared empty aggregate is safe (and desirable) —
|
|
40
|
+
// reusing the same resolved promise keeps React's `use()` in a known-fulfilled
|
|
41
|
+
// state across renders. On the server it would leak `.status = "fulfilled"`
|
|
42
|
+
// across requests and skip the Suspense fallback, so we rebuild on each call.
|
|
43
|
+
const SHARED_EMPTY_LOADER_PROMISE: Promise<any[]> | null = IS_BROWSER
|
|
44
|
+
? Promise.resolve([])
|
|
45
|
+
: null;
|
|
46
|
+
|
|
47
|
+
function hasSameReferences(a: any[], b: any[]): boolean {
|
|
48
|
+
if (a.length !== b.length) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
for (let i = 0; i < a.length; i++) {
|
|
52
|
+
if (a[i] !== b[i]) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function buildLoaderPromise(loaders: ResolvedSegment[]): Promise<any[]> {
|
|
60
|
+
if (loaders.length === 0) {
|
|
61
|
+
return Promise.resolve([]);
|
|
62
|
+
}
|
|
63
|
+
return Promise.all(
|
|
64
|
+
loaders.map((loader) =>
|
|
65
|
+
loader.loaderData instanceof Promise
|
|
66
|
+
? loader.loaderData
|
|
67
|
+
: Promise.resolve(loader.loaderData),
|
|
68
|
+
),
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isObjectLike(value: unknown): value is object {
|
|
73
|
+
return (
|
|
74
|
+
value !== null && (typeof value === "object" || typeof value === "function")
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Memoize an aggregate Promise.all for a set of loader segments. Reusing the
|
|
80
|
+
* same aggregate across renders — invalidated only when any underlying
|
|
81
|
+
* loader.loaderData ref changes — keeps React's `use()` in "known fulfilled"
|
|
82
|
+
* state and prevents a fresh Promise.all from suspending (and briefly
|
|
83
|
+
* committing the Suspense fallback) on every partial update that doesn't
|
|
84
|
+
* actually change loader data.
|
|
85
|
+
*
|
|
86
|
+
* @internal
|
|
87
|
+
*/
|
|
88
|
+
export function getMemoizedLoaderPromise(
|
|
89
|
+
loaders: ResolvedSegment[],
|
|
90
|
+
): Promise<any[]> | any[] {
|
|
91
|
+
if (loaders.length === 0) {
|
|
92
|
+
return SHARED_EMPTY_LOADER_PROMISE ?? buildLoaderPromise(loaders);
|
|
93
|
+
}
|
|
94
|
+
if (!objectLoaderCache || !primitiveLoaderCache) {
|
|
95
|
+
return buildLoaderPromise(loaders);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const sources = loaders.map((loader) => loader.loaderData);
|
|
99
|
+
const first = sources[0];
|
|
100
|
+
const entries = isObjectLike(first)
|
|
101
|
+
? objectLoaderCache.get(first)
|
|
102
|
+
: primitiveLoaderCache.get(first);
|
|
103
|
+
|
|
104
|
+
if (entries) {
|
|
105
|
+
for (const entry of entries) {
|
|
106
|
+
if (hasSameReferences(entry.sources, sources)) {
|
|
107
|
+
return entry.promise;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const promise = buildLoaderPromise(loaders);
|
|
113
|
+
const newEntry: LoaderCacheEntry = { sources, promise };
|
|
114
|
+
if (entries) {
|
|
115
|
+
entries.push(newEntry);
|
|
116
|
+
} else if (isObjectLike(first)) {
|
|
117
|
+
objectLoaderCache.set(first, [newEntry]);
|
|
118
|
+
} else {
|
|
119
|
+
primitiveLoaderCache.set(first, [newEntry]);
|
|
120
|
+
}
|
|
121
|
+
return promise;
|
|
122
|
+
}
|