@rangojs/router 0.0.0-experimental.116 → 0.0.0-experimental.117
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 +1 -1
- package/package.json +18 -17
- package/skills/loader/SKILL.md +17 -17
- package/src/browser/navigation-client.ts +56 -68
- package/src/browser/prefetch/cache.ts +58 -27
- package/src/browser/prefetch/fetch.ts +92 -33
- package/src/browser/response-adapter.ts +7 -1
- package/src/browser/rsc-router.tsx +5 -0
- package/src/router/lazy-includes.ts +1 -1
- package/src/router/loader-resolution.ts +63 -34
- package/src/router/manifest.ts +1 -1
- package/src/server/context.ts +32 -0
- package/src/server/request-context.ts +47 -9
- package/src/types/loader-types.ts +6 -3
package/dist/vite/index.js
CHANGED
|
@@ -2130,7 +2130,7 @@ import { resolve } from "node:path";
|
|
|
2130
2130
|
// package.json
|
|
2131
2131
|
var package_default = {
|
|
2132
2132
|
name: "@rangojs/router",
|
|
2133
|
-
version: "0.0.0-experimental.
|
|
2133
|
+
version: "0.0.0-experimental.117",
|
|
2134
2134
|
description: "Django-inspired RSC router with composable URL patterns",
|
|
2135
2135
|
keywords: [
|
|
2136
2136
|
"react",
|
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.117",
|
|
4
4
|
"description": "Django-inspired RSC router with composable URL patterns",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react",
|
|
@@ -132,6 +132,16 @@
|
|
|
132
132
|
"access": "public",
|
|
133
133
|
"tag": "experimental"
|
|
134
134
|
},
|
|
135
|
+
"scripts": {
|
|
136
|
+
"build": "pnpm dlx esbuild src/vite/index.ts --bundle --format=esm --outfile=dist/vite/index.js --platform=node --packages=external && mkdir -p dist/vite/plugins && cp src/vite/plugins/cloudflare-protocol-loader-hook.mjs dist/vite/plugins/cloudflare-protocol-loader-hook.mjs && pnpm dlx esbuild src/bin/rango.ts --bundle --format=esm --outfile=dist/bin/rango.js --platform=node --packages=external --banner:js='#!/usr/bin/env node' && chmod +x dist/bin/rango.js",
|
|
137
|
+
"prepublishOnly": "pnpm build",
|
|
138
|
+
"typecheck": "tsc --noEmit && tsc -p tsconfig.strict-check.json --noEmit && tsc -p tsconfig.augment-check.json --noEmit",
|
|
139
|
+
"test": "playwright test",
|
|
140
|
+
"test:ui": "playwright test --ui",
|
|
141
|
+
"test:hmr-local": "playwright test --project=dev-warmup --project=hmr-routes --project=hmr-basename --project=hmr-prerender --no-deps --workers=1",
|
|
142
|
+
"test:unit": "vitest run",
|
|
143
|
+
"test:unit:watch": "vitest"
|
|
144
|
+
},
|
|
135
145
|
"dependencies": {
|
|
136
146
|
"@types/debug": "^4.1.12",
|
|
137
147
|
"@vitejs/plugin-rsc": "^0.5.26",
|
|
@@ -142,17 +152,17 @@
|
|
|
142
152
|
},
|
|
143
153
|
"devDependencies": {
|
|
144
154
|
"@playwright/test": "^1.49.1",
|
|
155
|
+
"@shared/e2e": "workspace:*",
|
|
145
156
|
"@types/node": "^24.10.1",
|
|
146
|
-
"@types/react": "
|
|
147
|
-
"@types/react-dom": "
|
|
157
|
+
"@types/react": "catalog:",
|
|
158
|
+
"@types/react-dom": "catalog:",
|
|
148
159
|
"esbuild": "^0.27.0",
|
|
149
160
|
"jiti": "^2.6.1",
|
|
150
|
-
"react": "
|
|
151
|
-
"react-dom": "
|
|
161
|
+
"react": "catalog:",
|
|
162
|
+
"react-dom": "catalog:",
|
|
152
163
|
"tinyexec": "^0.3.2",
|
|
153
164
|
"typescript": "^5.3.0",
|
|
154
|
-
"vitest": "^4.0.0"
|
|
155
|
-
"@shared/e2e": "0.0.1"
|
|
165
|
+
"vitest": "^4.0.0"
|
|
156
166
|
},
|
|
157
167
|
"peerDependencies": {
|
|
158
168
|
"@cloudflare/vite-plugin": "^1.38.0",
|
|
@@ -168,14 +178,5 @@
|
|
|
168
178
|
"vite": {
|
|
169
179
|
"optional": true
|
|
170
180
|
}
|
|
171
|
-
},
|
|
172
|
-
"scripts": {
|
|
173
|
-
"build": "pnpm dlx esbuild src/vite/index.ts --bundle --format=esm --outfile=dist/vite/index.js --platform=node --packages=external && mkdir -p dist/vite/plugins && cp src/vite/plugins/cloudflare-protocol-loader-hook.mjs dist/vite/plugins/cloudflare-protocol-loader-hook.mjs && pnpm dlx esbuild src/bin/rango.ts --bundle --format=esm --outfile=dist/bin/rango.js --platform=node --packages=external --banner:js='#!/usr/bin/env node' && chmod +x dist/bin/rango.js",
|
|
174
|
-
"typecheck": "tsc --noEmit && tsc -p tsconfig.strict-check.json --noEmit && tsc -p tsconfig.augment-check.json --noEmit",
|
|
175
|
-
"test": "playwright test",
|
|
176
|
-
"test:ui": "playwright test --ui",
|
|
177
|
-
"test:hmr-local": "playwright test --project=dev-warmup --project=hmr-routes --project=hmr-basename --project=hmr-prerender --no-deps --workers=1",
|
|
178
|
-
"test:unit": "vitest run",
|
|
179
|
-
"test:unit:watch": "vitest"
|
|
180
181
|
}
|
|
181
|
-
}
|
|
182
|
+
}
|
package/skills/loader/SKILL.md
CHANGED
|
@@ -160,23 +160,23 @@ Loaders receive the same context shape as route handlers.
|
|
|
160
160
|
|
|
161
161
|
### Full field surface
|
|
162
162
|
|
|
163
|
-
| Field | Type | Notes
|
|
164
|
-
| -------------- | ------------------------------ |
|
|
165
|
-
| `params` | `TParams` | Merged route + explicit loader params; overridable by fetchable `load({ params })`.
|
|
166
|
-
| `routeParams` | `Record<string, string>` | Server-trusted route params from URL pattern matching; cannot be overridden.
|
|
167
|
-
| `request` | `Request` | The incoming `Request` (headers, method, body, `signal` for abort).
|
|
168
|
-
| `url` | `URL` | Parsed request URL.
|
|
169
|
-
| `pathname` | `string` | URL pathname (shortcut for `ctx.url.pathname`).
|
|
170
|
-
| `searchParams` | `URLSearchParams` | Shortcut for `ctx.url.searchParams`.
|
|
171
|
-
| `search` | `ResolveSearchSchema<TSearch>` | Typed query params when a search schema is declared on the route; `{}` otherwise.
|
|
172
|
-
| `env` | `TEnv` | Plain bindings from `createRouter<TEnv>()` (DB, KV, secrets, etc.).
|
|
173
|
-
| `get` | `(key \| ContextVar) => value` | Reads variables/context-vars set by middleware.
|
|
174
|
-
| `use` | `(loader \| handle) => T` | Access another loader's data (Promise) or a handle's collected data (after `await ctx.rendered()`).
|
|
175
|
-
| `rendered` | `() => Promise<void>` | **Experimental.** DSL loaders only — waits for non-loader segments before reading handle data.
|
|
176
|
-
| `method` | `string` | HTTP method. `"GET"` for SSR loader runs; reflects real method for fetchable loaders.
|
|
177
|
-
| `body` | `TBody \| undefined` | Parsed request body for fetchable POST/PUT/PATCH/DELETE calls.
|
|
178
|
-
| `formData` | `FormData \| undefined` | Present when a fetchable loader is invoked via form submission.
|
|
179
|
-
| `reverse` | `ScopedReverseFunction` | Generate type-checked URLs from route names (same scoped semantics as route handlers).
|
|
163
|
+
| Field | Type | Notes |
|
|
164
|
+
| -------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
165
|
+
| `params` | `TParams` | Merged route + explicit loader params; overridable by fetchable `load({ params })`. |
|
|
166
|
+
| `routeParams` | `Record<string, string>` | Server-trusted route params from URL pattern matching; cannot be overridden. |
|
|
167
|
+
| `request` | `Request` | The incoming `Request` (headers, method, body, `signal` for abort). |
|
|
168
|
+
| `url` | `URL` | Parsed request URL. |
|
|
169
|
+
| `pathname` | `string` | URL pathname (shortcut for `ctx.url.pathname`). |
|
|
170
|
+
| `searchParams` | `URLSearchParams` | Shortcut for `ctx.url.searchParams`. |
|
|
171
|
+
| `search` | `ResolveSearchSchema<TSearch>` | Typed query params when a search schema is declared on the route; `{}` otherwise. |
|
|
172
|
+
| `env` | `TEnv` | Plain bindings from `createRouter<TEnv>()` (DB, KV, secrets, etc.). |
|
|
173
|
+
| `get` | `(key \| ContextVar) => value` | Reads variables/context-vars set by middleware. |
|
|
174
|
+
| `use` | `(loader \| handle) => T` | Access another loader's data (Promise) or a handle's collected data (after `await ctx.rendered()`). |
|
|
175
|
+
| `rendered` | `() => Promise<void>` | **Experimental.** DSL loaders only — waits for all non-loader segments (including `loading()` streaming handlers) to settle before reading handle data. |
|
|
176
|
+
| `method` | `string` | HTTP method. `"GET"` for SSR loader runs; reflects real method for fetchable loaders. |
|
|
177
|
+
| `body` | `TBody \| undefined` | Parsed request body for fetchable POST/PUT/PATCH/DELETE calls. |
|
|
178
|
+
| `formData` | `FormData \| undefined` | Present when a fetchable loader is invoked via form submission. |
|
|
179
|
+
| `reverse` | `ScopedReverseFunction` | Generate type-checked URLs from route names (same scoped semantics as route handlers). |
|
|
180
180
|
|
|
181
181
|
### Example
|
|
182
182
|
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
buildSourceKey,
|
|
24
24
|
consumeInflightPrefetch,
|
|
25
25
|
consumePrefetch,
|
|
26
|
+
type DecodedPrefetch,
|
|
26
27
|
} from "./prefetch/cache.js";
|
|
27
28
|
|
|
28
29
|
/**
|
|
@@ -111,26 +112,26 @@ export function createNavigationClient(
|
|
|
111
112
|
const wildcardKey = buildPrefetchKey(rangoState, fetchUrl);
|
|
112
113
|
const cacheKey = buildSourceKey(rangoState, previousUrl, fetchUrl);
|
|
113
114
|
|
|
114
|
-
let
|
|
115
|
+
let cachedEntry: DecodedPrefetch | null = null;
|
|
115
116
|
let hitKey: string | null = null;
|
|
116
117
|
if (canUsePrefetch) {
|
|
117
|
-
|
|
118
|
-
if (
|
|
118
|
+
cachedEntry = consumePrefetch(cacheKey);
|
|
119
|
+
if (cachedEntry) {
|
|
119
120
|
hitKey = cacheKey;
|
|
120
121
|
} else {
|
|
121
|
-
|
|
122
|
-
if (
|
|
122
|
+
cachedEntry = consumePrefetch(wildcardKey);
|
|
123
|
+
if (cachedEntry) hitKey = wildcardKey;
|
|
123
124
|
}
|
|
124
125
|
}
|
|
125
126
|
|
|
126
|
-
let
|
|
127
|
-
if (canUsePrefetch && !
|
|
128
|
-
|
|
129
|
-
if (
|
|
127
|
+
let inflightEntryPromise: Promise<DecodedPrefetch | null> | null = null;
|
|
128
|
+
if (canUsePrefetch && !cachedEntry) {
|
|
129
|
+
inflightEntryPromise = consumeInflightPrefetch(cacheKey);
|
|
130
|
+
if (inflightEntryPromise) {
|
|
130
131
|
hitKey = cacheKey;
|
|
131
132
|
} else {
|
|
132
|
-
|
|
133
|
-
if (
|
|
133
|
+
inflightEntryPromise = consumeInflightPrefetch(wildcardKey);
|
|
134
|
+
if (inflightEntryPromise) hitKey = wildcardKey;
|
|
134
135
|
}
|
|
135
136
|
}
|
|
136
137
|
// Track when the stream completes
|
|
@@ -217,29 +218,32 @@ export function createNavigationClient(
|
|
|
217
218
|
});
|
|
218
219
|
};
|
|
219
220
|
|
|
220
|
-
|
|
221
|
+
// A warm prefetch hit returns its eagerly-decoded payload directly: the
|
|
222
|
+
// route's chunks were imported during the prefetch, so this click runs
|
|
223
|
+
// no decode and no network. Only the fresh path runs createFromFetch and
|
|
224
|
+
// resolves the local streamComplete (via doFreshFetch's teeWithCompletion
|
|
225
|
+
// and the control-header short-circuits in validateRscHeaders).
|
|
226
|
+
const freshResult = (): {
|
|
227
|
+
payload: Promise<RscPayload>;
|
|
228
|
+
streamComplete: Promise<void>;
|
|
229
|
+
} => ({
|
|
230
|
+
payload: deps.createFromFetch<RscPayload>(doFreshFetch()),
|
|
231
|
+
streamComplete,
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
let payloadPromise: Promise<RscPayload>;
|
|
235
|
+
let streamCompletePromise: Promise<void>;
|
|
221
236
|
|
|
222
|
-
if (
|
|
237
|
+
if (cachedEntry) {
|
|
223
238
|
if (tx) {
|
|
224
|
-
browserDebugLog(tx, "prefetch cache hit", {
|
|
239
|
+
browserDebugLog(tx, "prefetch cache hit (warm)", {
|
|
225
240
|
key: hitKey,
|
|
226
241
|
wildcard: hitKey === wildcardKey,
|
|
227
242
|
});
|
|
228
243
|
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
return teeWithCompletion(
|
|
234
|
-
validated,
|
|
235
|
-
() => {
|
|
236
|
-
if (tx) browserDebugLog(tx, "stream complete (from cache)");
|
|
237
|
-
resolveStreamComplete();
|
|
238
|
-
},
|
|
239
|
-
signal,
|
|
240
|
-
);
|
|
241
|
-
});
|
|
242
|
-
} else if (inflightResponsePromise) {
|
|
244
|
+
payloadPromise = cachedEntry.payload;
|
|
245
|
+
streamCompletePromise = cachedEntry.streamComplete;
|
|
246
|
+
} else if (inflightEntryPromise) {
|
|
243
247
|
if (tx) {
|
|
244
248
|
browserDebugLog(tx, "reusing inflight prefetch", {
|
|
245
249
|
key: hitKey,
|
|
@@ -247,51 +251,35 @@ export function createNavigationClient(
|
|
|
247
251
|
});
|
|
248
252
|
}
|
|
249
253
|
const adoptedViaWildcard = hitKey === wildcardKey;
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
}
|
|
255
|
-
return doFreshFetch();
|
|
254
|
+
const entry = await inflightEntryPromise;
|
|
255
|
+
if (!entry) {
|
|
256
|
+
if (tx) {
|
|
257
|
+
browserDebugLog(tx, "inflight prefetch unavailable, refetching");
|
|
256
258
|
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
//
|
|
261
|
-
// a different source page. Discard and refetch.
|
|
262
|
-
if (
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
browserDebugLog(
|
|
268
|
-
tx,
|
|
269
|
-
"wildcard inflight turned out source-scoped, refetching",
|
|
270
|
-
);
|
|
271
|
-
}
|
|
272
|
-
return doFreshFetch();
|
|
259
|
+
({ payload: payloadPromise, streamComplete: streamCompletePromise } =
|
|
260
|
+
freshResult());
|
|
261
|
+
} else if (adoptedViaWildcard && entry.scope === "source") {
|
|
262
|
+
// A wildcard-adopted inflight that turned out source-scoped was
|
|
263
|
+
// built for a different source page. Discard and refetch.
|
|
264
|
+
if (tx) {
|
|
265
|
+
browserDebugLog(
|
|
266
|
+
tx,
|
|
267
|
+
"wildcard inflight turned out source-scoped, refetching",
|
|
268
|
+
);
|
|
273
269
|
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
() => {
|
|
281
|
-
if (tx) {
|
|
282
|
-
browserDebugLog(tx, "stream complete (from inflight prefetch)");
|
|
283
|
-
}
|
|
284
|
-
resolveStreamComplete();
|
|
285
|
-
},
|
|
286
|
-
signal,
|
|
287
|
-
);
|
|
288
|
-
});
|
|
270
|
+
({ payload: payloadPromise, streamComplete: streamCompletePromise } =
|
|
271
|
+
freshResult());
|
|
272
|
+
} else {
|
|
273
|
+
payloadPromise = entry.payload;
|
|
274
|
+
streamCompletePromise = entry.streamComplete;
|
|
275
|
+
}
|
|
289
276
|
} else {
|
|
290
|
-
|
|
277
|
+
({ payload: payloadPromise, streamComplete: streamCompletePromise } =
|
|
278
|
+
freshResult());
|
|
291
279
|
}
|
|
292
280
|
|
|
293
281
|
try {
|
|
294
|
-
const payload = await
|
|
282
|
+
const payload = await payloadPromise;
|
|
295
283
|
|
|
296
284
|
if (tx) {
|
|
297
285
|
browserDebugLog(tx, "response received", {
|
|
@@ -300,7 +288,7 @@ export function createNavigationClient(
|
|
|
300
288
|
diffCount: payload.metadata?.diff?.length ?? 0,
|
|
301
289
|
});
|
|
302
290
|
}
|
|
303
|
-
return { payload, streamComplete };
|
|
291
|
+
return { payload, streamComplete: streamCompletePromise };
|
|
304
292
|
} catch (error) {
|
|
305
293
|
// Convert network-level errors to NetworkError for proper handling
|
|
306
294
|
if (isNetworkError(error)) {
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Prefetch Cache
|
|
3
3
|
*
|
|
4
|
-
* In-memory cache storing
|
|
5
|
-
* on subsequent navigation.
|
|
4
|
+
* In-memory cache storing eagerly-decoded prefetch payloads for instant,
|
|
5
|
+
* already-warm cache hits on subsequent navigation. A prefetch fetches the
|
|
6
|
+
* RSC partial AND decodes it (createFromFetch) up front — decoding the Flight
|
|
7
|
+
* stream resolves the route's client references, so the route's JS chunks are
|
|
8
|
+
* imported during prefetch rather than on click. The decoded payload is reused
|
|
9
|
+
* verbatim by navigation, so a prefetched click loads no new code. Two key
|
|
10
|
+
* scopes are in play:
|
|
6
11
|
* - Wildcard (default): built by `buildPrefetchKey(rangoState, target)` —
|
|
7
12
|
* shape `rangoState\0/target?...`. Shared across all source pages and
|
|
8
13
|
* invalidated automatically when Rango state bumps (deploy or
|
|
@@ -17,8 +22,8 @@
|
|
|
17
22
|
* from other pages.
|
|
18
23
|
*
|
|
19
24
|
* Also tracks in-flight prefetch promises. Each promise resolves to the
|
|
20
|
-
*
|
|
21
|
-
* still-downloading prefetch without
|
|
25
|
+
* decoded prefetch entry (or null), letting navigation adopt a
|
|
26
|
+
* still-downloading prefetch without issuing a duplicate request. A
|
|
22
27
|
* single promise can be registered under multiple alias keys (see
|
|
23
28
|
* `setInflightPromiseWithAliases`) so same-source navigations adopt via
|
|
24
29
|
* their source key while cross-source ones fall through to the wildcard
|
|
@@ -30,6 +35,31 @@
|
|
|
30
35
|
|
|
31
36
|
import { abortAllPrefetches } from "./queue.js";
|
|
32
37
|
import { invalidateRangoState } from "../rango-state.js";
|
|
38
|
+
import type { RscPayload } from "../types.js";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A prefetch that has been fetched AND eagerly decoded. Storing the decoded
|
|
42
|
+
* payload (not the raw Response) is what makes a prefetched navigation "warm":
|
|
43
|
+
* decoding the Flight stream during prefetch pulls the route's client chunks,
|
|
44
|
+
* so the click reuses ready elements and loads no new JS.
|
|
45
|
+
*/
|
|
46
|
+
export interface DecodedPrefetch {
|
|
47
|
+
/** The eagerly-decoded RSC payload. Reused verbatim by navigation. */
|
|
48
|
+
payload: Promise<RscPayload>;
|
|
49
|
+
/**
|
|
50
|
+
* Resolves when the underlying RSC stream finishes draining. Navigation
|
|
51
|
+
* forwards this as its streamComplete so scroll/revalidation gating is
|
|
52
|
+
* unchanged from the fresh-fetch path.
|
|
53
|
+
*/
|
|
54
|
+
streamComplete: Promise<void>;
|
|
55
|
+
/**
|
|
56
|
+
* Prefetch scope as tagged by the server via `X-RSC-Prefetch-Scope`.
|
|
57
|
+
* `"source"` means the response is source-page-sensitive and must not be
|
|
58
|
+
* reused by a navigation from a different page — navigation enforces this
|
|
59
|
+
* when it adopted an inflight entry through the wildcard key.
|
|
60
|
+
*/
|
|
61
|
+
scope: "source" | "wildcard";
|
|
62
|
+
}
|
|
33
63
|
|
|
34
64
|
// Default TTL: 5 minutes. Overridden by initPrefetchCache() with
|
|
35
65
|
// the server-configured prefetchCacheTTL from router options.
|
|
@@ -55,7 +85,7 @@ export function isPrefetchCacheDisabled(): boolean {
|
|
|
55
85
|
const MAX_PREFETCH_CACHE_SIZE = 50;
|
|
56
86
|
|
|
57
87
|
interface PrefetchCacheEntry {
|
|
58
|
-
|
|
88
|
+
entry: DecodedPrefetch;
|
|
59
89
|
timestamp: number;
|
|
60
90
|
}
|
|
61
91
|
|
|
@@ -63,17 +93,19 @@ const cache = new Map<string, PrefetchCacheEntry>();
|
|
|
63
93
|
const inflight = new Set<string>();
|
|
64
94
|
|
|
65
95
|
/**
|
|
66
|
-
* In-flight promise map. When a prefetch fetch is in progress, its
|
|
67
|
-
* Promise<
|
|
68
|
-
*
|
|
96
|
+
* In-flight promise map. When a prefetch fetch+decode is in progress, its
|
|
97
|
+
* Promise<DecodedPrefetch | null> is stored here so navigation can await it
|
|
98
|
+
* instead of starting a duplicate request. Resolves to null when the prefetch
|
|
99
|
+
* failed, was aborted, or carried a control header (reload/redirect) that the
|
|
100
|
+
* navigation must re-fetch to act on.
|
|
69
101
|
*/
|
|
70
|
-
const inflightPromises = new Map<string, Promise<
|
|
102
|
+
const inflightPromises = new Map<string, Promise<DecodedPrefetch | null>>();
|
|
71
103
|
|
|
72
104
|
/**
|
|
73
105
|
* Alias map for in-flight promises registered under multiple keys (see
|
|
74
106
|
* dual inflight in prefetch/fetch.ts). Records each key's sibling set so
|
|
75
107
|
* that consuming or clearing any one key atomically removes every alias —
|
|
76
|
-
* guaranteeing a single consumer for the shared
|
|
108
|
+
* guaranteeing a single consumer for the shared decode.
|
|
77
109
|
*/
|
|
78
110
|
const inflightAliases = new Map<string, string[]>();
|
|
79
111
|
|
|
@@ -152,14 +184,14 @@ export function hasPrefetch(key: string): boolean {
|
|
|
152
184
|
}
|
|
153
185
|
|
|
154
186
|
/**
|
|
155
|
-
* Consume a cached prefetch
|
|
156
|
-
* One-time consumption: the entry is deleted after retrieval.
|
|
187
|
+
* Consume a cached, eagerly-decoded prefetch. Returns null if not found or
|
|
188
|
+
* expired. One-time consumption: the entry is deleted after retrieval.
|
|
157
189
|
* Returns null when caching is disabled (TTL <= 0).
|
|
158
190
|
*
|
|
159
191
|
* Does NOT check in-flight prefetches — use consumeInflightPrefetch()
|
|
160
|
-
* for that (returns a Promise instead of a
|
|
192
|
+
* for that (returns a Promise instead of a resolved entry).
|
|
161
193
|
*/
|
|
162
|
-
export function consumePrefetch(key: string):
|
|
194
|
+
export function consumePrefetch(key: string): DecodedPrefetch | null {
|
|
163
195
|
if (cacheTTL <= 0) return null;
|
|
164
196
|
const entry = cache.get(key);
|
|
165
197
|
if (!entry) return null;
|
|
@@ -168,13 +200,14 @@ export function consumePrefetch(key: string): Response | null {
|
|
|
168
200
|
return null;
|
|
169
201
|
}
|
|
170
202
|
cache.delete(key);
|
|
171
|
-
return entry.
|
|
203
|
+
return entry.entry;
|
|
172
204
|
}
|
|
173
205
|
|
|
174
206
|
/**
|
|
175
207
|
* Consume an in-flight prefetch promise. Returns null if no prefetch is
|
|
176
|
-
* in-flight for this key. The returned Promise resolves to the
|
|
177
|
-
*
|
|
208
|
+
* in-flight for this key. The returned Promise resolves to the decoded
|
|
209
|
+
* prefetch entry (or null if the fetch failed/was aborted, or carried a
|
|
210
|
+
* control header the navigation must re-fetch to honor).
|
|
178
211
|
*
|
|
179
212
|
* One-time consumption: the promise entry is removed (along with any
|
|
180
213
|
* sibling aliases registered via `setInflightPromiseWithAliases`) so a
|
|
@@ -188,7 +221,7 @@ export function consumePrefetch(key: string): Response | null {
|
|
|
188
221
|
*/
|
|
189
222
|
export function consumeInflightPrefetch(
|
|
190
223
|
key: string,
|
|
191
|
-
): Promise<
|
|
224
|
+
): Promise<DecodedPrefetch | null> | null {
|
|
192
225
|
const promise = inflightPromises.get(key);
|
|
193
226
|
if (!promise) return null;
|
|
194
227
|
// Remove the promise under every alias so a second consumer cannot
|
|
@@ -201,16 +234,14 @@ export function consumeInflightPrefetch(
|
|
|
201
234
|
}
|
|
202
235
|
|
|
203
236
|
/**
|
|
204
|
-
* Store
|
|
205
|
-
* The response should be a clone() of the original so the caller can
|
|
206
|
-
* still consume the body. The clone's body streams independently.
|
|
237
|
+
* Store an eagerly-decoded prefetch in the in-memory cache.
|
|
207
238
|
*
|
|
208
239
|
* Skips storage if the generation has changed since the fetch started
|
|
209
240
|
* (a server action invalidated the cache mid-flight).
|
|
210
241
|
*/
|
|
211
242
|
export function storePrefetch(
|
|
212
243
|
key: string,
|
|
213
|
-
|
|
244
|
+
entry: DecodedPrefetch,
|
|
214
245
|
fetchGeneration: number,
|
|
215
246
|
): void {
|
|
216
247
|
if (cacheTTL <= 0) return;
|
|
@@ -218,8 +249,8 @@ export function storePrefetch(
|
|
|
218
249
|
|
|
219
250
|
// Evict expired entries
|
|
220
251
|
const now = Date.now();
|
|
221
|
-
for (const [k,
|
|
222
|
-
if (now -
|
|
252
|
+
for (const [k, cached] of cache) {
|
|
253
|
+
if (now - cached.timestamp > cacheTTL) {
|
|
223
254
|
cache.delete(k);
|
|
224
255
|
}
|
|
225
256
|
}
|
|
@@ -230,7 +261,7 @@ export function storePrefetch(
|
|
|
230
261
|
if (oldest) cache.delete(oldest);
|
|
231
262
|
}
|
|
232
263
|
|
|
233
|
-
cache.set(key, {
|
|
264
|
+
cache.set(key, { entry, timestamp: now });
|
|
234
265
|
}
|
|
235
266
|
|
|
236
267
|
/**
|
|
@@ -250,7 +281,7 @@ export function markPrefetchInflight(key: string): void {
|
|
|
250
281
|
*/
|
|
251
282
|
export function setInflightPromise(
|
|
252
283
|
key: string,
|
|
253
|
-
promise: Promise<
|
|
284
|
+
promise: Promise<DecodedPrefetch | null>,
|
|
254
285
|
): void {
|
|
255
286
|
inflightPromises.set(key, promise);
|
|
256
287
|
}
|
|
@@ -263,7 +294,7 @@ export function setInflightPromise(
|
|
|
263
294
|
*/
|
|
264
295
|
export function setInflightPromiseWithAliases(
|
|
265
296
|
keys: string[],
|
|
266
|
-
promise: Promise<
|
|
297
|
+
promise: Promise<DecodedPrefetch | null>,
|
|
267
298
|
): void {
|
|
268
299
|
for (const k of keys) {
|
|
269
300
|
inflightPromises.set(k, promise);
|
|
@@ -3,12 +3,16 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Fetch-based prefetch logic used by Link (hover/viewport/render strategies)
|
|
5
5
|
* and useRouter().prefetch(). Sends the same headers and segment IDs as a
|
|
6
|
-
* real navigation so the server returns a proper diff. The
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* real navigation so the server returns a proper diff. The response is fetched
|
|
7
|
+
* AND eagerly decoded (createFromFetch) up front: decoding the Flight stream
|
|
8
|
+
* resolves the route's client references, so the route's JS chunks are imported
|
|
9
|
+
* during prefetch rather than on click. The decoded payload is stored in an
|
|
10
|
+
* in-memory cache and reused verbatim by navigation, so a prefetched click
|
|
11
|
+
* loads no new code.
|
|
9
12
|
*
|
|
10
13
|
* In-flight promises are tracked in the cache so that navigation can reuse
|
|
11
|
-
* a prefetch that is still downloading instead of starting a
|
|
14
|
+
* a prefetch that is still downloading/decoding instead of starting a
|
|
15
|
+
* duplicate request.
|
|
12
16
|
*/
|
|
13
17
|
|
|
14
18
|
import {
|
|
@@ -20,11 +24,34 @@ import {
|
|
|
20
24
|
storePrefetch,
|
|
21
25
|
clearPrefetchInflight,
|
|
22
26
|
currentGeneration,
|
|
27
|
+
type DecodedPrefetch,
|
|
23
28
|
} from "./cache.js";
|
|
24
29
|
import { getRangoState } from "../rango-state.js";
|
|
25
30
|
import { enqueuePrefetch } from "./queue.js";
|
|
26
31
|
import { shouldPrefetch } from "./policy.js";
|
|
27
32
|
import { debugLog } from "../logging.js";
|
|
33
|
+
import { teeWithCompletion } from "../response-adapter.js";
|
|
34
|
+
import type { RscPayload } from "../types.js";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Decoder injected at app startup (see setPrefetchDecoder). This is
|
|
38
|
+
* `deps.createFromFetch` — decoupled from the RSC runtime exactly like the
|
|
39
|
+
* navigation client. Prefetch decodes through it so the route's client chunks
|
|
40
|
+
* are pulled during the prefetch, not on click.
|
|
41
|
+
*/
|
|
42
|
+
type PrefetchDecoder = (response: Promise<Response>) => Promise<RscPayload>;
|
|
43
|
+
|
|
44
|
+
let decoder: PrefetchDecoder | null = null;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Wire the RSC decoder used to eagerly decode prefetched responses. Called
|
|
48
|
+
* once from initBrowserApp with the same createFromFetch the navigation client
|
|
49
|
+
* uses. Until set, prefetch warming is inert (prefetches are skipped) — the
|
|
50
|
+
* browser app always sets it before any Link can fire.
|
|
51
|
+
*/
|
|
52
|
+
export function setPrefetchDecoder(fn: PrefetchDecoder): void {
|
|
53
|
+
decoder = fn;
|
|
54
|
+
}
|
|
28
55
|
|
|
29
56
|
/**
|
|
30
57
|
* Check if a URL resolves to the current page (same pathname + search).
|
|
@@ -78,24 +105,34 @@ function buildPrefetchUrl(
|
|
|
78
105
|
}
|
|
79
106
|
|
|
80
107
|
/**
|
|
81
|
-
* Core prefetch fetch logic. Fetches the response,
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
* reuse an in-flight prefetch via
|
|
108
|
+
* Core prefetch fetch logic. Fetches the response, eagerly decodes it, and
|
|
109
|
+
* stores the decoded payload in the in-memory cache. The returned Promise
|
|
110
|
+
* resolves to the decoded entry (or null on failure / control header) so
|
|
111
|
+
* navigation can safely reuse an in-flight prefetch via
|
|
112
|
+
* consumeInflightPrefetch().
|
|
113
|
+
*
|
|
114
|
+
* Eager decode is the warming step: createFromFetch parses the Flight stream,
|
|
115
|
+
* which resolves the route's client references and imports its JS chunks. The
|
|
116
|
+
* stored payload is reused as-is by navigation, so the click loads no new code.
|
|
117
|
+
*
|
|
118
|
+
* Control headers are NOT acted on here. A speculative prefetch must never
|
|
119
|
+
* reload the page or throw a redirect — if the response carries X-RSC-Reload
|
|
120
|
+
* or X-RSC-Redirect, we drop it (resolve null) and let the real navigation
|
|
121
|
+
* re-fetch and honor it.
|
|
85
122
|
*
|
|
86
123
|
* Inflight + storage key selection:
|
|
87
124
|
*
|
|
88
125
|
* - `forceSourceScope` (Link opted in with `prefetchKey=":source"`): single
|
|
89
|
-
* inflight registration under `sourceKey`;
|
|
90
|
-
*
|
|
126
|
+
* inflight registration under `sourceKey`; entry stored under `sourceKey`.
|
|
127
|
+
* No wildcard leak is possible.
|
|
91
128
|
*
|
|
92
129
|
* - Otherwise: dual inflight registration under both `wildcardKey` and
|
|
93
130
|
* `sourceKey` so same-source navigations adopt directly via their own
|
|
94
131
|
* source key. Storage key is chosen at response time from the
|
|
95
132
|
* `X-RSC-Prefetch-Scope` header — `"source"` → `sourceKey` (intercept
|
|
96
|
-
* modals etc.), anything else → `wildcardKey`.
|
|
97
|
-
* that adopted via `wildcardKey`
|
|
98
|
-
*
|
|
133
|
+
* modals etc.), anything else → `wildcardKey`. The entry records its scope
|
|
134
|
+
* so cross-source navigations that adopted via `wildcardKey` can bail out
|
|
135
|
+
* in `navigation-client.ts` when the adopted entry turns out source-scoped.
|
|
99
136
|
*/
|
|
100
137
|
function executePrefetchFetch(
|
|
101
138
|
wildcardKey: string,
|
|
@@ -103,14 +140,14 @@ function executePrefetchFetch(
|
|
|
103
140
|
fetchUrl: string,
|
|
104
141
|
forceSourceScope: boolean,
|
|
105
142
|
signal?: AbortSignal,
|
|
106
|
-
): Promise<
|
|
143
|
+
): Promise<DecodedPrefetch | null> {
|
|
107
144
|
const gen = currentGeneration();
|
|
108
145
|
const inflightKeys = forceSourceScope
|
|
109
146
|
? [sourceKey]
|
|
110
147
|
: [wildcardKey, sourceKey];
|
|
111
148
|
for (const k of inflightKeys) markPrefetchInflight(k);
|
|
112
149
|
|
|
113
|
-
const promise: Promise<
|
|
150
|
+
const promise: Promise<DecodedPrefetch | null> = fetch(fetchUrl, {
|
|
114
151
|
priority: "low" as RequestPriority,
|
|
115
152
|
signal,
|
|
116
153
|
headers: {
|
|
@@ -120,25 +157,47 @@ function executePrefetchFetch(
|
|
|
120
157
|
},
|
|
121
158
|
})
|
|
122
159
|
.then((response) => {
|
|
123
|
-
if (!response.ok) return null;
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
statusText: response.statusText,
|
|
132
|
-
};
|
|
133
|
-
let storageKey: string;
|
|
134
|
-
if (forceSourceScope) {
|
|
135
|
-
storageKey = sourceKey;
|
|
136
|
-
} else {
|
|
137
|
-
const scope = response.headers.get("x-rsc-prefetch-scope");
|
|
138
|
-
storageKey = scope === "source" ? sourceKey : wildcardKey;
|
|
160
|
+
if (!response.ok || !decoder) return null;
|
|
161
|
+
// Control headers mean this response is stale (reload) or redirecting.
|
|
162
|
+
// Don't warm it — drop so navigation re-fetches and acts on the header.
|
|
163
|
+
if (
|
|
164
|
+
response.headers.has("X-RSC-Reload") ||
|
|
165
|
+
response.headers.has("X-RSC-Redirect")
|
|
166
|
+
) {
|
|
167
|
+
return null;
|
|
139
168
|
}
|
|
140
|
-
|
|
141
|
-
|
|
169
|
+
|
|
170
|
+
const scope: "source" | "wildcard" =
|
|
171
|
+
forceSourceScope ||
|
|
172
|
+
response.headers.get("x-rsc-prefetch-scope") === "source"
|
|
173
|
+
? "source"
|
|
174
|
+
: "wildcard";
|
|
175
|
+
const storageKey = scope === "source" ? sourceKey : wildcardKey;
|
|
176
|
+
|
|
177
|
+
// Track stream completion off a tee so navigation's scroll/revalidation
|
|
178
|
+
// gating matches the fresh-fetch path; decode the other branch.
|
|
179
|
+
let resolveStreamComplete!: () => void;
|
|
180
|
+
const streamComplete = new Promise<void>((resolve) => {
|
|
181
|
+
resolveStreamComplete = resolve;
|
|
182
|
+
});
|
|
183
|
+
const tracked = teeWithCompletion(
|
|
184
|
+
response,
|
|
185
|
+
() => resolveStreamComplete(),
|
|
186
|
+
signal,
|
|
187
|
+
// Speculative prefetch: a never-consumed/aborted stream error is benign.
|
|
188
|
+
true,
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
// Eager decode: parsing the Flight stream imports the route's client
|
|
192
|
+
// chunks now, not on click.
|
|
193
|
+
const payload = decoder(Promise.resolve(tracked));
|
|
194
|
+
// Mark handled so an unconsumed prefetch decode error stays quiet; the
|
|
195
|
+
// error is still surfaced to navigation if it consumes the entry.
|
|
196
|
+
payload.catch(() => {});
|
|
197
|
+
|
|
198
|
+
const entry: DecodedPrefetch = { payload, streamComplete, scope };
|
|
199
|
+
storePrefetch(storageKey, entry, gen);
|
|
200
|
+
return entry;
|
|
142
201
|
})
|
|
143
202
|
.catch(() => null)
|
|
144
203
|
.finally(() => {
|
|
@@ -56,11 +56,17 @@ export function handleReloadHeader(
|
|
|
56
56
|
*
|
|
57
57
|
* If the response has no body, onComplete fires synchronously.
|
|
58
58
|
* If signal is provided, an abort cancels the tracking reader.
|
|
59
|
+
*
|
|
60
|
+
* `silent` suppresses the stream-error log. Prefetch passes it: a speculative,
|
|
61
|
+
* low-priority prefetch that is aborted or never consumed can error its stream
|
|
62
|
+
* benignly, which is not worth surfacing. The fresh-navigation path keeps the
|
|
63
|
+
* log (default), where a stream error reflects a real failed navigation.
|
|
59
64
|
*/
|
|
60
65
|
export function teeWithCompletion(
|
|
61
66
|
response: Response,
|
|
62
67
|
onComplete: () => void,
|
|
63
68
|
signal?: AbortSignal,
|
|
69
|
+
silent = false,
|
|
64
70
|
): Response {
|
|
65
71
|
if (!response.body) {
|
|
66
72
|
onComplete();
|
|
@@ -84,7 +90,7 @@ export function teeWithCompletion(
|
|
|
84
90
|
onComplete();
|
|
85
91
|
}
|
|
86
92
|
})().catch((error) => {
|
|
87
|
-
if (!signal?.aborted) {
|
|
93
|
+
if (!silent && !signal?.aborted) {
|
|
88
94
|
console.error("[Browser] Error reading tracking stream:", error);
|
|
89
95
|
}
|
|
90
96
|
onComplete();
|
|
@@ -23,6 +23,7 @@ import type { EventController } from "./event-controller.js";
|
|
|
23
23
|
import type { ResolvedThemeConfig, Theme } from "../theme/types.js";
|
|
24
24
|
import { initRangoState } from "./rango-state.js";
|
|
25
25
|
import { initPrefetchCache } from "./prefetch/cache.js";
|
|
26
|
+
import { setPrefetchDecoder } from "./prefetch/fetch.js";
|
|
26
27
|
import { setAppVersion } from "./app-version.js";
|
|
27
28
|
import {
|
|
28
29
|
isInterceptSegment,
|
|
@@ -238,6 +239,10 @@ export async function initBrowserApp(
|
|
|
238
239
|
initPrefetchCache(prefetchCacheTTL);
|
|
239
240
|
}
|
|
240
241
|
|
|
242
|
+
// Wire the RSC decoder so prefetches decode eagerly and warm the route's
|
|
243
|
+
// client chunks (same createFromFetch the navigation client uses).
|
|
244
|
+
setPrefetchDecoder((response) => deps.createFromFetch<RscPayload>(response));
|
|
245
|
+
|
|
241
246
|
// Create a bound renderSegments that reads rootLayout through the shell
|
|
242
247
|
// ref. On app switch the ref is updated before the tree re-renders, so
|
|
243
248
|
// the new app's Document (rootLayout) replaces the previous one.
|
|
@@ -119,7 +119,7 @@ export function evaluateLazyEntry<TEnv = any>(
|
|
|
119
119
|
const lazyContext = entry.lazyContext;
|
|
120
120
|
|
|
121
121
|
// Create a new context for evaluating the lazy patterns.
|
|
122
|
-
// KNOWN REDUNDANCY (LP3, docs/internal/matching-
|
|
122
|
+
// KNOWN REDUNDANCY (LP3, docs/internal/matching-and-lazy-discovery.md): this
|
|
123
123
|
// runs lazyPatterns.handler() purely to extract `patterns` (route name ->
|
|
124
124
|
// pattern) for matching, and DISCARDS the EntryData `manifest` it builds.
|
|
125
125
|
// loadManifest() then runs the SAME handler again on the first request to
|
|
@@ -27,6 +27,8 @@ import { _getRequestContext } from "../server/request-context.js";
|
|
|
27
27
|
import {
|
|
28
28
|
isInsideLoaderScope,
|
|
29
29
|
runInsideLoaderBodyScope,
|
|
30
|
+
isInsidePushCallbackScope,
|
|
31
|
+
runInsidePushCallbackScope,
|
|
30
32
|
} from "../server/context.js";
|
|
31
33
|
import { debugLog } from "./logging.js";
|
|
32
34
|
|
|
@@ -290,6 +292,12 @@ function createLoaderExecutor<TEnv>(
|
|
|
290
292
|
);
|
|
291
293
|
}
|
|
292
294
|
const segmentOrder = reqCtx._renderBarrierSegmentOrder ?? [];
|
|
295
|
+
// The complete snapshot is cached at barrier resolution for
|
|
296
|
+
// non-streaming trees, and by rendered() after handleStore.settled for
|
|
297
|
+
// streaming trees (where the eager snapshot would have been incomplete
|
|
298
|
+
// because loading() handlers were still in flight). Either way it is
|
|
299
|
+
// present by the time a loader reads a handle; the fresh build is only
|
|
300
|
+
// a defensive fallback.
|
|
293
301
|
const snapshot =
|
|
294
302
|
reqCtx._renderBarrierHandleSnapshot ??
|
|
295
303
|
buildHandleSnapshot(reqCtx._handleStore, segmentOrder);
|
|
@@ -311,15 +319,7 @@ function createLoaderExecutor<TEnv>(
|
|
|
311
319
|
);
|
|
312
320
|
}
|
|
313
321
|
|
|
314
|
-
// Guard: reject streaming trees
|
|
315
322
|
const reqCtx = reqCtxRef ?? _getRequestContext();
|
|
316
|
-
if (reqCtx?._treeHasStreaming) {
|
|
317
|
-
throw new Error(
|
|
318
|
-
`ctx.rendered() is not supported when the matched route tree uses loading(). ` +
|
|
319
|
-
`Streaming handlers may not have settled when rendered() resolves. ` +
|
|
320
|
-
`Remove loading() from the route tree or restructure to avoid rendered().`,
|
|
321
|
-
);
|
|
322
|
-
}
|
|
323
323
|
|
|
324
324
|
if (renderedPromise) return renderedPromise;
|
|
325
325
|
|
|
@@ -330,7 +330,10 @@ function createLoaderExecutor<TEnv>(
|
|
|
330
330
|
}
|
|
331
331
|
|
|
332
332
|
// Bidirectional deadlock check: if a handler already started
|
|
333
|
-
// awaiting this loader, calling rendered() would deadlock.
|
|
333
|
+
// awaiting this loader, calling rendered() would deadlock. This is the
|
|
334
|
+
// real cycle guard (it holds for both streaming and non-streaming): the
|
|
335
|
+
// handler blocks segment resolution, which blocks the barrier, which
|
|
336
|
+
// blocks this loader.
|
|
334
337
|
if (reqCtx._handlerLoaderDeps?.has(currentLoaderId)) {
|
|
335
338
|
throw new Error(
|
|
336
339
|
`Deadlock: loader "${currentLoaderId}" called ctx.rendered() but a handler ` +
|
|
@@ -348,7 +351,29 @@ function createLoaderExecutor<TEnv>(
|
|
|
348
351
|
}
|
|
349
352
|
reqCtx._renderBarrierWaiters.add(currentLoaderId);
|
|
350
353
|
|
|
351
|
-
|
|
354
|
+
// Streaming trees (loading()): the barrier resolves once the segment
|
|
355
|
+
// tree is resolved, but loading() handlers stream behind Suspense and
|
|
356
|
+
// their handle pushes are still in flight then. Their async execution
|
|
357
|
+
// IS tracked in the handle store (trackHandler -> store.track), so after
|
|
358
|
+
// the barrier we seal (no further handlers register once the tree is
|
|
359
|
+
// resolved) and wait for settled — every tracked handler, streaming
|
|
360
|
+
// included, has finished pushing. The loader's own segment streams in
|
|
361
|
+
// after, so this does not block the shell; the deadlock guard above
|
|
362
|
+
// keeps a handler from depending on this loader.
|
|
363
|
+
const streaming = reqCtx._treeHasStreaming === true;
|
|
364
|
+
renderedPromise = reqCtx._renderBarrier.then(async () => {
|
|
365
|
+
if (streaming) {
|
|
366
|
+
reqCtx._handleStore.seal();
|
|
367
|
+
await reqCtx._handleStore.settled;
|
|
368
|
+
// The eager snapshot was intentionally left unbuilt for streaming
|
|
369
|
+
// (it would have been incomplete). Build the complete one once, now
|
|
370
|
+
// that the store has settled, so every ctx.use(handle) reads the
|
|
371
|
+
// cached snapshot instead of rebuilding it per call.
|
|
372
|
+
reqCtx._renderBarrierHandleSnapshot ??= buildHandleSnapshot(
|
|
373
|
+
reqCtx._handleStore,
|
|
374
|
+
reqCtx._renderBarrierSegmentOrder ?? [],
|
|
375
|
+
);
|
|
376
|
+
}
|
|
352
377
|
renderedResolved = true;
|
|
353
378
|
});
|
|
354
379
|
return renderedPromise;
|
|
@@ -404,12 +429,6 @@ export function setupLoaderAccess<TEnv>(
|
|
|
404
429
|
|
|
405
430
|
const useLoader = createLoaderExecutor(ctx, loaderPromises);
|
|
406
431
|
|
|
407
|
-
// Track whether we're inside a handle push callback. Loaders started
|
|
408
|
-
// from push callbacks (e.g. push(async () => ctx.use(Loader))) do NOT
|
|
409
|
-
// block segment resolution, so they must not be registered as handler
|
|
410
|
-
// dependencies for deadlock detection.
|
|
411
|
-
let insideHandlePush = false;
|
|
412
|
-
|
|
413
432
|
ctx.use = ((item: LoaderDefinition<any, any> | Handle<any, any>) => {
|
|
414
433
|
if (isHandle(item)) {
|
|
415
434
|
const handle = item;
|
|
@@ -430,15 +449,17 @@ export function setupLoaderAccess<TEnv>(
|
|
|
430
449
|
if (!store) return;
|
|
431
450
|
|
|
432
451
|
if (typeof dataOrFn === "function") {
|
|
433
|
-
//
|
|
434
|
-
//
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
452
|
+
// Run the callback inside the push-callback scope so ctx.use(loader)
|
|
453
|
+
// calls it makes — including after its own awaits, for an async
|
|
454
|
+
// callback — are not registered as handler-to-loader deps and do not
|
|
455
|
+
// trip the deadlock guard. A pushed promise value is not tracked by
|
|
456
|
+
// handleStore.settled and does not block segment resolution, so it
|
|
457
|
+
// cannot form a rendered() deadlock. The ALS scope (not a plain
|
|
458
|
+
// boolean) is what survives the callback's awaits.
|
|
459
|
+
const result = runInsidePushCallbackScope(() =>
|
|
460
|
+
(dataOrFn as () => Promise<unknown>)(),
|
|
461
|
+
);
|
|
462
|
+
store.push(handle.$$id, segmentId, result);
|
|
442
463
|
return;
|
|
443
464
|
}
|
|
444
465
|
|
|
@@ -450,9 +471,12 @@ export function setupLoaderAccess<TEnv>(
|
|
|
450
471
|
// Skip when inside a DSL loader scope (resolveLoaderData also calls
|
|
451
472
|
// ctx.use() but that's DSL-to-DSL, not handler-to-loader) or when
|
|
452
473
|
// inside a handle push callback (push callbacks don't block segment
|
|
453
|
-
// resolution so they can't cause rendered() deadlocks).
|
|
474
|
+
// resolution so they can't cause rendered() deadlocks). The push-callback
|
|
475
|
+
// check is an ALS scope so it also exempts an ASYNC callback's continuation
|
|
476
|
+
// after its first await — relevant on streaming trees, where the guard
|
|
477
|
+
// state now stays live until handleStore.settled.
|
|
454
478
|
const loader = item as LoaderDefinition<any, any>;
|
|
455
|
-
if (!isInsideLoaderScope() && !
|
|
479
|
+
if (!isInsideLoaderScope() && !isInsidePushCallbackScope()) {
|
|
456
480
|
const reqCtx = reqCtxRef ?? _getRequestContext();
|
|
457
481
|
if (reqCtx) {
|
|
458
482
|
// Direction 1: handler awaits loader that already called rendered()
|
|
@@ -466,13 +490,18 @@ export function setupLoaderAccess<TEnv>(
|
|
|
466
490
|
`Move the data dependency to a loader-to-loader pattern instead.`,
|
|
467
491
|
);
|
|
468
492
|
}
|
|
469
|
-
// Direction 2: track dep so rendered() can detect the deadlock
|
|
470
|
-
//
|
|
471
|
-
//
|
|
472
|
-
//
|
|
473
|
-
//
|
|
474
|
-
//
|
|
475
|
-
|
|
493
|
+
// Direction 2: track dep so rendered() can detect the deadlock if the
|
|
494
|
+
// loader calls it later. Skip once the guard window is CLOSED — for a
|
|
495
|
+
// non-streaming tree that is when the barrier resolves (rendered()
|
|
496
|
+
// resolves immediately), and for a streaming tree it is when
|
|
497
|
+
// handleStore.settled completes (rendered() keeps waiting until then, so
|
|
498
|
+
// a loading() handler resuming after the barrier can still form a
|
|
499
|
+
// cycle). Using the explicit guard-closed flag rather than
|
|
500
|
+
// _renderBarrierSegmentOrder keeps tracking live across the streaming
|
|
501
|
+
// settle wait. (Handle push callbacks are already excluded above via
|
|
502
|
+
// isInsidePushCallbackScope(), so they cannot produce false positives
|
|
503
|
+
// here.)
|
|
504
|
+
if (!reqCtx._renderBarrierGuardClosed) {
|
|
476
505
|
if (!reqCtx._handlerLoaderDeps) reqCtx._handlerLoaderDeps = new Set();
|
|
477
506
|
reqCtx._handlerLoaderDeps.add(loader.$$id);
|
|
478
507
|
}
|
package/src/router/manifest.ts
CHANGED
|
@@ -32,7 +32,7 @@ import { VERSION } from "@rangojs/router:version";
|
|
|
32
32
|
// own pruned manifest, so alternating sibling requests would thrash (re-run the
|
|
33
33
|
// handler every time). Running the include handler once per isolate instead of once
|
|
34
34
|
// per route is possible but needs an unpruned manifest cache with prune-on-read — see
|
|
35
|
-
// LP1 in docs/internal/matching-
|
|
35
|
+
// LP1 in docs/internal/matching-and-lazy-discovery.md. VERSION comes from the
|
|
36
36
|
// @rangojs/router:version virtual module which Vite invalidates on RSC module HMR.
|
|
37
37
|
// When VERSION changes, this module re-evaluates and the cache is recreated empty.
|
|
38
38
|
const manifestModuleCache = new Map<string, Map<string, EntryData>>();
|
package/src/server/context.ts
CHANGED
|
@@ -805,3 +805,35 @@ export function runInsideLoaderScope<T>(fn: () => T): T {
|
|
|
805
805
|
export function runInsideLoaderBodyScope<T>(fn: () => T): T {
|
|
806
806
|
return loaderBodyScopeALS.run({ active: true }, fn);
|
|
807
807
|
}
|
|
808
|
+
|
|
809
|
+
// Scope for handle PUSH CALLBACKS (push(() => ...), including async ones).
|
|
810
|
+
// A push callback's value is stored as-is; if it is a promise it is NOT tracked
|
|
811
|
+
// by handleStore.settled and does not block segment resolution, so a
|
|
812
|
+
// ctx.use(loader) made from inside such a callback can never form a rendered()
|
|
813
|
+
// deadlock. This is an ALS (not a plain boolean) so the exemption survives the
|
|
814
|
+
// callback's own awaits — an async push callback that resumes after `await`
|
|
815
|
+
// still reads as "inside a push callback" and stays out of the deadlock guard.
|
|
816
|
+
const PUSH_CALLBACK_SCOPE_KEY = Symbol.for(
|
|
817
|
+
"rangojs-router:push-callback-scope",
|
|
818
|
+
);
|
|
819
|
+
const pushCallbackScopeALS: AsyncLocalStorage<{ active: true }> = ((
|
|
820
|
+
globalThis as any
|
|
821
|
+
)[PUSH_CALLBACK_SCOPE_KEY] ??= new AsyncLocalStorage<{ active: true }>());
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* Check if the current execution is inside a handle push callback (sync or an
|
|
825
|
+
* async callback's continuation). Used by the handler-to-loader deadlock guard
|
|
826
|
+
* to exempt push-callback continuations.
|
|
827
|
+
*/
|
|
828
|
+
export function isInsidePushCallbackScope(): boolean {
|
|
829
|
+
return pushCallbackScopeALS.getStore()?.active === true;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* Run `fn` inside a push-callback scope. Wraps the invocation of a handle push
|
|
834
|
+
* callback so that any ctx.use(loader) it makes — including after one of its own
|
|
835
|
+
* awaits — is exempt from the deadlock guard.
|
|
836
|
+
*/
|
|
837
|
+
export function runInsidePushCallbackScope<T>(fn: () => T): T {
|
|
838
|
+
return pushCallbackScopeALS.run({ active: true }, fn);
|
|
839
|
+
}
|
|
@@ -273,7 +273,9 @@ export interface RequestContext<
|
|
|
273
273
|
|
|
274
274
|
/**
|
|
275
275
|
* @internal Set to true when the matched entry tree contains any `loading()`
|
|
276
|
-
* entries (streaming).
|
|
276
|
+
* entries (streaming). On a streaming tree rendered() waits for the streaming
|
|
277
|
+
* handlers to settle (via handleStore.settled) before resolving, and the
|
|
278
|
+
* deadlock guard state is kept live until that wait completes.
|
|
277
279
|
*/
|
|
278
280
|
_treeHasStreaming?: boolean;
|
|
279
281
|
|
|
@@ -297,6 +299,18 @@ export interface RequestContext<
|
|
|
297
299
|
*/
|
|
298
300
|
_renderBarrierHandleSnapshot?: HandleData;
|
|
299
301
|
|
|
302
|
+
/**
|
|
303
|
+
* @internal The deadlock guard window is closed (no further handler-awaits-
|
|
304
|
+
* loader cycle is possible). For non-streaming trees this is set when the
|
|
305
|
+
* barrier resolves. For streaming trees the window stays open until
|
|
306
|
+
* handleStore.settled — rendered() keeps waiting past the barrier and a
|
|
307
|
+
* loading() handler can still resume and await a still-waiting loader — so it
|
|
308
|
+
* is set only after settled. The guard (loader-resolution `setupLoaderAccess`)
|
|
309
|
+
* reads this instead of `_renderBarrierSegmentOrder` so it does not go blind
|
|
310
|
+
* during the streaming settle wait.
|
|
311
|
+
*/
|
|
312
|
+
_renderBarrierGuardClosed?: boolean;
|
|
313
|
+
|
|
300
314
|
/** @internal Per-request error dedup set for onError reporting */
|
|
301
315
|
_reportedErrors: WeakSet<object>;
|
|
302
316
|
|
|
@@ -355,6 +369,7 @@ export type PublicRequestContext<
|
|
|
355
369
|
| "_renderBarrierWaiters"
|
|
356
370
|
| "_handlerLoaderDeps"
|
|
357
371
|
| "_renderBarrierHandleSnapshot"
|
|
372
|
+
| "_renderBarrierGuardClosed"
|
|
358
373
|
| "_reportBackgroundError"
|
|
359
374
|
| "_debugPerformance"
|
|
360
375
|
| "_metricsStore"
|
|
@@ -797,14 +812,37 @@ export function createRequestContext<TEnv>(
|
|
|
797
812
|
.filter((s) => s.type !== "loader")
|
|
798
813
|
.map((s) => s.id);
|
|
799
814
|
ctx._renderBarrierSegmentOrder = segOrder;
|
|
800
|
-
|
|
801
|
-
//
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
815
|
+
|
|
816
|
+
// Closing the guard window means no handler can still form a deadlock cycle
|
|
817
|
+
// with a rendered() loader: drop the dependency-tracking state and mark it
|
|
818
|
+
// closed. WHEN this runs is the only streaming/non-streaming difference.
|
|
819
|
+
const closeGuard = () => {
|
|
820
|
+
ctx._renderBarrierWaiters = undefined;
|
|
821
|
+
ctx._handlerLoaderDeps = undefined;
|
|
822
|
+
ctx._renderBarrierGuardClosed = true;
|
|
823
|
+
};
|
|
824
|
+
|
|
825
|
+
if (ctx._treeHasStreaming) {
|
|
826
|
+
// Streaming: rendered() keeps waiting on handleStore.settled past this
|
|
827
|
+
// point, and loading() handlers are still in flight. The eager snapshot
|
|
828
|
+
// here would be incomplete, so leave it unset — rendered() builds and
|
|
829
|
+
// caches the complete one after settled. Keep the guard window OPEN so a
|
|
830
|
+
// handler that resumes and awaits a still-waiting rendered() loader is
|
|
831
|
+
// still caught; close it once settled (every tracked handler has finished
|
|
832
|
+
// then, so none can await a loader anymore). settled resolves after
|
|
833
|
+
// rendered() seals; if no loader used rendered(), nothing seals and the
|
|
834
|
+
// (empty) guard state is simply GC'd at request end.
|
|
835
|
+
handleStore.settled.then(closeGuard);
|
|
836
|
+
} else {
|
|
837
|
+
// Non-streaming: all handlers have settled by now. Build and cache the
|
|
838
|
+
// snapshot so loader ctx.use(handle) calls don't rebuild it, and close the
|
|
839
|
+
// guard window immediately.
|
|
840
|
+
ctx._renderBarrierHandleSnapshot = buildHandleSnapshot(
|
|
841
|
+
handleStore,
|
|
842
|
+
segOrder,
|
|
843
|
+
);
|
|
844
|
+
closeGuard();
|
|
845
|
+
}
|
|
808
846
|
if (resolveBarrier) resolveBarrier();
|
|
809
847
|
};
|
|
810
848
|
Object.defineProperty(ctx, "_renderBarrier", {
|
|
@@ -72,9 +72,12 @@ export type LoaderContext<
|
|
|
72
72
|
* **Experimental.** Wait for all non-loader segments to settle.
|
|
73
73
|
*
|
|
74
74
|
* After the returned promise resolves, handle data is available via
|
|
75
|
-
* `ctx.use(handle)`.
|
|
76
|
-
* trees
|
|
77
|
-
*
|
|
75
|
+
* `ctx.use(handle)`. Supported in DSL loaders, including on streaming
|
|
76
|
+
* trees that use `loading()` — the barrier waits for the streaming
|
|
77
|
+
* handlers to finish pushing before it resolves. Throws if called from a
|
|
78
|
+
* handler-invoked loader, or if a handler is already awaiting this loader
|
|
79
|
+
* via `ctx.use()` (that would deadlock — use a loader-to-loader
|
|
80
|
+
* dependency instead).
|
|
78
81
|
*
|
|
79
82
|
* @example
|
|
80
83
|
* ```typescript
|