@rangojs/router 0.0.0-experimental.88a3b2f7 → 0.0.0-experimental.8bcfea43

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