@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.
@@ -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.116",
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.116",
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": "^19.2.7",
147
- "@types/react-dom": "^19.2.3",
157
+ "@types/react": "catalog:",
158
+ "@types/react-dom": "catalog:",
148
159
  "esbuild": "^0.27.0",
149
160
  "jiti": "^2.6.1",
150
- "react": "^19.2.6",
151
- "react-dom": "^19.2.6",
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
+ }
@@ -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 cachedResponse: Response | null = null;
115
+ let cachedEntry: DecodedPrefetch | null = null;
115
116
  let hitKey: string | null = null;
116
117
  if (canUsePrefetch) {
117
- cachedResponse = consumePrefetch(cacheKey);
118
- if (cachedResponse) {
118
+ cachedEntry = consumePrefetch(cacheKey);
119
+ if (cachedEntry) {
119
120
  hitKey = cacheKey;
120
121
  } else {
121
- cachedResponse = consumePrefetch(wildcardKey);
122
- if (cachedResponse) hitKey = wildcardKey;
122
+ cachedEntry = consumePrefetch(wildcardKey);
123
+ if (cachedEntry) hitKey = wildcardKey;
123
124
  }
124
125
  }
125
126
 
126
- let inflightResponsePromise: Promise<Response | null> | null = null;
127
- if (canUsePrefetch && !cachedResponse) {
128
- inflightResponsePromise = consumeInflightPrefetch(cacheKey);
129
- if (inflightResponsePromise) {
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
- inflightResponsePromise = consumeInflightPrefetch(wildcardKey);
133
- if (inflightResponsePromise) hitKey = wildcardKey;
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
- let responsePromise: Promise<Response>;
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 (cachedResponse) {
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
- responsePromise = Promise.resolve(cachedResponse).then((response) => {
230
- const validated = validateRscHeaders(response, "prefetch cache");
231
- if (validated instanceof Promise) return validated;
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
- responsePromise = inflightResponsePromise.then(async (response) => {
251
- if (!response) {
252
- if (tx) {
253
- browserDebugLog(tx, "inflight prefetch unavailable, refetching");
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
- // Cross-source safety: an inflight promise adopted via the
259
- // wildcard key may turn out to be source-scoped (server emitted
260
- // `X-RSC-Prefetch-Scope: source`), which means it was built for
261
- // a different source page. Discard and refetch.
262
- if (
263
- adoptedViaWildcard &&
264
- response.headers.get("x-rsc-prefetch-scope") === "source"
265
- ) {
266
- if (tx) {
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
- const validated = validateRscHeaders(response, "inflight prefetch");
276
- if (validated instanceof Promise) return validated;
277
-
278
- return teeWithCompletion(
279
- validated,
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
- responsePromise = doFreshFetch();
277
+ ({ payload: payloadPromise, streamComplete: streamCompletePromise } =
278
+ freshResult());
291
279
  }
292
280
 
293
281
  try {
294
- const payload = await deps.createFromFetch<RscPayload>(responsePromise);
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 prefetched Response objects for instant cache hits
5
- * on subsequent navigation. Two key scopes are in play:
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
- * navigation branch of a tee'd Response, allowing navigation to adopt a
21
- * still-downloading prefetch without reparsing or buffering the body. A
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
- response: Response;
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<Response | null> is stored here so navigation can await
68
- * it instead of starting a duplicate request.
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<Response | null>>();
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 Response stream.
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 response. Returns null if not found or expired.
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 Response).
192
+ * for that (returns a Promise instead of a resolved entry).
161
193
  */
162
- export function consumePrefetch(key: string): Response | null {
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.response;
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 buffered
177
- * Response (or null if the fetch failed/was aborted).
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<Response | null> | null {
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 a prefetch response in the in-memory cache.
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
- response: Response,
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, entry] of cache) {
222
- if (now - entry.timestamp > cacheTTL) {
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, { response, timestamp: now });
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<Response | null>,
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<Response | null>,
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 Response is fully
7
- * buffered and stored in an in-memory cache for instant consumption on
8
- * subsequent navigation.
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 duplicate request.
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, tees the body, and stores
82
- * one branch in the in-memory cache. The returned Promise resolves to the
83
- * sibling navigation branch (or null on failure) so navigation can safely
84
- * reuse an in-flight prefetch via consumeInflightPrefetch().
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`; response stored under
90
- * `sourceKey`. No wildcard leak is possible.
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`. Cross-source navigations
97
- * that adopted via `wildcardKey` must bail out in `navigation-client.ts`
98
- * if the adopted response turns out to be source-scoped.
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<Response | null> {
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<Response | null> = fetch(fetchUrl, {
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
- // Don't buffer with arrayBuffer() that blocks until the entire
125
- // body downloads, defeating streaming for slow loaders.
126
- // Tee the body: one branch for navigation, one for cache storage.
127
- const [navStream, cacheStream] = response.body!.tee();
128
- const responseInit = {
129
- headers: response.headers,
130
- status: response.status,
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
- storePrefetch(storageKey, new Response(cacheStream, responseInit), gen);
141
- return new Response(navStream, responseInit);
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-stability-review.md): this
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
- renderedPromise = reqCtx._renderBarrier.then(() => {
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
- // Mark scope so ctx.use(loader) calls inside the callback
434
- // are not registered as handler-to-loader deps.
435
- insideHandlePush = true;
436
- try {
437
- const result = (dataOrFn as () => Promise<unknown>)();
438
- store.push(handle.$$id, segmentId, result);
439
- } finally {
440
- insideHandlePush = false;
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() && !insideHandlePush) {
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
- // if the loader calls it later. Skip when the barrier has already
471
- // resolved no deadlock is possible (rendered() resolves immediately).
472
- // _renderBarrierSegmentOrder is undefined before resolution, string[]
473
- // after. This also prevents false positives from handle push callbacks
474
- // that resume after their first await (post-barrier-resolution).
475
- if (reqCtx._renderBarrierSegmentOrder === undefined) {
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
  }
@@ -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-stability-review.md. VERSION comes from the
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>>();
@@ -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). Used by rendered() to fail fast.
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
- // 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
- );
806
- ctx._renderBarrierWaiters = undefined;
807
- ctx._handlerLoaderDeps = undefined;
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)`. Only supported in DSL loaders on non-streaming
76
- * trees (no `loading()`). Throws if called from a handler-invoked
77
- * loader or when the tree uses streaming.
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