@rangojs/router 0.0.0-experimental.88a3b2f7 → 0.0.0-experimental.8bcfea43
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 +50 -20
- package/dist/vite/index.js +647 -176
- 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 +362 -0
- package/skills/hooks/SKILL.md +28 -20
- package/skills/intercept/SKILL.md +20 -0
- package/skills/layout/SKILL.md +22 -0
- package/skills/links/SKILL.md +88 -16
- package/skills/loader/SKILL.md +35 -2
- package/skills/middleware/SKILL.md +32 -3
- package/skills/migrate-nextjs/SKILL.md +560 -0
- package/skills/migrate-react-router/SKILL.md +765 -0
- package/skills/parallel/SKILL.md +59 -0
- package/skills/rango/SKILL.md +24 -22
- package/skills/response-routes/SKILL.md +8 -0
- package/skills/route/SKILL.md +24 -0
- package/skills/streams-and-websockets/SKILL.md +283 -0
- package/skills/typesafety/SKILL.md +3 -1
- package/src/browser/app-shell.ts +52 -0
- package/src/browser/navigation-bridge.ts +72 -4
- package/src/browser/navigation-client.ts +64 -13
- package/src/browser/navigation-store.ts +25 -1
- package/src/browser/partial-update.ts +34 -3
- 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 +50 -11
- package/src/browser/react/use-navigation.ts +22 -2
- package/src/browser/react/use-params.ts +11 -1
- package/src/browser/react/use-router.ts +8 -1
- package/src/browser/rsc-router.tsx +34 -6
- package/src/browser/segment-reconciler.ts +36 -14
- package/src/browser/types.ts +13 -0
- package/src/build/route-trie.ts +50 -24
- package/src/cache/cf/cf-cache-store.ts +5 -7
- package/src/client.tsx +84 -230
- package/src/index.rsc.ts +3 -0
- package/src/index.ts +44 -9
- package/src/outlet-context.ts +1 -1
- package/src/response-utils.ts +28 -0
- package/src/reverse.ts +7 -3
- package/src/route-definition/dsl-helpers.ts +180 -24
- package/src/route-definition/helpers-types.ts +61 -14
- package/src/route-definition/resolve-handler-use.ts +6 -0
- package/src/route-types.ts +7 -0
- package/src/router/handler-context.ts +24 -4
- package/src/router/lazy-includes.ts +6 -6
- package/src/router/loader-resolution.ts +73 -46
- package/src/router/manifest.ts +22 -13
- package/src/router/match-api.ts +3 -3
- package/src/router/match-middleware/cache-lookup.ts +10 -5
- package/src/router/match-middleware/segment-resolution.ts +1 -1
- package/src/router/match-result.ts +82 -4
- package/src/router/middleware-types.ts +2 -22
- package/src/router/middleware.ts +32 -4
- package/src/router/pattern-matching.ts +60 -9
- package/src/router/segment-resolution/fresh.ts +52 -0
- package/src/router/segment-resolution/revalidation.ts +69 -1
- package/src/router/trie-matching.ts +10 -4
- package/src/router/url-params.ts +49 -0
- package/src/router.ts +1 -2
- package/src/rsc/handler.ts +21 -9
- package/src/rsc/helpers.ts +69 -41
- package/src/rsc/loader-fetch.ts +23 -3
- package/src/rsc/progressive-enhancement.ts +12 -2
- package/src/rsc/response-route-handler.ts +14 -1
- package/src/rsc/rsc-rendering.ts +12 -1
- package/src/rsc/server-action.ts +8 -0
- package/src/rsc/types.ts +1 -0
- package/src/segment-content-promise.ts +67 -0
- package/src/segment-loader-promise.ts +122 -0
- package/src/segment-system.tsx +11 -61
- package/src/server/context.ts +26 -3
- package/src/server/handle-store.ts +19 -0
- package/src/server/request-context.ts +64 -56
- package/src/types/handler-context.ts +2 -34
- 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 +1 -1
- package/src/urls/include-helper.ts +24 -14
- package/src/urls/path-helper-types.ts +34 -5
- package/src/urls/response-types.ts +2 -10
- package/src/use-loader.tsx +77 -5
- package/src/vite/debug.ts +55 -0
- package/src/vite/discovery/prerender-collection.ts +124 -83
- 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-id-utils.ts +12 -0
- package/src/vite/plugins/expose-ids/handler-transform.ts +30 -0
- package/src/vite/plugins/expose-internal-ids.ts +257 -40
- package/src/vite/plugins/performance-tracks.ts +4 -6
- package/src/vite/rango.ts +49 -14
- package/src/vite/router-discovery.ts +186 -26
- package/src/vite/utils/banner.ts +1 -1
- package/src/vite/utils/package-resolution.ts +41 -1
- package/src/vite/utils/prerender-utils.ts +20 -6
package/src/rsc/rsc-rendering.ts
CHANGED
|
@@ -173,7 +173,11 @@ export async function handleRscRendering<TEnv>(
|
|
|
173
173
|
|
|
174
174
|
// Serialize to RSC stream
|
|
175
175
|
const rscSerializeStart = performance.now();
|
|
176
|
-
const rscStream = ctx.renderToReadableStream<RscPayload>(payload
|
|
176
|
+
const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
|
|
177
|
+
onError: (error: unknown) => {
|
|
178
|
+
ctx.callOnError(error, "rendering", { request, url, env });
|
|
179
|
+
},
|
|
180
|
+
});
|
|
177
181
|
const rscSerializeDur = performance.now() - rscSerializeStart;
|
|
178
182
|
// This measures synchronous stream creation, not end-to-end stream consumption.
|
|
179
183
|
appendMetric(
|
|
@@ -200,6 +204,13 @@ export async function handleRscRendering<TEnv>(
|
|
|
200
204
|
"content-type": "text/x-component;charset=utf-8",
|
|
201
205
|
vary: "accept, X-Rango-State, X-RSC-Router-Client-Path",
|
|
202
206
|
};
|
|
207
|
+
// Tell the client's prefetch cache to scope this response to its source
|
|
208
|
+
// URL (instead of the default source-agnostic wildcard). Intercept
|
|
209
|
+
// responses depend on the source page matching an intercept rule, so
|
|
210
|
+
// they must not be reused for navigations from other sources.
|
|
211
|
+
if (hasInterceptSlots) {
|
|
212
|
+
rscHeaders["x-rsc-prefetch-scope"] = "source";
|
|
213
|
+
}
|
|
203
214
|
// Enable browser HTTP caching for prefetch responses only.
|
|
204
215
|
// Requires X-Rango-Prefetch header (sent by Link prefetch fetch),
|
|
205
216
|
// non-intercept context (intercept responses depend on source page),
|
package/src/rsc/server-action.ts
CHANGED
|
@@ -213,6 +213,7 @@ export async function executeServerAction<TEnv>(
|
|
|
213
213
|
isPartial: true,
|
|
214
214
|
matched: errorResult.matched,
|
|
215
215
|
diff: errorResult.diff,
|
|
216
|
+
params: errorResult.params,
|
|
216
217
|
isError: true,
|
|
217
218
|
handles: handleStore.stream(),
|
|
218
219
|
version: ctx.version,
|
|
@@ -226,6 +227,9 @@ export async function executeServerAction<TEnv>(
|
|
|
226
227
|
|
|
227
228
|
const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
|
|
228
229
|
temporaryReferences,
|
|
230
|
+
onError: (error: unknown) => {
|
|
231
|
+
ctx.callOnError(error, "rendering", { request, url, env });
|
|
232
|
+
},
|
|
229
233
|
});
|
|
230
234
|
|
|
231
235
|
return createResponseWithMergedHeaders(rscStream, {
|
|
@@ -320,6 +324,7 @@ export async function revalidateAfterAction<TEnv>(
|
|
|
320
324
|
isPartial: true,
|
|
321
325
|
matched: matchResult.matched,
|
|
322
326
|
diff: matchResult.diff,
|
|
327
|
+
params: matchResult.params,
|
|
323
328
|
slots: matchResult.slots,
|
|
324
329
|
handles: handleStore.stream(),
|
|
325
330
|
version: ctx.version,
|
|
@@ -332,6 +337,9 @@ export async function revalidateAfterAction<TEnv>(
|
|
|
332
337
|
const renderStart = performance.now();
|
|
333
338
|
const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
|
|
334
339
|
temporaryReferences,
|
|
340
|
+
onError: (error: unknown) => {
|
|
341
|
+
ctx.callOnError(error, "rendering", { request, url, env });
|
|
342
|
+
},
|
|
335
343
|
});
|
|
336
344
|
const rscSerializeDur = performance.now() - renderStart;
|
|
337
345
|
// This measures synchronous stream creation, not end-to-end stream consumption.
|
package/src/rsc/types.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/segment-system.tsx
CHANGED
|
@@ -2,11 +2,7 @@ import * as React from "react";
|
|
|
2
2
|
import { createElement, type ReactNode, type ComponentType } from "react";
|
|
3
3
|
import { OutletProvider } from "./client.js";
|
|
4
4
|
import { MountContextProvider } from "./browser/react/mount-context.js";
|
|
5
|
-
import type {
|
|
6
|
-
ResolvedSegment,
|
|
7
|
-
LoaderDataResult,
|
|
8
|
-
RootLayoutProps,
|
|
9
|
-
} from "./types.js";
|
|
5
|
+
import type { ResolvedSegment, RootLayoutProps } from "./types.js";
|
|
10
6
|
import { isLoaderDataResult } from "./types.js";
|
|
11
7
|
import { invariant } from "./errors.js";
|
|
12
8
|
import {
|
|
@@ -14,6 +10,8 @@ import {
|
|
|
14
10
|
LoaderBoundary,
|
|
15
11
|
} from "./route-content-wrapper.js";
|
|
16
12
|
import { RootErrorBoundary } from "./root-error-boundary.js";
|
|
13
|
+
import { getMemoizedContentPromise } from "./segment-content-promise.js";
|
|
14
|
+
import { getMemoizedLoaderPromise } from "./segment-loader-promise.js";
|
|
17
15
|
|
|
18
16
|
// ViewTransition is only available in React experimental.
|
|
19
17
|
// Access via namespace import to avoid compile-time errors on stable React.
|
|
@@ -61,20 +59,6 @@ function restoreParallelLoaderMarkers(
|
|
|
61
59
|
return nextSegments ?? segments;
|
|
62
60
|
}
|
|
63
61
|
|
|
64
|
-
function hasSameReferences(a: unknown[] | undefined, b: unknown[]): boolean {
|
|
65
|
-
if (!a || a.length !== b.length) {
|
|
66
|
-
return false;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
for (let i = 0; i < a.length; i++) {
|
|
70
|
-
if (a[i] !== b[i]) {
|
|
71
|
-
return false;
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
return true;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
62
|
/**
|
|
79
63
|
* Resolve loader data from raw results, unwrapping LoaderDataResult wrappers
|
|
80
64
|
*/
|
|
@@ -278,10 +262,7 @@ export async function renderSegments(
|
|
|
278
262
|
loading !== null && loading !== undefined && loading !== false
|
|
279
263
|
? createElement(RouteContentWrapper, {
|
|
280
264
|
key: `suspense-loading-${id}`,
|
|
281
|
-
content:
|
|
282
|
-
resolvedComponent instanceof Promise
|
|
283
|
-
? resolvedComponent
|
|
284
|
-
: Promise.resolve(resolvedComponent),
|
|
265
|
+
content: getMemoizedContentPromise(resolvedComponent),
|
|
285
266
|
fallback: loading,
|
|
286
267
|
segmentId: id,
|
|
287
268
|
})
|
|
@@ -305,16 +286,7 @@ export async function renderSegments(
|
|
|
305
286
|
|
|
306
287
|
// Prepare loader data if there are loaders
|
|
307
288
|
const loaderIds = loaderEntries.map((loader) => loader.loaderId!);
|
|
308
|
-
const loaderDataPromise =
|
|
309
|
-
loaderEntries.length > 0
|
|
310
|
-
? Promise.all(
|
|
311
|
-
loaderEntries.map((loader) =>
|
|
312
|
-
loader.loaderData instanceof Promise
|
|
313
|
-
? loader.loaderData
|
|
314
|
-
: Promise.resolve(loader.loaderData),
|
|
315
|
-
),
|
|
316
|
-
)
|
|
317
|
-
: Promise.resolve([]);
|
|
289
|
+
const loaderDataPromise = getMemoizedLoaderPromise(loaderEntries);
|
|
318
290
|
|
|
319
291
|
// Use LoaderBoundary when loading is defined to maintain consistent tree structure
|
|
320
292
|
// This ensures cached segments (which may not have loader segments) have the same
|
|
@@ -396,34 +368,12 @@ export async function renderSegments(
|
|
|
396
368
|
continue;
|
|
397
369
|
}
|
|
398
370
|
|
|
399
|
-
|
|
400
|
-
const
|
|
401
|
-
p.
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
hasSameReferences(p.parallelLoaderSources, parallelLoaderSources);
|
|
406
|
-
|
|
407
|
-
const parallelLoaderDataPromise = shouldReuseParallelPromise
|
|
408
|
-
? p.loaderDataPromise
|
|
409
|
-
: forceAwait || isAction
|
|
410
|
-
? await Promise.all(
|
|
411
|
-
ownedLoaders.map((l) =>
|
|
412
|
-
l.loaderData instanceof Promise
|
|
413
|
-
? l.loaderData
|
|
414
|
-
: Promise.resolve(l.loaderData),
|
|
415
|
-
),
|
|
416
|
-
)
|
|
417
|
-
: Promise.all(
|
|
418
|
-
ownedLoaders.map((l) =>
|
|
419
|
-
l.loaderData instanceof Promise
|
|
420
|
-
? l.loaderData
|
|
421
|
-
: Promise.resolve(l.loaderData),
|
|
422
|
-
),
|
|
423
|
-
);
|
|
424
|
-
|
|
425
|
-
p.loaderDataPromise = parallelLoaderDataPromise;
|
|
426
|
-
p.parallelLoaderSources = parallelLoaderSources;
|
|
371
|
+
p.loaderIds = ownedLoaders.map((l) => l.loaderId!);
|
|
372
|
+
const aggregated = getMemoizedLoaderPromise(ownedLoaders);
|
|
373
|
+
p.loaderDataPromise =
|
|
374
|
+
(forceAwait || isAction) && aggregated instanceof Promise
|
|
375
|
+
? await aggregated
|
|
376
|
+
: aggregated;
|
|
427
377
|
}
|
|
428
378
|
}
|
|
429
379
|
|
package/src/server/context.ts
CHANGED
|
@@ -280,6 +280,22 @@ interface HelperContext {
|
|
|
280
280
|
/** True when resolving handlers inside a cache() DSL boundary.
|
|
281
281
|
* Read by ctx.get() to guard non-cacheable variable reads. */
|
|
282
282
|
insideCacheScope?: boolean;
|
|
283
|
+
/**
|
|
284
|
+
* Include scope string applied to direct-descendant shortCodes.
|
|
285
|
+
*
|
|
286
|
+
* Each `include(...)` call allocates a sibling-positional token like `I0`,
|
|
287
|
+
* `I1` from its parent's include counter and stores the composed scope
|
|
288
|
+
* (`${parentScope}I${idx}`) in its lazyContext. When the include's handler
|
|
289
|
+
* evaluates lazily, the store's `includeScope` is set from that context so
|
|
290
|
+
* every direct-descendant shortCode is generated as
|
|
291
|
+
* `${parent.shortCode}${includeScope}${prefix}${index}` — preventing
|
|
292
|
+
* collisions with siblings declared outside the include.
|
|
293
|
+
*
|
|
294
|
+
* The scope is NOT propagated through `store.run(...)`, so layouts /
|
|
295
|
+
* parallels / caches inside the include absorb the scope into their own
|
|
296
|
+
* shortCodes and their children start fresh.
|
|
297
|
+
*/
|
|
298
|
+
includeScope?: string;
|
|
283
299
|
}
|
|
284
300
|
// Use a global symbol key so the AsyncLocalStorage instance survives HMR
|
|
285
301
|
// module re-evaluation. Without this, Vite's RSC module runner may create
|
|
@@ -382,6 +398,8 @@ export const getContext = (): {
|
|
|
382
398
|
const mountPrefix =
|
|
383
399
|
store.mountIndex !== undefined ? `M${store.mountIndex}` : "";
|
|
384
400
|
|
|
401
|
+
const includeScope = store.includeScope ?? "";
|
|
402
|
+
|
|
385
403
|
if (!parent) {
|
|
386
404
|
// Root entry: prefix with mount index and use mount-scoped counter
|
|
387
405
|
const counterKey = mountPrefix
|
|
@@ -392,12 +410,16 @@ export const getContext = (): {
|
|
|
392
410
|
store.counters[counterKey] = index + 1;
|
|
393
411
|
return `${mountPrefix}${prefix}${index}`;
|
|
394
412
|
} else {
|
|
395
|
-
// Child entry: use parent-scoped counter
|
|
396
|
-
|
|
413
|
+
// Child entry: use parent-scoped counter with includeScope appended.
|
|
414
|
+
// When we're evaluating a lazy include's direct children, includeScope
|
|
415
|
+
// is a per-include token like "I0" / "I1I0" that partitions the
|
|
416
|
+
// parent's counter namespace so routes inside one include cannot
|
|
417
|
+
// collide with siblings declared outside it.
|
|
418
|
+
const counterKey = `${parent.shortCode}${includeScope}_${type}`;
|
|
397
419
|
store.counters[counterKey] ??= 0;
|
|
398
420
|
const index = store.counters[counterKey];
|
|
399
421
|
store.counters[counterKey] = index + 1;
|
|
400
|
-
return `${parent.shortCode}${prefix}${index}`;
|
|
422
|
+
return `${parent.shortCode}${includeScope}${prefix}${index}`;
|
|
401
423
|
}
|
|
402
424
|
},
|
|
403
425
|
runWithStore: <T>(
|
|
@@ -424,6 +446,7 @@ export const getContext = (): {
|
|
|
424
446
|
rootScoped: store.rootScoped,
|
|
425
447
|
trackedIncludes: store.trackedIncludes,
|
|
426
448
|
cacheProfiles: store.cacheProfiles,
|
|
449
|
+
includeScope: store.includeScope,
|
|
427
450
|
},
|
|
428
451
|
callback,
|
|
429
452
|
);
|
|
@@ -13,6 +13,25 @@
|
|
|
13
13
|
*/
|
|
14
14
|
export type HandleData = Record<string, Record<string, unknown[]>>;
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Build a HandleData snapshot from a HandleStore using segment ordering.
|
|
18
|
+
* Reads data directly from the store for each segment in order.
|
|
19
|
+
*/
|
|
20
|
+
export function buildHandleSnapshot(
|
|
21
|
+
handleStore: HandleStore,
|
|
22
|
+
segmentOrder: string[],
|
|
23
|
+
): HandleData {
|
|
24
|
+
const data: HandleData = {};
|
|
25
|
+
for (const segmentId of segmentOrder) {
|
|
26
|
+
const segData = handleStore.getDataForSegment(segmentId);
|
|
27
|
+
for (const handleName in segData) {
|
|
28
|
+
if (!data[handleName]) data[handleName] = {};
|
|
29
|
+
data[handleName][segmentId] = segData[handleName];
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return data;
|
|
33
|
+
}
|
|
34
|
+
|
|
16
35
|
function createLateHandlePushError(
|
|
17
36
|
handleName: string,
|
|
18
37
|
segmentId: string,
|
|
@@ -26,12 +26,19 @@ import {
|
|
|
26
26
|
contextSet,
|
|
27
27
|
isNonCacheable,
|
|
28
28
|
} from "../context-var.js";
|
|
29
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
createHandleStore,
|
|
31
|
+
buildHandleSnapshot,
|
|
32
|
+
type HandleStore,
|
|
33
|
+
type HandleData,
|
|
34
|
+
} from "./handle-store.js";
|
|
30
35
|
import { isHandle } from "../handle.js";
|
|
31
36
|
import { track, type MetricsStore } from "./context.js";
|
|
32
37
|
import { getFetchableLoader } from "./fetchable-loader-store.js";
|
|
33
38
|
import type { SegmentCacheStore } from "../cache/types.js";
|
|
34
39
|
import type { Theme, ResolvedThemeConfig } from "../theme/types.js";
|
|
40
|
+
import type { ExecutionContext, RequestScope } from "../types/request-scope.js";
|
|
41
|
+
import { fireAndForgetWaitUntil } from "../types/request-scope.js";
|
|
35
42
|
import { THEME_COOKIE } from "../theme/constants.js";
|
|
36
43
|
import type { LocationStateEntry } from "../browser/react/location-state-shared.js";
|
|
37
44
|
import { NOCACHE_SYMBOL, assertNotInsideCacheExec } from "../cache/taint.js";
|
|
@@ -53,22 +60,7 @@ import { isAutoGeneratedRouteName } from "../route-name.js";
|
|
|
53
60
|
export interface RequestContext<
|
|
54
61
|
TEnv = DefaultEnv,
|
|
55
62
|
TParams = Record<string, string>,
|
|
56
|
-
> {
|
|
57
|
-
/** Platform bindings (Cloudflare env, etc.) */
|
|
58
|
-
env: TEnv;
|
|
59
|
-
/** Original HTTP request */
|
|
60
|
-
request: Request;
|
|
61
|
-
/** Parsed URL (with internal `_rsc*` params stripped) */
|
|
62
|
-
url: URL;
|
|
63
|
-
/**
|
|
64
|
-
* The original request URL with all parameters intact, including
|
|
65
|
-
* internal `_rsc*` transport params.
|
|
66
|
-
*/
|
|
67
|
-
originalUrl: URL;
|
|
68
|
-
/** URL pathname */
|
|
69
|
-
pathname: string;
|
|
70
|
-
/** URL search params (with internal `_rsc*` params stripped, same as `url.searchParams`) */
|
|
71
|
-
searchParams: URLSearchParams;
|
|
63
|
+
> extends RequestScope<TEnv> {
|
|
72
64
|
/** @internal Shared variable backing store for ctx.get()/ctx.set(). */
|
|
73
65
|
_variables: Record<string, any>;
|
|
74
66
|
/** Get a variable set by middleware */
|
|
@@ -154,20 +146,6 @@ export interface RequestContext<
|
|
|
154
146
|
import("../cache/profile-registry.js").CacheProfile
|
|
155
147
|
>;
|
|
156
148
|
|
|
157
|
-
/**
|
|
158
|
-
* Schedule work to run after the response is sent.
|
|
159
|
-
* On Cloudflare Workers, uses ctx.waitUntil().
|
|
160
|
-
* On Node.js, runs as fire-and-forget.
|
|
161
|
-
*
|
|
162
|
-
* @example
|
|
163
|
-
* ```typescript
|
|
164
|
-
* ctx.waitUntil(async () => {
|
|
165
|
-
* await cacheStore.set(key, data, ttl);
|
|
166
|
-
* });
|
|
167
|
-
* ```
|
|
168
|
-
*/
|
|
169
|
-
waitUntil(fn: () => Promise<void>): void;
|
|
170
|
-
|
|
171
149
|
/**
|
|
172
150
|
* Register a callback to run when the response is created.
|
|
173
151
|
* Callbacks are sync and receive the response. They can:
|
|
@@ -306,6 +284,19 @@ export interface RequestContext<
|
|
|
306
284
|
*/
|
|
307
285
|
_renderBarrierWaiters?: Set<string>;
|
|
308
286
|
|
|
287
|
+
/**
|
|
288
|
+
* @internal Loader IDs that handlers have started awaiting via ctx.use().
|
|
289
|
+
* Used for bidirectional deadlock detection: if a loader later calls
|
|
290
|
+
* rendered() and a handler already awaits it, we can detect the deadlock.
|
|
291
|
+
*/
|
|
292
|
+
_handlerLoaderDeps?: Set<string>;
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* @internal Cached HandleData snapshot built at barrier resolution time.
|
|
296
|
+
* Avoids rebuilding the snapshot on every loader ctx.use(handle) call.
|
|
297
|
+
*/
|
|
298
|
+
_renderBarrierHandleSnapshot?: HandleData;
|
|
299
|
+
|
|
309
300
|
/** @internal Per-request error dedup set for onError reporting */
|
|
310
301
|
_reportedErrors: WeakSet<object>;
|
|
311
302
|
|
|
@@ -362,6 +353,8 @@ export type PublicRequestContext<
|
|
|
362
353
|
| "_renderBarrierSegmentOrder"
|
|
363
354
|
| "_treeHasStreaming"
|
|
364
355
|
| "_renderBarrierWaiters"
|
|
356
|
+
| "_handlerLoaderDeps"
|
|
357
|
+
| "_renderBarrierHandleSnapshot"
|
|
365
358
|
| "_reportBackgroundError"
|
|
366
359
|
| "_debugPerformance"
|
|
367
360
|
| "_metricsStore"
|
|
@@ -478,13 +471,7 @@ export function requireRequestContext<
|
|
|
478
471
|
return getRequestContext<TEnv>();
|
|
479
472
|
}
|
|
480
473
|
|
|
481
|
-
|
|
482
|
-
* Cloudflare Workers ExecutionContext (subset we need)
|
|
483
|
-
*/
|
|
484
|
-
export interface ExecutionContext {
|
|
485
|
-
waitUntil(promise: Promise<any>): void;
|
|
486
|
-
passThroughOnException(): void;
|
|
487
|
-
}
|
|
474
|
+
export type { ExecutionContext };
|
|
488
475
|
|
|
489
476
|
/**
|
|
490
477
|
* Options for creating a request context
|
|
@@ -748,16 +735,14 @@ export function createRequestContext<TEnv>(
|
|
|
748
735
|
|
|
749
736
|
waitUntil(fn: () => Promise<void>): void {
|
|
750
737
|
if (executionContext?.waitUntil) {
|
|
751
|
-
// Cloudflare Workers: use native waitUntil
|
|
752
738
|
executionContext.waitUntil(fn());
|
|
753
739
|
} else {
|
|
754
|
-
|
|
755
|
-
fn().catch((err) =>
|
|
756
|
-
console.error("[waitUntil] Background task failed:", err),
|
|
757
|
-
);
|
|
740
|
+
fireAndForgetWaitUntil(fn);
|
|
758
741
|
}
|
|
759
742
|
},
|
|
760
743
|
|
|
744
|
+
executionContext,
|
|
745
|
+
|
|
761
746
|
_onResponseCallbacks: [],
|
|
762
747
|
|
|
763
748
|
onResponse(callback: (response: Response) => Response): void {
|
|
@@ -798,29 +783,49 @@ export function createRequestContext<TEnv>(
|
|
|
798
783
|
reverse: createReverseFunction(getGlobalRouteMap(), undefined, {}),
|
|
799
784
|
};
|
|
800
785
|
|
|
801
|
-
//
|
|
802
|
-
//
|
|
803
|
-
// deferred promise resolved after segment resolution (or after handle replay
|
|
804
|
-
// on cache/prerender paths). No HandleStore sealing here — that stays in the
|
|
805
|
-
// existing lifecycle (rsc-rendering.ts, cache-scope.ts, etc.).
|
|
786
|
+
// Lazy render barrier: only allocate the Promise when a loader actually
|
|
787
|
+
// calls rendered(). Requests that don't use rendered() pay zero cost.
|
|
806
788
|
let barrierResolved = false;
|
|
807
|
-
let resolveBarrier: () => void;
|
|
808
|
-
ctx._renderBarrier =
|
|
809
|
-
resolveBarrier = resolve;
|
|
810
|
-
});
|
|
789
|
+
let resolveBarrier: (() => void) | undefined;
|
|
790
|
+
ctx._renderBarrier = null as any; // lazy — created on first access
|
|
811
791
|
ctx._resolveRenderBarrier = (
|
|
812
792
|
segments: Array<{ type: string; id: string }>,
|
|
813
793
|
) => {
|
|
814
794
|
if (barrierResolved) return;
|
|
815
795
|
barrierResolved = true;
|
|
816
|
-
|
|
796
|
+
const segOrder = segments
|
|
817
797
|
.filter((s) => s.type !== "loader")
|
|
818
798
|
.map((s) => s.id);
|
|
819
|
-
|
|
820
|
-
//
|
|
799
|
+
ctx._renderBarrierSegmentOrder = segOrder;
|
|
800
|
+
// Build and cache handle snapshot so loader ctx.use(handle) calls
|
|
801
|
+
// don't rebuild it on every invocation.
|
|
802
|
+
ctx._renderBarrierHandleSnapshot = buildHandleSnapshot(
|
|
803
|
+
handleStore,
|
|
804
|
+
segOrder,
|
|
805
|
+
);
|
|
821
806
|
ctx._renderBarrierWaiters = undefined;
|
|
822
|
-
|
|
807
|
+
ctx._handlerLoaderDeps = undefined;
|
|
808
|
+
if (resolveBarrier) resolveBarrier();
|
|
823
809
|
};
|
|
810
|
+
Object.defineProperty(ctx, "_renderBarrier", {
|
|
811
|
+
get() {
|
|
812
|
+
// Barrier already resolved (cache/prerender hit) or first lazy access.
|
|
813
|
+
// Either way, replace the getter with a concrete value to avoid
|
|
814
|
+
// repeated Promise.resolve() allocations on subsequent reads.
|
|
815
|
+
const p = barrierResolved
|
|
816
|
+
? Promise.resolve()
|
|
817
|
+
: new Promise<void>((resolve) => {
|
|
818
|
+
resolveBarrier = resolve;
|
|
819
|
+
});
|
|
820
|
+
Object.defineProperty(ctx, "_renderBarrier", {
|
|
821
|
+
value: p,
|
|
822
|
+
writable: false,
|
|
823
|
+
configurable: false,
|
|
824
|
+
});
|
|
825
|
+
return p;
|
|
826
|
+
},
|
|
827
|
+
configurable: true,
|
|
828
|
+
});
|
|
824
829
|
|
|
825
830
|
// Now create use() with access to ctx
|
|
826
831
|
ctx.use = createUseFunction({
|
|
@@ -1003,7 +1008,10 @@ export function createUseFunction<TEnv>(
|
|
|
1003
1008
|
search: (ctx as any).search ?? {},
|
|
1004
1009
|
pathname: ctx.pathname,
|
|
1005
1010
|
url: ctx.url,
|
|
1011
|
+
originalUrl: ctx.originalUrl,
|
|
1006
1012
|
env: ctx.env as any,
|
|
1013
|
+
waitUntil: ctx.waitUntil.bind(ctx),
|
|
1014
|
+
executionContext: ctx.executionContext,
|
|
1007
1015
|
get: ctx.get as any,
|
|
1008
1016
|
use: (<TDep, TDepParams = any>(
|
|
1009
1017
|
dep: LoaderDefinition<TDep, TDepParams>,
|