@rangojs/router 0.32.0 → 0.33.1

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 (181) hide show
  1. package/README.md +3 -2
  2. package/dist/bin/browser-7KIJ5HSV.js +15 -0
  3. package/dist/bin/chunk-3B7CKBRO.js +463 -0
  4. package/dist/bin/chunk-524VACCG.js +71 -0
  5. package/dist/bin/chunk-LD7UBHT4.js +196 -0
  6. package/dist/bin/chunk-TO6CRWLN.js +1429 -0
  7. package/dist/bin/chunk-TTXC2U3G.js +2477 -0
  8. package/dist/bin/client-urls-projection-DQLWPDAT.js +28 -0
  9. package/dist/bin/forward-user-plugins-URBZKJP4.js +62 -0
  10. package/dist/bin/rango.js +42 -10173
  11. package/dist/bin/runtime-EPUDEHO4.js +596 -0
  12. package/dist/bin/runtime-discovery-3HH6BP3K.js +352 -0
  13. package/dist/bin/state-ACD2MGKJ.js +16 -0
  14. package/dist/bin/temp-rsc-server-JALTIWLT.js +3190 -0
  15. package/dist/types/bin/rango.d.ts +1 -0
  16. package/dist/types/browser/action-fence.d.ts +20 -0
  17. package/dist/types/browser/cookie-name.d.ts +51 -10
  18. package/dist/types/browser/invalidate-client-cache.d.ts +8 -6
  19. package/dist/types/browser/navigation-bridge.d.ts +36 -1
  20. package/dist/types/browser/navigation-client.d.ts +19 -0
  21. package/dist/types/browser/navigation-store-handle.d.ts +37 -4
  22. package/dist/types/browser/navigation-store.d.ts +2 -2
  23. package/dist/types/browser/navigation-transaction.d.ts +0 -1
  24. package/dist/types/browser/partial-update.d.ts +13 -0
  25. package/dist/types/browser/prefetch/cache.d.ts +96 -8
  26. package/dist/types/browser/prefetch/fetch.d.ts +26 -2
  27. package/dist/types/browser/prefetch/queue.d.ts +3 -2
  28. package/dist/types/browser/rango-state.d.ts +34 -5
  29. package/dist/types/browser/react/Link.d.ts +15 -6
  30. package/dist/types/browser/types.d.ts +25 -0
  31. package/dist/types/build/generate-route-types.d.ts +1 -1
  32. package/dist/types/build/route-types/include-resolution.d.ts +9 -1
  33. package/dist/types/build/route-types/router-processing.d.ts +3 -1
  34. package/dist/types/cache/cache-key-utils.d.ts +12 -8
  35. package/dist/types/cache/cache-scope.d.ts +4 -2
  36. package/dist/types/cache/cf/cf-cache-types.d.ts +9 -5
  37. package/dist/types/cache/document-cache-policy.d.ts +9 -1
  38. package/dist/types/cache/memory-segment-store.d.ts +3 -2
  39. package/dist/types/cache/segment-codec.d.ts +2 -2
  40. package/dist/types/cache/shell-snapshot.d.ts +1 -1
  41. package/dist/types/cache/types.d.ts +12 -7
  42. package/dist/types/cache/vercel/vercel-cache-store.d.ts +2 -1
  43. package/dist/types/client-urls/types.d.ts +7 -5
  44. package/dist/types/prefetch-markers.d.ts +9 -0
  45. package/dist/types/prefetch-source-scope.d.ts +66 -0
  46. package/dist/types/route-definition/helpers-types.d.ts +8 -5
  47. package/dist/types/router/intercept-resolution.d.ts +25 -11
  48. package/dist/types/router/match-context.d.ts +1 -0
  49. package/dist/types/router/match-handlers.d.ts +1 -1
  50. package/dist/types/router/prefetch-cache-ttl.d.ts +11 -3
  51. package/dist/types/router/router-context.d.ts +1 -1
  52. package/dist/types/router/router-options.d.ts +6 -3
  53. package/dist/types/router/segment-resolution/loader-cache.d.ts +8 -0
  54. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +23 -7
  55. package/dist/types/router/segment-resolution/revalidation.d.ts +19 -0
  56. package/dist/types/router/source-reads.d.ts +53 -0
  57. package/dist/types/router/types.d.ts +1 -1
  58. package/dist/types/rsc/helpers.d.ts +5 -2
  59. package/dist/types/rsc/prefetch-render-error.d.ts +7 -2
  60. package/dist/types/rsc/request-stages.d.ts +2 -0
  61. package/dist/types/rsc/shell-serve.d.ts +6 -1
  62. package/dist/types/server/context.d.ts +23 -6
  63. package/dist/types/server/cookie-store.d.ts +15 -1
  64. package/dist/types/server/document-location-state.d.ts +5 -2
  65. package/dist/types/server/handle-store.d.ts +2 -0
  66. package/dist/types/server/request-context.d.ts +43 -7
  67. package/dist/types/testing/e2e/server.d.ts +16 -1
  68. package/dist/types/testing/render-route.d.ts +66 -2
  69. package/dist/types/testing/serve-shell-request.d.ts +6 -3
  70. package/dist/types/types/handler-context.d.ts +7 -5
  71. package/dist/types/types/loader-types.d.ts +7 -4
  72. package/dist/types/types/segments.d.ts +15 -4
  73. package/dist/types/urls/path-helper-types.d.ts +7 -5
  74. package/dist/types/vite/temp-rsc-server.d.ts +2 -2
  75. package/dist/vite/index.js +449 -189
  76. package/package.json +2 -2
  77. package/skills/app-structure/SKILL.md +81 -0
  78. package/skills/app-structure/e-commerce.md +10 -9
  79. package/skills/app-structure/large-app.md +18 -2
  80. package/skills/caching/SKILL.md +10 -5
  81. package/skills/client-urls/SKILL.md +29 -11
  82. package/skills/comparison/references/framework-comparison.md +13 -8
  83. package/skills/document-cache/SKILL.md +8 -0
  84. package/skills/hooks/navigation.md +8 -8
  85. package/skills/hooks/outlets.md +4 -0
  86. package/skills/hooks/state.md +24 -8
  87. package/skills/intercept/SKILL.md +33 -12
  88. package/skills/layout/SKILL.md +60 -0
  89. package/skills/links/SKILL.md +101 -4
  90. package/skills/loader/SKILL.md +49 -22
  91. package/skills/migrate-nextjs/SKILL.md +8 -2
  92. package/skills/migrate-react-router/data-and-actions.md +5 -0
  93. package/skills/ppr/SKILL.md +26 -5
  94. package/skills/prerender/SKILL.md +4 -1
  95. package/skills/route/SKILL.md +6 -6
  96. package/skills/router-setup/SKILL.md +2 -1
  97. package/skills/server-actions/SKILL.md +41 -8
  98. package/skills/testing/client-components.md +29 -13
  99. package/skills/testing/serve-shell-request.md +7 -7
  100. package/skills/testing/server-actions.md +8 -0
  101. package/skills/ux-architecture/SKILL.md +70 -19
  102. package/src/bin/rango.ts +39 -13
  103. package/src/browser/action-fence.ts +72 -0
  104. package/src/browser/cookie-name.ts +82 -14
  105. package/src/browser/invalidate-client-cache.ts +9 -7
  106. package/src/browser/navigation-bridge.ts +164 -32
  107. package/src/browser/navigation-client.ts +95 -20
  108. package/src/browser/navigation-store-handle.ts +67 -5
  109. package/src/browser/navigation-store.ts +86 -17
  110. package/src/browser/navigation-transaction.ts +0 -6
  111. package/src/browser/partial-update.ts +31 -12
  112. package/src/browser/prefetch/cache.ts +188 -11
  113. package/src/browser/prefetch/fetch.ts +211 -51
  114. package/src/browser/prefetch/queue.ts +3 -2
  115. package/src/browser/rango-state.ts +50 -8
  116. package/src/browser/react/Link.tsx +20 -9
  117. package/src/browser/react/use-navigation.ts +1 -1
  118. package/src/browser/rsc-router.tsx +8 -1
  119. package/src/browser/server-action-bridge.ts +16 -11
  120. package/src/browser/types.ts +25 -0
  121. package/src/build/generate-route-types.ts +1 -0
  122. package/src/build/route-types/include-resolution.ts +40 -3
  123. package/src/build/route-types/router-processing.ts +170 -25
  124. package/src/build/route-types/scan-filter.ts +1 -0
  125. package/src/cache/cache-key-utils.ts +12 -8
  126. package/src/cache/cache-runtime.ts +5 -1
  127. package/src/cache/cache-scope.ts +28 -7
  128. package/src/cache/cf/cf-cache-types.ts +9 -5
  129. package/src/cache/document-cache-policy.ts +27 -0
  130. package/src/cache/document-cache.ts +5 -0
  131. package/src/cache/memory-segment-store.ts +3 -2
  132. package/src/cache/segment-codec.ts +2 -2
  133. package/src/cache/shell-snapshot.ts +1 -1
  134. package/src/cache/types.ts +12 -7
  135. package/src/cache/vercel/vercel-cache-store.ts +2 -1
  136. package/src/client-urls/types.ts +7 -5
  137. package/src/prefetch-markers.ts +10 -0
  138. package/src/prefetch-source-scope.ts +137 -0
  139. package/src/route-definition/helpers-types.ts +8 -5
  140. package/src/router/intercept-resolution.ts +94 -50
  141. package/src/router/loader-resolution.ts +8 -6
  142. package/src/router/match-api.ts +33 -29
  143. package/src/router/match-context.ts +5 -0
  144. package/src/router/match-handlers.ts +2 -1
  145. package/src/router/match-middleware/cache-lookup.ts +21 -13
  146. package/src/router/match-result.ts +39 -87
  147. package/src/router/navigation-snapshot.ts +18 -3
  148. package/src/router/prefetch-cache-ttl.ts +15 -3
  149. package/src/router/revalidation.ts +53 -23
  150. package/src/router/router-context.ts +2 -1
  151. package/src/router/router-options.ts +6 -3
  152. package/src/router/segment-resolution/loader-cache.ts +21 -3
  153. package/src/router/segment-resolution/prefetch-deferral.ts +38 -31
  154. package/src/router/segment-resolution/revalidation.ts +40 -12
  155. package/src/router/source-reads.ts +122 -0
  156. package/src/router/types.ts +2 -1
  157. package/src/router.ts +11 -12
  158. package/src/rsc/helpers.ts +8 -3
  159. package/src/rsc/prefetch-render-error.ts +7 -2
  160. package/src/rsc/progressive-enhancement.ts +3 -2
  161. package/src/rsc/request-stages.ts +59 -0
  162. package/src/rsc/rsc-rendering.ts +49 -33
  163. package/src/rsc/server-action.ts +2 -1
  164. package/src/rsc/shell-capture.ts +4 -5
  165. package/src/rsc/shell-serve.ts +12 -1
  166. package/src/segment-system.tsx +1 -1
  167. package/src/server/context.ts +37 -6
  168. package/src/server/cookie-store.ts +22 -1
  169. package/src/server/document-location-state.ts +23 -2
  170. package/src/server/handle-store.ts +2 -0
  171. package/src/server/request-context.ts +113 -28
  172. package/src/testing/dispatch.ts +3 -0
  173. package/src/testing/e2e/fixture.ts +4 -12
  174. package/src/testing/e2e/server.ts +91 -4
  175. package/src/testing/render-route.tsx +269 -4
  176. package/src/testing/serve-shell-request.ts +6 -3
  177. package/src/types/handler-context.ts +7 -5
  178. package/src/types/loader-types.ts +7 -4
  179. package/src/types/segments.ts +15 -4
  180. package/src/urls/path-helper-types.ts +7 -5
  181. package/src/vite/temp-rsc-server.ts +2 -2
@@ -71,6 +71,7 @@ export function shouldCacheResponse(
71
71
  RequestContext,
72
72
  | "_payloadVisitorTheme"
73
73
  | "_payloadDeferred"
74
+ | "_payloadSourceScoped"
74
75
  | "_payloadVisitorLocation"
75
76
  | "_documentLocationState"
76
77
  >,
@@ -118,10 +119,36 @@ export function shouldCacheResponse(
118
119
  return null;
119
120
  }
120
121
 
122
+ // A partial scoped to its source page: this cache keys it by
123
+ // URL and the ids the client holds, so it would answer another page with
124
+ // the same ids. rsc-rendering.ts sends it NOT_REUSED; a middleware can
125
+ // replace that after next().
126
+ if (requestCtx?._payloadSourceScoped) {
127
+ return null;
128
+ }
129
+
121
130
  const cacheControl = response.headers.get("Cache-Control");
122
131
  return parseCacheControl(cacheControl);
123
132
  }
124
133
 
134
+ /**
135
+ * Whether a response carries one client's state, which a stored copy would
136
+ * replay to that client's later requests: a per-client signal header (a state
137
+ * cookie rotation, `x-rango-keep-cache`) or location state. The stored
138
+ * prefetch Cache-Control (rsc/request-stages.ts) is not sent on
139
+ * one.
140
+ */
141
+ export function carriesClientState(
142
+ response: Response,
143
+ requestCtx: Pick<RequestContext, "_locationState" | "_documentLocationState">,
144
+ ): boolean {
145
+ return (
146
+ hasPerClientSignal(response.headers) ||
147
+ (requestCtx._locationState?.length ?? 0) > 0 ||
148
+ requestCtx._documentLocationState === true
149
+ );
150
+ }
151
+
125
152
  /**
126
153
  * Whether this cache will store the response the current render produces, as
127
154
  * far as the render can tell: the middleware runs it (a MISS or a stale
@@ -357,6 +357,11 @@ export function createDocumentCacheMiddleware<TEnv = any>(
357
357
  if (!cached.shouldRevalidate) {
358
358
  // Fresh hit - return immediately
359
359
  log(`[DocumentCache] HIT ${typeLabel}: ${url.pathname}`);
360
+ // Nothing in it runs: its loaders' output is replayed, and no
361
+ // failed render is ever stored (throwIfRenderErrored). A prefetch
362
+ // answered with it leaves with the router's prefetch Cache-Control
363
+ // (rsc/request-stages.ts).
364
+ requestCtx._servedFromStorage = true;
360
365
  return drainOnResponseCallbacks(
361
366
  addCacheStatusHeader(cached.response, "HIT"),
362
367
  requestCtx,
@@ -120,13 +120,14 @@ export interface MemorySegmentCacheStoreOptions<TEnv = unknown> {
120
120
 
121
121
  /**
122
122
  * Custom key generator applied to all cache operations.
123
- * Receives the full RequestContext and the default-generated key.
123
+ * Receives the full RequestContext and the default-generated key. A route
124
+ * record keeps its request kind (see SegmentCacheStore.keyGenerator).
124
125
  *
125
126
  * @example
126
127
  * ```typescript
127
128
  * keyGenerator: (ctx, defaultKey) => {
128
129
  * const locale = cookies().get('locale')?.value || 'en';
129
- * return `${locale}:${defaultKey}`;
130
+ * return `${defaultKey}|${locale}`;
130
131
  * }
131
132
  * ```
132
133
  */
@@ -309,8 +309,8 @@ export async function serializeSegments(
309
309
  * cached with the route record).
310
310
  *
311
311
  * The loading "null" sentinel decodes here (not on the consumer): loading:null
312
- * must survive as null, and shipping the raw sentinel would change the
313
- * deduplicateLoaderSegments loading-presence check.
312
+ * must survive as null, and shipping the raw sentinel would hand renderSegments
313
+ * a truthy `loading`, the fallback tree shape (docs/tree-structure.md).
314
314
  */
315
315
  export async function fragmentSegments(
316
316
  data: SerializedSegmentData[],
@@ -407,7 +407,7 @@ const PIN_KEY_PREFIX = /^[^D.]*D\d+\./;
407
407
  * Deserializations run in parallel; a record that fails to decode is skipped
408
408
  * and reported to `onUndecodable`: its loader runs instead, which a document
409
409
  * HIT has already committed a prelude against, so serveShellHit retires the
410
- * entry (dc-t89). Returns undefined when the snapshot carries no loader
410
+ * entry. Returns undefined when the snapshot carries no loader
411
411
  * records, without touching the Flight codec (kept lazy for cold paths and
412
412
  * non-RSC configs).
413
413
  */
@@ -93,16 +93,21 @@ export interface SegmentCacheStore<TEnv = unknown> {
93
93
  /**
94
94
  * Custom key generator applied to all cache operations using this store.
95
95
  * Receives the full RequestContext and the default-generated key.
96
- * Return value becomes the final cache key (unless route overrides with `key` option).
96
+ * The return value is the cache key (unless route overrides with `key`
97
+ * option), with a route record's request kind appended when it does not
98
+ * start with that kind (below).
97
99
  *
98
100
  * Resolution priority:
99
101
  * 1. Route-level `key` function (replaces the URL part; the request kind stays)
100
102
  * 2. Store-level `keyGenerator` (modifies default key)
101
103
  * 3. Default key generation (prefix:pathname:params)
102
104
  *
103
- * Keep `defaultKey` in the result: its prefix names the request kind (a
104
- * document, a navigation or an intercept navigation), whose records hold
105
- * different segments. A result without it gives every kind one record.
105
+ * A route record keeps its request kind (a document, a navigation or an
106
+ * intercept navigation), whose records hold different segments: the kind
107
+ * is `defaultKey`'s prefix (`doc:`, `partial:`, `intercept:<intercept>:`),
108
+ * and a result that does not start with it gets the kind appended as a
109
+ * key part of its own. Build on `defaultKey` (`${defaultKey}|${segment}`)
110
+ * and the result is the record key byte for byte.
106
111
  *
107
112
  * Return `defaultKey` unchanged to leave a request unpartitioned. An empty
108
113
  * string is not a key: where the result partitions a nested cache() on
@@ -114,7 +119,7 @@ export interface SegmentCacheStore<TEnv = unknown> {
114
119
  * ```typescript
115
120
  * keyGenerator: (ctx, defaultKey) => {
116
121
  * const segment = ctx.request.headers.get('x-user-segment') || 'default';
117
- * return `${segment}:${defaultKey}`;
122
+ * return `${defaultKey}|${segment}`;
118
123
  * }
119
124
  * ```
120
125
  *
@@ -122,7 +127,7 @@ export interface SegmentCacheStore<TEnv = unknown> {
122
127
  * ```typescript
123
128
  * keyGenerator: (ctx, defaultKey) => {
124
129
  * const region = ctx.env.REGION || 'us';
125
- * return `${region}:${defaultKey}`;
130
+ * return `${defaultKey}|${region}`;
126
131
  * }
127
132
  * ```
128
133
  *
@@ -130,7 +135,7 @@ export interface SegmentCacheStore<TEnv = unknown> {
130
135
  * ```typescript
131
136
  * keyGenerator: (ctx, defaultKey) => {
132
137
  * const locale = cookies().get('locale')?.value || 'en';
133
- * return `${locale}:${defaultKey}`;
138
+ * return `${defaultKey}|${locale}`;
134
139
  * }
135
140
  * ```
136
141
  */
@@ -356,7 +356,8 @@ export interface VercelCacheStoreOptions<TEnv = unknown> {
356
356
 
357
357
  /**
358
358
  * Custom key generator applied to all cache operations (region/locale/user
359
- * segmentation). Receives the request context and the default key.
359
+ * segmentation). Receives the request context and the default key. A route
360
+ * record keeps its request kind (see SegmentCacheStore.keyGenerator).
360
361
  */
361
362
  keyGenerator?: (
362
363
  ctx: RequestContext<TEnv>,
@@ -85,8 +85,9 @@ export interface ClientUrlLoaderRecord {
85
85
  */
86
86
  readonly ssr?: false;
87
87
  /**
88
- * loader(Def, { prefetch: false }): a prefetch of the route, or of an
89
- * intercept's target from its source, defers this loader and the click
88
+ * loader(Def, { prefetch: false }): a viewport or render prefetch of the
89
+ * route, or of an intercept's target from its source, defers this loader
90
+ * (a hover prefetch runs it) and the click
90
91
  * that adopts it runs it in its fill request (see {@link LoaderOptions}).
91
92
  * Projected onto the materialized stub.
92
93
  */
@@ -169,12 +170,13 @@ export interface ClientUrlHelpers {
169
170
  * holes).
170
171
  *
171
172
  * Pass `{ prefetch: false }` to keep this loader out of the route's
172
- * prefetches (see {@link LoaderOptions}): an in-group click still shows
173
+ * viewport and render prefetches; a hover prefetch runs it (see
174
+ * {@link LoaderOptions}). An in-group click still shows
173
175
  * the destination at once, commits the adopted prefetch with the other
174
176
  * loaders' data, and runs only the flagged loaders in its one fill
175
177
  * request. A `layout()` loader with the flag is flagged on every route
176
- * below it. Inside intercept() it defers the loader in a prefetch of the
177
- * intercept from its source, as in urls().
178
+ * below it. Inside intercept() it defers the loader in a viewport or
179
+ * render prefetch of the intercept from its source, as in urls().
178
180
  */
179
181
  readonly loader: <TData>(
180
182
  definition: LoaderDefinition<TData>,
@@ -9,6 +9,16 @@
9
9
  /** Request header of a `<Link>` prefetch (browser/prefetch/fetch.ts). */
10
10
  export const PREFETCH_HEADER = "X-Rango-Prefetch";
11
11
 
12
+ /**
13
+ * PREFETCH_HEADER's value on an intent prefetch: a hover or an explicit
14
+ * `prefetch()` call (browser/prefetch/fetch.ts prefetchDirect). The server
15
+ * defers nothing in it: `prefetch: false` loaders and the work behind a
16
+ * flagged `loading()` run, as in the navigation it starts early. A
17
+ * speculative prefetch (viewport, render) sends `1`, and any value other than
18
+ * this one reads as speculative.
19
+ */
20
+ export const PREFETCH_INTENT = "intent";
21
+
12
22
  /**
13
23
  * The last row of a prefetch whose render reported an error
14
24
  * (rsc/prefetch-render-error.ts): a Flight hint with a code React does not
@@ -0,0 +1,137 @@
1
+ /**
2
+ * The source group of a prefetch answer a revalidate() rule kept from the
3
+ * page being left (#1331). Shared by the server, which reports
4
+ * the group in `x-rsc-prefetch-scope: params; <label>` (rsc/rsc-rendering.ts),
5
+ * and the browser, which stores the answer under that label and rebuilds it
6
+ * from the page on screen to look it up (browser/prefetch/cache.ts).
7
+ * Dependency free, like prefetch-markers.ts: the browser must not import the
8
+ * server modules that record the reads (router/source-reads.ts).
9
+ *
10
+ * Why a label is enough: a rule's decision depends on the source page only
11
+ * through what it read. Two pages with equal values of everything the kept
12
+ * rules read run every rule the same way, so one answer serves both.
13
+ */
14
+
15
+ /** What the revalidate() rules that kept a segment read of the source page. */
16
+ export interface SourceReads {
17
+ /**
18
+ * `currentParams` was read. Its key set is then part of the group: a rule
19
+ * can test a key's presence (`in`, `Object.keys`) without reading a value.
20
+ */
21
+ keys: boolean;
22
+ /**
23
+ * Each `currentParams` value read; a spread or JSON.stringify reads all.
24
+ * Non-empty only with `keys`: a value is read through `currentParams`.
25
+ */
26
+ params: Set<string>;
27
+ /** `fromRouteName` was read. */
28
+ route: boolean;
29
+ /** `currentUrl` was read: the answer is the exact page's. */
30
+ url: boolean;
31
+ }
32
+
33
+ /** The page a label describes: the source on the server, the screen in the browser. */
34
+ export interface SourcePage {
35
+ params: Readonly<Record<string, string | undefined>>;
36
+ /** The public route name (route-name.ts publicRouteName). */
37
+ routeName: string | undefined;
38
+ }
39
+
40
+ /** What a label reads, without the values: one per kind of rule set. */
41
+ export interface SourceShape {
42
+ keys: boolean;
43
+ route: boolean;
44
+ /** Sorted. */
45
+ names: readonly string[];
46
+ }
47
+
48
+ /** `x-rsc-prefetch-scope` value prefix of a params-grouped answer. */
49
+ export const PARAMS_SCOPE_PREFIX = "params; ";
50
+
51
+ export function createSourceReads(): SourceReads {
52
+ return { keys: false, params: new Set(), route: false, url: false };
53
+ }
54
+
55
+ export function hasSourceReads(reads: SourceReads): boolean {
56
+ return reads.keys || reads.route || reads.url || reads.params.size > 0;
57
+ }
58
+
59
+ /** Union `from` into `into`. */
60
+ export function mergeSourceReads(into: SourceReads, from: SourceReads): void {
61
+ into.keys ||= from.keys;
62
+ into.route ||= from.route;
63
+ into.url ||= from.url;
64
+ for (const name of from.params) into.params.add(name);
65
+ }
66
+
67
+ export function shapeOfReads(reads: SourceReads): SourceShape {
68
+ return {
69
+ keys: reads.keys,
70
+ route: reads.route,
71
+ names: [...reads.params].sort(),
72
+ };
73
+ }
74
+
75
+ /**
76
+ * The label of `page` under `shape`: `&`-joined, `$k=<keys>` first, then
77
+ * `$r[=<route>]`, then `<name>[=<value>]` per read param, sorted. A missing
78
+ * value has no `=`. Names and values are URI-encoded, so a `$` entry never
79
+ * collides with a param (encodeURIComponent encodes `$`).
80
+ */
81
+ export function formatSourceLabel(
82
+ shape: SourceShape,
83
+ page: SourcePage,
84
+ ): string {
85
+ const parts: string[] = [];
86
+ if (shape.keys) {
87
+ parts.push(
88
+ "$k=" + Object.keys(page.params).sort().map(encodeURIComponent).join(","),
89
+ );
90
+ }
91
+ if (shape.route) parts.push(entry("$r", page.routeName));
92
+ for (const name of shape.names) {
93
+ parts.push(entry(encodeURIComponent(name), page.params[name]));
94
+ }
95
+ return parts.join("&");
96
+ }
97
+
98
+ function entry(name: string, value: string | undefined): string {
99
+ return value === undefined ? name : name + "=" + encodeURIComponent(value);
100
+ }
101
+
102
+ /**
103
+ * The label of an `x-rsc-prefetch-scope` value, or undefined when the value
104
+ * is not a params scope.
105
+ */
106
+ export function paramsScopeLabel(
107
+ header: string | null | undefined,
108
+ ): string | undefined {
109
+ if (!header?.startsWith(PARAMS_SCOPE_PREFIX)) return undefined;
110
+ return header.slice(PARAMS_SCOPE_PREFIX.length);
111
+ }
112
+
113
+ /** The shape of a label formatSourceLabel wrote. */
114
+ export function parseSourceShape(label: string): SourceShape {
115
+ let keys = false;
116
+ let route = false;
117
+ const names: string[] = [];
118
+ for (const part of label.split("&")) {
119
+ if (part === "") continue;
120
+ const eq = part.indexOf("=");
121
+ const name = eq === -1 ? part : part.slice(0, eq);
122
+ if (name === "$k") keys = true;
123
+ else if (name === "$r") route = true;
124
+ else names.push(decodeURIComponent(name));
125
+ }
126
+ return { keys, route, names: names.sort() };
127
+ }
128
+
129
+ /** A stable id of a shape, for indexing. */
130
+ export function sourceShapeId(shape: SourceShape): string {
131
+ return (
132
+ (shape.keys ? "k" : "") +
133
+ (shape.route ? "r" : "") +
134
+ ":" +
135
+ shape.names.map(encodeURIComponent).join(",")
136
+ );
137
+ }
@@ -333,9 +333,10 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
333
333
  * loader(RecommendationsLoader), // still streams behind loading()
334
334
  * ```
335
335
  *
336
- * Pass `{ prefetch: false }` to keep the loader out of prefetches: a
337
- * prefetch skips it and the click that adopts the prefetch fetches it in
338
- * one follow-up request (see {@link LoaderOptions}). Only on a segment the
336
+ * Pass `{ prefetch: false }` to keep the loader out of speculative
337
+ * prefetches (viewport, render): such a prefetch skips it and the click
338
+ * that adopts it fetches it in one follow-up request; a hover prefetch runs
339
+ * it (see {@link LoaderOptions}). Only on a segment the
339
340
  * client does not have yet: a loader the client already holds runs in the
340
341
  * prefetch when it revalidates.
341
342
  *
@@ -361,14 +362,16 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
361
362
  * // Skip loading on SSR, only show on client navigation
362
363
  * loading(<Skeleton />, { ssr: false })
363
364
  *
364
- * // Keep everything behind this fallback out of prefetches
365
+ * // Keep everything behind this fallback out of viewport and render
366
+ * // prefetches (a hover prefetch runs it)
365
367
  * loading(<Skeleton />, { prefetch: false })
366
368
  * ```
367
369
  * @param component - The loading UI to show during navigation
368
370
  * @param options - Configuration options
369
371
  * @param options.ssr - If false, skip showing loading on document requests (SSR)
370
372
  * @param options.prefetch - If false, the flag applies when this entry's
371
- * segment is new to the client. A prefetch then skips the whole unit: the
373
+ * segment is new to the client. A viewport or render prefetch then skips
374
+ * the whole unit (a hover prefetch runs it): the
372
375
  * handler, its loaders, its orphan layouts and parallel slots and, for a
373
376
  * layout, every deeper entry; the click that adopts the prefetch shows
374
377
  * this fallback and fetches them in one follow-up request, which reads
@@ -26,11 +26,12 @@ import {
26
26
  } from "./segment-resolution/revalidation.js";
27
27
  import { fillSends } from "./segment-resolution/prefetch-deferral.js";
28
28
  import type { ResolutionPass } from "./segment-resolution/resolution-pass.js";
29
- import { getRouterContext } from "./router-context.js";
29
+ import { getRouterContext, type InterceptResult } from "./router-context.js";
30
30
  import { runInSegmentTagScope } from "../cache/cache-tag.js";
31
31
  import {
32
32
  RangoContext,
33
33
  latchPprHeaderScopeForEntries,
34
+ runAsSegment,
34
35
  } from "../server/context.js";
35
36
  import { _getRequestContext } from "../server/request-context.js";
36
37
 
@@ -60,18 +61,11 @@ function latchPprHeaderScopeForInterceptTarget(
60
61
  * If no when() conditions are defined, the intercept always activates.
61
62
  * A selector that throws yields the conservative default, no intercept (the
62
63
  * full page renders), and is logged; it never fails the request.
63
- *
64
- * During action revalidation, when() is NOT evaluated.
65
64
  */
66
65
  export function evaluateInterceptWhen(
67
66
  intercept: InterceptEntry,
68
67
  selectorContext: InterceptSelectorContext | null,
69
- isAction: boolean,
70
68
  ): boolean {
71
- if (isAction) {
72
- return true;
73
- }
74
-
75
69
  if (!intercept.when || intercept.when.length === 0) {
76
70
  return true;
77
71
  }
@@ -92,9 +86,34 @@ export function evaluateInterceptWhen(
92
86
  }
93
87
 
94
88
  /**
95
- * Find an intercept for the target route by walking up the entry chain.
96
- * Returns the first (innermost) matching intercept along with the entry that defines it.
89
+ * Walk the intercepts declared along `fromEntry`'s chain in selection order,
90
+ * an entry's own and then its orphans' (nested ones included) in render
91
+ * order, from `fromEntry` up, until `pick` returns a value. Tolerates partial
92
+ * entries (unit-test mocks omit the arrays); real registration always
93
+ * populates layout/intercept.
97
94
  */
95
+ function findOnChain<T>(
96
+ fromEntry: EntryData | null,
97
+ pick: (intercept: InterceptEntry, entry: EntryData) => T | undefined,
98
+ ): T | undefined {
99
+ const visit = (entry: EntryData): T | undefined => {
100
+ for (const intercept of entry.intercept ?? []) {
101
+ const found = pick(intercept, entry);
102
+ if (found !== undefined) return found;
103
+ }
104
+ return undefined;
105
+ };
106
+ let current: EntryData | null = fromEntry;
107
+ let prev: EntryData | null = null;
108
+ while (current) {
109
+ const found = visit(current) ?? findInOrphans(current, visit, prev);
110
+ if (found !== undefined) return found;
111
+ prev = current;
112
+ current = current.parent;
113
+ }
114
+ return undefined;
115
+ }
116
+
98
117
  /**
99
118
  * Collect every intercept TARGET route name reachable from an origin entry —
100
119
  * the same chain walk findInterceptForRoute() performs when this location is
@@ -110,55 +129,78 @@ export function collectInterceptTargetNames(
110
129
  fromEntry: EntryData | null,
111
130
  ): string[] {
112
131
  const names = new Set<string>();
113
- // Tolerate partial entries (unit-test mocks omit the arrays); real
114
- // registration always populates layout/intercept.
115
- const add = (source: EntryData): undefined => {
116
- for (const intercept of source.intercept ?? []) {
117
- names.add(intercept.routeName);
118
- }
132
+ findOnChain(fromEntry, (intercept) => {
133
+ names.add(intercept.routeName);
119
134
  return undefined;
120
- };
121
- let current: EntryData | null = fromEntry;
122
- let prev: EntryData | null = null;
123
- while (current) {
124
- add(current);
125
- findInOrphans(current, add, prev);
126
- prev = current;
127
- current = current.parent;
128
- }
135
+ });
129
136
  return [...names];
130
137
  }
131
138
 
139
+ /**
140
+ * The intercept for the target route, with the entry that declares it.
141
+ * `targetRouteKeys` are the names it may be declared for, in precedence
142
+ * order: the route's own name, then its local name inside include()
143
+ * (match-api.ts). A navigation, or an action with no `held`, takes the first
144
+ * along the chain from `fromEntry` up under the first name that has one: on a
145
+ * navigation, the first whose when() passes; on an action, the first, when()
146
+ * not evaluated.
147
+ *
148
+ * An action given `held` (the request's segment ids) re-renders the intercept
149
+ * on screen: one whose slot id the client holds, null when it holds none. Its
150
+ * candidates are collected under every name in one walk, the route's own
151
+ * name's first: a held id under the own name (a parallel() default of the
152
+ * slot's name) must not win before the local name is looked at. One held:
153
+ * that one, when() not evaluated, so an open intercept stays open when its
154
+ * when() would now reject. Several held (intercepts on one entry that fill one
155
+ * slot share its id, as does a parallel() slot of that name): the ones whose
156
+ * slot has no parallel() default on their entry, if any (a default holds its
157
+ * slot id whether or not an intercept is open there). One left: that one,
158
+ * when() not evaluated. Else the first whose when() passes against the
159
+ * action's intercept source, else the first.
160
+ */
132
161
  export function findInterceptForRoute(
133
- targetRouteKey: string,
162
+ targetRouteKeys: string | readonly string[],
134
163
  fromEntry: EntryData | null,
135
164
  selectorContext: InterceptSelectorContext | null = null,
136
165
  isAction: boolean = false,
137
- ): { intercept: InterceptEntry; entry: EntryData } | null {
138
- const match = (source: EntryData) => {
139
- for (const intercept of source.intercept) {
140
- if (
141
- intercept.routeName === targetRouteKey &&
142
- evaluateInterceptWhen(intercept, selectorContext, isAction)
143
- ) {
144
- return { intercept, entry: source };
145
- }
166
+ held?: ReadonlySet<string>,
167
+ ): InterceptResult | null {
168
+ const names =
169
+ typeof targetRouteKeys === "string" ? [targetRouteKeys] : targetRouteKeys;
170
+ if (!isAction || !held) {
171
+ for (const name of names) {
172
+ const found = findOnChain(fromEntry, (intercept, entry) =>
173
+ intercept.routeName === name &&
174
+ (isAction || evaluateInterceptWhen(intercept, selectorContext))
175
+ ? { intercept, entry }
176
+ : undefined,
177
+ );
178
+ if (found) return found;
146
179
  }
147
- return undefined;
148
- };
149
- let current: EntryData | null = fromEntry;
150
- let prev: EntryData | null = null;
151
-
152
- while (current) {
153
- // current first, then its orphans (nested ones included) in render order.
154
- const found = match(current) ?? findInOrphans(current, match, prev);
155
- if (found) return found;
156
-
157
- prev = current;
158
- current = current.parent;
180
+ return null;
159
181
  }
160
-
161
- return null;
182
+ const byName: InterceptResult[][] = names.map(() => []);
183
+ findOnChain(fromEntry, (intercept, entry) => {
184
+ const rank = names.indexOf(intercept.routeName);
185
+ if (rank !== -1 && held.has(interceptSlotId(entry, intercept))) {
186
+ byName[rank]!.push({ intercept, entry });
187
+ }
188
+ return undefined;
189
+ });
190
+ const open = byName.flat();
191
+ if (open.length < 2) return open[0] ?? null;
192
+ // A parallel() default of the slot's name holds its slot id whichever
193
+ // intercept is open, so a candidate that has one proves nothing about what
194
+ // is on screen: among several, the ones without a default are the open ones.
195
+ const unshadowed = open.filter(
196
+ (c) => !c.entry.parallel?.[c.intercept.slotName],
197
+ );
198
+ const pool = unshadowed.length > 0 ? unshadowed : open;
199
+ if (pool.length === 1) return pool[0]!;
200
+ return (
201
+ pool.find((c) => evaluateInterceptWhen(c.intercept, selectorContext)) ??
202
+ pool[0]!
203
+ );
162
204
  }
163
205
 
164
206
  /**
@@ -357,7 +399,9 @@ export async function resolveInterceptEntry<TEnv>(
357
399
  handlerResult =
358
400
  typeof handler === "function"
359
401
  ? handleHandlerResult(
360
- runInSegmentTagScope(slotSegmentId, handler, context),
402
+ runAsSegment(slotSegmentId, () =>
403
+ runInSegmentTagScope(slotSegmentId, handler, context),
404
+ ),
361
405
  )
362
406
  : handler;
363
407
  } catch (error) {
@@ -70,6 +70,8 @@ import {
70
70
  scopeReverse,
71
71
  runInsideLoaderBodyScope,
72
72
  runInsideLoaderRun,
73
+ runAsSegment,
74
+ segmentPinOf,
73
75
  isInsidePushCallbackScope,
74
76
  runInsidePushCallbackScope,
75
77
  type LoaderRunIdentity,
@@ -871,8 +873,10 @@ function createLoaderExecutor<TEnv>(
871
873
  Promise.resolve(
872
874
  runInsideLoaderBodyScope(
873
875
  () =>
874
- runInsideLoaderRun(identity, () =>
875
- loaderFn(loaderCtx as LoaderContext<any, TEnv>),
876
+ runAsSegment(owningSegmentId, () =>
877
+ runInsideLoaderRun(identity, () =>
878
+ loaderFn(loaderCtx as LoaderContext<any, TEnv>),
879
+ ),
876
880
  ),
877
881
  loader.$$id,
878
882
  recordedTags,
@@ -982,8 +986,7 @@ export function setupLoaderAccess<TEnv>(
982
986
  if (isHandle(item)) {
983
987
  const handle = item;
984
988
  const store = handleStoreRef;
985
- const segmentId = (ctx as InternalHandlerContext<any, TEnv>)
986
- ._currentSegmentId;
989
+ const segmentId = segmentPinOf(ctx as InternalHandlerContext<any, TEnv>);
987
990
 
988
991
  if (!segmentId) {
989
992
  throw new Error(
@@ -1057,8 +1060,7 @@ export function setupBuildUse<TEnv>(ctx: HandlerContext<any, TEnv>): void {
1057
1060
  if (isHandle(item)) {
1058
1061
  const handle = item;
1059
1062
  const store = handleStoreRef;
1060
- const segmentId = (ctx as InternalHandlerContext<any, TEnv>)
1061
- ._currentSegmentId;
1063
+ const segmentId = segmentPinOf(ctx as InternalHandlerContext<any, TEnv>);
1062
1064
 
1063
1065
  if (!segmentId) {
1064
1066
  throw new Error(