@rangojs/router 0.31.0 → 0.32.0

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 (52) hide show
  1. package/dist/bin/rango.js +37 -7
  2. package/dist/types/browser/history-state.d.ts +13 -2
  3. package/dist/types/browser/react/Link.d.ts +10 -13
  4. package/dist/types/browser/react/location-state-shared.d.ts +2 -1
  5. package/dist/types/cache/document-cache-policy.d.ts +1 -1
  6. package/dist/types/route-definition/redirect.d.ts +5 -0
  7. package/dist/types/router/middleware-types.d.ts +6 -0
  8. package/dist/types/rsc/helpers.d.ts +5 -3
  9. package/dist/types/rsc/request-stages.d.ts +2 -0
  10. package/dist/types/server/document-location-state.d.ts +56 -0
  11. package/dist/types/server/request-context.d.ts +23 -6
  12. package/dist/types/testing/render-route.d.ts +17 -0
  13. package/dist/types/testing/serve-shell-request.d.ts +9 -0
  14. package/dist/types/types/handler-context.d.ts +18 -3
  15. package/dist/types/types/segments.d.ts +6 -0
  16. package/dist/types/vite/utils/forward-user-plugins.d.ts +32 -2
  17. package/dist/vite/index.js +3 -3
  18. package/package.json +1 -1
  19. package/skills/comparison/references/framework-comparison.md +3 -2
  20. package/skills/document-cache/SKILL.md +5 -0
  21. package/skills/hooks/state.md +110 -6
  22. package/skills/intercept/SKILL.md +2 -1
  23. package/skills/ppr/SKILL.md +12 -0
  24. package/skills/server-actions/SKILL.md +8 -0
  25. package/skills/testing/client-components.md +44 -19
  26. package/skills/testing/serve-shell-request.md +38 -13
  27. package/skills/ux-architecture/SKILL.md +6 -3
  28. package/src/browser/history-state.ts +24 -2
  29. package/src/browser/react/Link.tsx +10 -13
  30. package/src/browser/react/location-state-shared.ts +8 -1
  31. package/src/browser/rsc-router.tsx +5 -2
  32. package/src/build/runtime-discovery.ts +46 -8
  33. package/src/cache/document-cache-policy.ts +10 -1
  34. package/src/cache/document-cache.ts +7 -0
  35. package/src/route-definition/redirect.ts +5 -0
  36. package/src/router/match-middleware/cache-lookup.ts +23 -6
  37. package/src/router/match-result.ts +9 -1
  38. package/src/router/middleware-types.ts +6 -0
  39. package/src/router/revalidation.ts +23 -3
  40. package/src/rsc/helpers.ts +13 -4
  41. package/src/rsc/progressive-enhancement.ts +5 -0
  42. package/src/rsc/request-stages.ts +28 -1
  43. package/src/rsc/rsc-rendering.ts +39 -9
  44. package/src/server/document-location-state.ts +313 -0
  45. package/src/server/request-context.ts +24 -5
  46. package/src/testing/dispatch.ts +3 -0
  47. package/src/testing/render-route.tsx +40 -6
  48. package/src/testing/run-revalidate.ts +11 -1
  49. package/src/testing/serve-shell-request.ts +16 -0
  50. package/src/types/handler-context.ts +18 -3
  51. package/src/types/segments.ts +6 -0
  52. package/src/vite/utils/forward-user-plugins.ts +13 -5
@@ -0,0 +1,313 @@
1
+ /**
2
+ * Location state on document responses (#1330).
3
+ *
4
+ * A client navigation or an action carries server-set location state in its
5
+ * Flight payload, and a redirect on either becomes a Flight redirect that
6
+ * carries it too. A document request has no such channel for a 3xx: the
7
+ * browser follows it with a plain GET. This module carries the state across
8
+ * that one hop in a short-lived cookie:
9
+ *
10
+ * - `carryLocationStateAcrossRedirect` (rsc/request-stages.ts
11
+ * finalizeRequestResponse) writes it on a document request's same-origin
12
+ * 3xx when the request set location state;
13
+ * - `consumeDocumentLocationState` (rsc/request-stages.ts runRequestPasses)
14
+ * reads it on the next GET document request that navigates to the target
15
+ * pathname (`Sec-Fetch-Dest` a browsing context, where sent), seeds
16
+ * `_locationState` with its entries (so the render attaches them to the
17
+ * bootstrap payload, rsc/rsc-rendering.ts) and clears it.
18
+ *
19
+ * A document that carries location state is per visitor: it is marked
20
+ * `_documentLocationState`, never stored by the document cache or as a PPR
21
+ * shell, and leaves with `Cache-Control: private, no-store`
22
+ * (`markDocumentResponsePrivate`).
23
+ *
24
+ * The value is JSON, not Flight: a cookie holds text, and the client merges
25
+ * it as `history.state` would hold it. `Path=/` because the `__Host-` prefix
26
+ * (https) requires it, which keeps a sibling subdomain from planting state;
27
+ * the target pathname inside the value decides who consumes it.
28
+ */
29
+
30
+ import {
31
+ isLocationStateKey,
32
+ resolveLocationStateEntries,
33
+ type LocationStateEntry,
34
+ } from "../browser/react/location-state-shared.js";
35
+ import { getRawCookieValue } from "../browser/cookie-name.js";
36
+ import { isExternalRedirect } from "../redirect-origin.js";
37
+ import { isWebSocketUpgradeResponse } from "../response-utils.js";
38
+ import { mayNeedSSR } from "../rsc/ssr-setup.js";
39
+ import { requestHeaders } from "./request-headers.js";
40
+ import {
41
+ serializeCookieValue,
42
+ type RequestContext,
43
+ } from "./request-context.js";
44
+
45
+ /** Seconds the cookie lives: one redirect hop, never a later visit. */
46
+ const DOCUMENT_LOCATION_STATE_MAX_AGE = 60;
47
+
48
+ /**
49
+ * Upper bound of one serialized `Set-Cookie` (name, value and attributes).
50
+ * Browsers keep at least 4096 bytes per cookie; the margin covers the
51
+ * attributes some add.
52
+ */
53
+ const DOCUMENT_LOCATION_STATE_MAX_BYTES = 4000;
54
+
55
+ const COOKIE_SUFFIX = "-ls";
56
+
57
+ const PRIVATE_NO_STORE = "private, no-store";
58
+
59
+ interface CarriedState {
60
+ /** The pathname of the redirect target, the only request that reads it. */
61
+ p: string;
62
+ /** The resolved entries: slot key to value. */
63
+ s: Record<string, unknown>;
64
+ }
65
+
66
+ /** The cookie's name for a router: `__Host-` on https. */
67
+ export function documentLocationStateCookieName(
68
+ stateCookieName: string,
69
+ secure: boolean,
70
+ ): string {
71
+ return `${secure ? "__Host-" : ""}${stateCookieName}${COOKIE_SUFFIX}`;
72
+ }
73
+
74
+ function cookieAttributes(secure: boolean): {
75
+ path: string;
76
+ httpOnly: boolean;
77
+ secure: boolean;
78
+ sameSite: "lax";
79
+ } {
80
+ return { path: "/", httpOnly: true, secure, sameSite: "lax" };
81
+ }
82
+
83
+ /**
84
+ * A document request: one that renders HTML (mayNeedSSR), not a navigation,
85
+ * an action, a loader fetch or a Flight fetch. Those carry state in their
86
+ * Flight payload, or are not what the browser shows.
87
+ */
88
+ function isDocumentRequest(request: Request, url: URL): boolean {
89
+ return mayNeedSSR(request, url);
90
+ }
91
+
92
+ function parseCarriedState(raw: string): CarriedState | undefined {
93
+ try {
94
+ const parsed: unknown = JSON.parse(decodeURIComponent(raw));
95
+ if (parsed === null || typeof parsed !== "object") return undefined;
96
+ const { p, s } = parsed as Partial<CarriedState>;
97
+ if (typeof p !== "string" || s === null || typeof s !== "object") {
98
+ return undefined;
99
+ }
100
+ return { p, s };
101
+ } catch {
102
+ return undefined;
103
+ }
104
+ }
105
+
106
+ /**
107
+ * `Sec-Fetch-Dest` values of a request that navigates a browsing context: the
108
+ * one a browser makes when it follows a document redirect.
109
+ */
110
+ const NAVIGATION_FETCH_DESTS: ReadonlySet<string> = new Set([
111
+ "document",
112
+ "iframe",
113
+ "frame",
114
+ ]);
115
+
116
+ /**
117
+ * A request that navigates: the browser shows its response. Where the browser
118
+ * sends `Sec-Fetch-Dest`, it must name a browsing context. A same-path
119
+ * `fetch()` with a wildcard `Accept` or a `<link rel=prefetch>` passes
120
+ * mayNeedSSR but sends `empty`, and must not eat the cookie before the
121
+ * navigation it was left for. A request without the header (an older
122
+ * browser, a non-browser client) keeps the mayNeedSSR rule alone.
123
+ */
124
+ function isNavigationRequest(request: Request): boolean {
125
+ const dest = requestHeaders(request).get("sec-fetch-dest");
126
+ return dest === null || NAVIGATION_FETCH_DESTS.has(dest);
127
+ }
128
+
129
+ /**
130
+ * Read the cookie a document redirect left for this request: seed
131
+ * `_locationState` with its slots and clear it. Only a GET document request
132
+ * of the target pathname that navigates consumes it (the request the browser
133
+ * makes for a 301/302/303, see isNavigationRequest); any other request, a
134
+ * form POST to that path or a prefetch of it included, leaves it to its
135
+ * expiry. A malformed value is cleared and ignored, and only `__rsc_ls_*`
136
+ * slots are taken from it (the value comes from the browser).
137
+ */
138
+ export function consumeDocumentLocationState(
139
+ reqCtx: Pick<RequestContext, "_locationState" | "setCookie">,
140
+ request: Request,
141
+ url: URL,
142
+ stateCookieName: string,
143
+ ): void {
144
+ if (request.method !== "GET") return;
145
+ if (!isDocumentRequest(request, url)) return;
146
+ if (!isNavigationRequest(request)) return;
147
+ const secure = url.protocol === "https:";
148
+ const name = documentLocationStateCookieName(stateCookieName, secure);
149
+ const raw = getRawCookieValue(requestHeaders(request).get("cookie"), name);
150
+ if (raw === null) return;
151
+ const carried = parseCarriedState(raw);
152
+ if (carried && carried.p !== url.pathname) return;
153
+ reqCtx.setCookie(name, "", { ...cookieAttributes(secure), maxAge: 0 });
154
+ if (!carried) return;
155
+ const entries: LocationStateEntry[] = [];
156
+ for (const key of Object.keys(carried.s)) {
157
+ if (!isLocationStateKey(key)) continue;
158
+ entries.push({ __rsc_ls_key: key, __rsc_ls_value: carried.s[key] });
159
+ }
160
+ if (entries.length === 0) return;
161
+ reqCtx._locationState = entries;
162
+ }
163
+
164
+ /** JSON keeps it as it is: what the cookie can carry without change. */
165
+ function isJsonPlain(value: unknown): boolean {
166
+ if (value === null) return true;
167
+ switch (typeof value) {
168
+ case "string":
169
+ case "boolean":
170
+ return true;
171
+ case "number":
172
+ return Number.isFinite(value);
173
+ case "object": {
174
+ if (Array.isArray(value)) return value.every(isJsonPlain);
175
+ const proto = Object.getPrototypeOf(value);
176
+ if (proto !== Object.prototype && proto !== null) return false;
177
+ return Object.values(value as Record<string, unknown>).every(
178
+ (member) => member !== undefined && isJsonPlain(member),
179
+ );
180
+ }
181
+ default:
182
+ return false;
183
+ }
184
+ }
185
+
186
+ function slotNames(state: Record<string, unknown>): string {
187
+ return Object.keys(state).join(", ");
188
+ }
189
+
190
+ /**
191
+ * Write the cookie on a document request's redirect that set location state,
192
+ * and mark the redirect private. Not for a partial or action request (their
193
+ * redirect is a Flight payload), an external or cross-origin target (the
194
+ * cookie would never reach it) or a response that is not a 3xx with a
195
+ * Location. Returns the response to send: a new one when its headers are
196
+ * immutable.
197
+ */
198
+ export function carryLocationStateAcrossRedirect(
199
+ response: Response,
200
+ reqCtx: Pick<RequestContext, "_locationState">,
201
+ request: Request,
202
+ url: URL,
203
+ stateCookieName: string,
204
+ ): Response {
205
+ const entries = reqCtx._locationState;
206
+ if (!entries || entries.length === 0) return response;
207
+ if (!isDocumentRequest(request, url)) return response;
208
+ if (response.status < 300 || response.status >= 400) return response;
209
+ if (isExternalRedirect(response)) return response;
210
+ const location = response.headers.get("Location");
211
+ if (!location) return response;
212
+ let target: URL;
213
+ try {
214
+ target = new URL(location, url);
215
+ } catch {
216
+ return response;
217
+ }
218
+ if (target.origin !== url.origin) return response;
219
+
220
+ let state: Record<string, unknown>;
221
+ try {
222
+ state = resolveLocationStateEntries(entries);
223
+ } catch (error) {
224
+ // A lazy entry's getter threw: the redirect still leaves, without state.
225
+ console.warn(
226
+ "[rango] Location state was dropped from a document redirect: " +
227
+ `resolving it threw (${String(error)}).`,
228
+ );
229
+ return response;
230
+ }
231
+ if (process.env.NODE_ENV !== "production") {
232
+ for (const [key, value] of Object.entries(state)) {
233
+ if (!isJsonPlain(value)) {
234
+ console.warn(
235
+ `[rango] Location state "${key}" crosses a document redirect as JSON, ` +
236
+ "which does not keep this value as it is (a Date, Map, Set, bigint, " +
237
+ "undefined or class instance). Store plain JSON data in state that a " +
238
+ "document request redirects with.",
239
+ );
240
+ }
241
+ }
242
+ }
243
+
244
+ const secure = url.protocol === "https:";
245
+ const name = documentLocationStateCookieName(stateCookieName, secure);
246
+ let cookie: string;
247
+ try {
248
+ cookie = serializeCookieValue(
249
+ name,
250
+ JSON.stringify({ p: target.pathname, s: state } satisfies CarriedState),
251
+ { ...cookieAttributes(secure), maxAge: DOCUMENT_LOCATION_STATE_MAX_AGE },
252
+ );
253
+ } catch {
254
+ console.warn(
255
+ `[rango] Location state (${slotNames(state)}) was dropped from a document ` +
256
+ "redirect: it cannot be serialized as JSON.",
257
+ );
258
+ return response;
259
+ }
260
+ if (cookie.length > DOCUMENT_LOCATION_STATE_MAX_BYTES) {
261
+ console.warn(
262
+ `[rango] Location state (${slotNames(state)}) was dropped from a document ` +
263
+ `redirect: its cookie is ${cookie.length} bytes, over the ` +
264
+ `${DOCUMENT_LOCATION_STATE_MAX_BYTES}-byte limit. Carry less state, or ` +
265
+ "an id the target page loads the rest by.",
266
+ );
267
+ return response;
268
+ }
269
+
270
+ return withHeaders(response, (headers) => {
271
+ headers.append("Set-Cookie", cookie);
272
+ headers.set("Cache-Control", PRIVATE_NO_STORE);
273
+ });
274
+ }
275
+
276
+ /**
277
+ * Apply `write` to the response's headers, or to a copy of the response when
278
+ * they are immutable (a `Response.redirect()`). A WebSocket upgrade is never
279
+ * rebuilt and never carries location state.
280
+ */
281
+ function withHeaders(
282
+ response: Response,
283
+ write: (headers: Headers) => void,
284
+ ): Response {
285
+ if (isWebSocketUpgradeResponse(response)) return response;
286
+ try {
287
+ write(response.headers);
288
+ return response;
289
+ } catch {
290
+ const copy = new Response(response.body, {
291
+ status: response.status,
292
+ statusText: response.statusText,
293
+ headers: new Headers(response.headers),
294
+ });
295
+ write(copy.headers);
296
+ return copy;
297
+ }
298
+ }
299
+
300
+ /**
301
+ * `Cache-Control: private, no-store` on a document that carries location
302
+ * state, after every middleware: a shared cache must never store or replay
303
+ * one visitor's state. Returns the response to send.
304
+ */
305
+ export function markDocumentResponsePrivate(
306
+ response: Response,
307
+ reqCtx: Pick<RequestContext, "_documentLocationState">,
308
+ ): Response {
309
+ if (!reqCtx._documentLocationState) return response;
310
+ return withHeaders(response, (headers) =>
311
+ headers.set("Cache-Control", PRIVATE_NO_STORE),
312
+ );
313
+ }
@@ -680,12 +680,22 @@ export interface RequestContext<
680
680
  /**
681
681
  * Attach location state entries to the current response.
682
682
  *
683
- * For partial (SPA) requests, the state is included in the RSC payload
684
- * metadata and merged into history.pushState on the client. For redirect
685
- * responses, the state travels through the redirect payload so the target
686
- * page can read it via useLocationState.
683
+ * For a client navigation or an action, the state is included in the RSC
684
+ * payload metadata and merged into the entry's history.state on the
685
+ * client. For a document request (a page load, a no-JS form post), it rides
686
+ * the document's bootstrap payload and is merged into the entry when the
687
+ * client starts; readers get it once the page has hydrated. For a redirect,
688
+ * the state travels with the redirect: in its Flight payload on a
689
+ * navigation or action, in a one-shot cookie the target's document request
690
+ * reads on a document request. A document that carries state is sent
691
+ * `Cache-Control: private, no-store` and never stored by the document cache
692
+ * or as a PPR shell.
687
693
  *
688
- * Multiple calls accumulate entries.
694
+ * Multiple calls accumulate entries across definitions. For one definition
695
+ * the last write wins: two entries of the same definition share its slot
696
+ * in history.state, so a later call (or a later entry in one array)
697
+ * replaces an earlier one. To combine writes, make the state an array and
698
+ * write it once.
689
699
  *
690
700
  * @example
691
701
  * ```typescript
@@ -697,6 +707,14 @@ export interface RequestContext<
697
707
  /** @internal Accumulated location state entries */
698
708
  _locationState?: LocationStateEntry[];
699
709
 
710
+ /**
711
+ * @internal Set when a document's payload carries location state
712
+ * (rsc/rsc-rendering.ts, rsc/progressive-enhancement.ts), a shell HIT's
713
+ * included. The document is per visitor: the document cache refuses it,
714
+ * and it leaves `private, no-store` (server/document-location-state.ts).
715
+ */
716
+ _documentLocationState?: boolean;
717
+
700
718
  /**
701
719
  * The matched route name, if the route has an explicit name.
702
720
  * Undefined before route matching or for unnamed routes.
@@ -1006,6 +1024,7 @@ export type PublicRequestContext<
1006
1024
  | "_requestKind"
1007
1025
  | "_payloadVisitorLocation"
1008
1026
  | "_locationState"
1027
+ | "_documentLocationState"
1009
1028
  | "_routeName"
1010
1029
  | "_prevRouteKey"
1011
1030
  | "_visitorMoved"
@@ -177,6 +177,8 @@ interface DispatchableRouter<TEnv> {
177
177
  onTimeout?: OnTimeoutCallback<TEnv>;
178
178
  findMatch: ClassifyRequestDeps<TEnv>["findMatch"];
179
179
  basename?: string;
180
+ /** The rango state cookie name (RangoInternal field). */
181
+ resolvedStateCookieName: string;
180
182
  cache?:
181
183
  | {
182
184
  enabled?: boolean;
@@ -292,6 +294,7 @@ export async function dispatch<TEnv = any>(
292
294
  telemetry: router.telemetry,
293
295
  timeouts: router.timeouts,
294
296
  onTimeout: router.onTimeout,
297
+ resolvedStateCookieName: router.resolvedStateCookieName,
295
298
  },
296
299
  callOnError: stages.createErrorReporter<TEnv>(router),
297
300
  // A response-route handler's own throw is serialized into the same typed
@@ -116,6 +116,7 @@ import {
116
116
  } from "../browser/react/location-state-shared.js";
117
117
  import {
118
118
  buildHistoryState,
119
+ applyDocumentLocationState,
119
120
  clearLocationStateOnDocumentLoad,
120
121
  pushHistoryWithIdx,
121
122
  resolveNavigationState,
@@ -274,6 +275,25 @@ export interface RenderRouteOptions {
274
275
  locationState?: ReadonlyArray<
275
276
  readonly [LocationStateDefinition<any, any>, unknown]
276
277
  >;
278
+ /**
279
+ * Location state the document response carries, as `[def, value]` pairs:
280
+ * what a handler, middleware or no-JS form action set with
281
+ * `ctx.setLocationState()` (or a document redirect carried) for this page
282
+ * load. Merged into the entry at start-up as initBrowserApp merges the
283
+ * bootstrap payload's: after `locationState` and, with `hydrate: true`,
284
+ * after the `clearOnReload` pass, so a `clearOnReload` slot given here is
285
+ * applied. With `hydrate: true` a reader hydrates as `undefined` and gets
286
+ * the value once the root has hydrated.
287
+ *
288
+ * @example
289
+ * await renderRoute([{ path: "/", Component: FlashBanner }], {
290
+ * hydrate: true,
291
+ * documentLocationState: [[FlashMessage, { text: "Payment declined" }]],
292
+ * });
293
+ */
294
+ documentLocationState?: ReadonlyArray<
295
+ readonly [LocationStateDefinition<any, any>, unknown]
296
+ >;
277
297
  /**
278
298
  * Handles to seed by REFERENCE, for components that read handle output via
279
299
  * `useHandle(SomeHandle)` (e.g. a client `Breadcrumbs` trail). Each entry is
@@ -510,6 +530,19 @@ export interface TestRouterHandle {
510
530
  /** Result of renderRoute: RTL's render result plus the router handle. */
511
531
  export type RenderRouteResult = RenderResult & { router: TestRouterHandle };
512
532
 
533
+ /** `[def, value]` seeds as `history.state` slots, each def keyed. */
534
+ function locationStateSlots(
535
+ seeds:
536
+ | ReadonlyArray<readonly [LocationStateDefinition<any, any>, unknown]>
537
+ | undefined,
538
+ ): Record<string, unknown> {
539
+ const slots: Record<string, unknown> = {};
540
+ for (const [def, value] of seeds ?? []) {
541
+ slots[withLocationStateKey(def).__rsc_ls_key] = value;
542
+ }
543
+ return slots;
544
+ }
545
+
513
546
  /** Result of renderRoute with `hydrate: true`. */
514
547
  export type RenderRouteHydrateResult = RenderRouteResult & {
515
548
  /** The HTML the server pass produced, as placed in the container. */
@@ -743,14 +776,15 @@ export async function renderRoute(
743
776
  // The tree has no app version (its bridge reports none), so a seed needs
744
777
  // none either. Reset what an initBrowserApp in the same file may have left.
745
778
  setLocationStateVersion(undefined);
746
- const stateObj: Record<string, unknown> = {};
747
- for (const [def, value] of options.locationState ?? []) {
748
- stateObj[withLocationStateKey(def).__rsc_ls_key] = value;
749
- }
750
- window.history.replaceState(stateObj, "");
779
+ window.history.replaceState(locationStateSlots(options.locationState), "");
751
780
  // Hydrate mode is a document load of the seeded entry: run start-up's
752
- // step (initBrowserApp), not a copy of it.
781
+ // steps (initBrowserApp), not a copy of them.
753
782
  if (options.hydrate) clearLocationStateOnDocumentLoad();
783
+ if (options.documentLocationState) {
784
+ applyDocumentLocationState(
785
+ locationStateSlots(options.documentLocationState),
786
+ );
787
+ }
754
788
  }
755
789
 
756
790
  const resolve = (pathname: string): ResolvedMatch => {
@@ -121,7 +121,11 @@ export async function runRevalidate<TEnv = any>(
121
121
  belongsToRoute: opts.belongsToRoute ?? true,
122
122
  };
123
123
  const fns = Array.isArray(fn) ? fn : [fn];
124
- return runWithRequestContext(ctx as never, () =>
124
+ // evaluateRevalidation marks the context it decides on (_sourceHeld); a
125
+ // caller's own context leaves the run as it came in.
126
+ const own = opts.context as Record<string, unknown> | undefined;
127
+ const ownHadMark = own !== undefined && "_sourceHeld" in own;
128
+ const decision = runWithRequestContext(ctx as never, () =>
125
129
  evaluateRevalidation({
126
130
  segment,
127
131
  prevParams: currentParams,
@@ -146,4 +150,10 @@ export async function runRevalidate<TEnv = any>(
146
150
  stale: opts.stale ?? false,
147
151
  }),
148
152
  );
153
+ if (own === undefined || ownHadMark) return decision;
154
+ try {
155
+ return await decision;
156
+ } finally {
157
+ delete own._sourceHeld;
158
+ }
149
159
  }
@@ -269,6 +269,15 @@ export interface ServeShellRequestResult {
269
269
  * hydrates without it.
270
270
  */
271
271
  readPayloadFormState(): Promise<unknown>;
272
+ /**
273
+ * The payload's location state as the browser reads it: the slots
274
+ * (`Def.__rsc_ls_key` to value) a document's start-up merges into its
275
+ * entry (or a navigation's commit, for a `partial` request). Undefined when
276
+ * the payload carries none (or no Flight was rendered). A redirect carries
277
+ * a document request's state in a `Set-Cookie` instead: pass that cookie
278
+ * on the request of its target to read what the target's payload carries.
279
+ */
280
+ readLocationState(): Promise<Record<string, unknown> | undefined>;
272
281
  /**
273
282
  * For an `action` request, the call's answer as the browser reads it: its
274
283
  * result and the `revalidationError` its loaders' reruns report.
@@ -806,6 +815,13 @@ export async function serveShellRequest<TEnv = any>(
806
815
  }>(flight);
807
816
  return payload.formState;
808
817
  },
818
+ async readLocationState() {
819
+ if (!flight) return undefined;
820
+ const payload = await decodePayload<{
821
+ metadata?: { locationState?: Record<string, unknown> };
822
+ }>(flight);
823
+ return payload.metadata?.locationState;
824
+ },
809
825
  async readAction() {
810
826
  return flight ? decodeAction(flight) : undefined;
811
827
  },
@@ -381,9 +381,16 @@ export type HandlerContext<
381
381
  */
382
382
  setTheme?: (theme: Theme) => void;
383
383
  /**
384
- * Attach location state entries to this response.
385
- * State is delivered to the client via history.pushState and accessible
386
- * through the useLocationState() hook.
384
+ * Attach location state entries to this response, for the useLocationState()
385
+ * hook to read on the entry this response renders. A client navigation
386
+ * delivers it with its payload; a document load (a link from outside the
387
+ * app, a no-JS form post) with its bootstrap payload, once the page has
388
+ * hydrated; a redirect carries it to its target (in a one-shot cookie on a
389
+ * document request). A document carrying it is sent
390
+ * `Cache-Control: private, no-store`.
391
+ *
392
+ * Entries accumulate across definitions; for one definition the last write
393
+ * wins (an earlier entry of it is replaced, not merged).
387
394
  *
388
395
  * @example
389
396
  * ```typescript
@@ -510,6 +517,14 @@ export type InternalHandlerContext<
510
517
  * request is a fill. See router/segment-resolution/prefetch-deferral.ts.
511
518
  */
512
519
  _prefetchDeferral?: import("../router/segment-resolution/prefetch-deferral.js").PrefetchDeferral;
520
+ /**
521
+ * @internal A revalidate() predicate that read the source page
522
+ * (`currentParams`, `currentUrl`, `fromRouteName`) kept a held segment, so
523
+ * this match's diff is right for that source only (#1331). Set by
524
+ * evaluateRevalidation (router/revalidation.ts); match-result.ts turns it
525
+ * into `sourceHeld`, which scopes a prefetch of the response to its source.
526
+ */
527
+ _sourceHeld?: true;
513
528
  /**
514
529
  * @internal DSL loaders registered and not started, by `$$id`: a prefetch
515
530
  * deferred them, or a pass that skips loaders (`skipLoaders`) did not run
@@ -299,6 +299,12 @@ export interface MatchResult {
299
299
  * source, so the RSC handler scopes its prefetch entry to it (#1007).
300
300
  */
301
301
  interceptTargeted?: true;
302
+ /**
303
+ * Set on a partial match when a revalidate() predicate read the source page
304
+ * and kept a held segment: the diff is that source's. The RSC handler
305
+ * scopes its prefetch entry to it, like `interceptTargeted` (#1331).
306
+ */
307
+ sourceHeld?: true;
302
308
  /**
303
309
  * Redirect URL for trailing slash normalization.
304
310
  * When set, the RSC handler should return a 308 redirect to this URL
@@ -24,8 +24,12 @@ import type { Plugin, ResolvedConfig, UserConfig } from "vite";
24
24
  * Forwarding re-inits workerd, defeating the Node temp
25
25
  * server. Matched specifically so a scoped user resolver
26
26
  * like `@cloudflare/kv-alias` is still forwarded.
27
+ *
28
+ * `rango generate --runtime` drops these before it resolves the user's config,
29
+ * so rango()'s configResolved (which writes the static gen files) and the
30
+ * Cloudflare plugin's config hooks never run there.
27
31
  */
28
- function isDenied(name: string): boolean {
32
+ export function isFrameworkPlugin(name: string): boolean {
29
33
  return (
30
34
  name.startsWith("vite:") ||
31
35
  name === "rsc" ||
@@ -78,6 +82,9 @@ function isForwardable(p: Plugin): boolean {
78
82
  * matcher, held in closure) is already populated. Forwarding only the
79
83
  * resolution hooks therefore preserves correct resolution while avoiding a
80
84
  * second `buildStart`/`configureServer`/`config` lifecycle in the temp server.
85
+ * `rango generate --runtime` has no main server: it resolves the user config
86
+ * itself, without the framework plugins (see isFrameworkPlugin), so there a
87
+ * forwarded plugin's config hooks see no `rsc` environment.
81
88
  *
82
89
  * `enforce` and `applyToEnvironment` are preserved so ordering and per-environment
83
90
  * gating match the real pipeline.
@@ -85,7 +92,8 @@ function isForwardable(p: Plugin): boolean {
85
92
  * `apply` is intentionally dropped. Vite filters plugins by `apply` against the
86
93
  * command during config resolution, so the `config.plugins` we read here is
87
94
  * already command-filtered by the main server (build: `apply: "build"` +
88
- * unconditional; dev: `apply: "serve"` + unconditional). The discovery temp
95
+ * unconditional; dev: `apply: "serve"` + unconditional; `rango generate
96
+ * --runtime` resolves as serve). The discovery temp
89
97
  * server is always created with `createServer` (`command === "serve"`), so a
90
98
  * forwarded `apply: "build"` plugin would be filtered straight back out -- even
91
99
  * during a production build, where build-only resolvers are exactly what
@@ -117,7 +125,7 @@ export function selectForwardableResolvePlugins(
117
125
  const forwarded: Plugin[] = [];
118
126
  for (const p of plugins) {
119
127
  const name = p?.name;
120
- if (!name || isDenied(name)) continue;
128
+ if (!name || isFrameworkPlugin(name)) continue;
121
129
  if (!isForwardable(p)) continue;
122
130
  forwarded.push(stripToResolutionHooks(p));
123
131
  }
@@ -139,8 +147,8 @@ export interface ForwardedRunnerConfig {
139
147
  /**
140
148
  * Extract the data-only config slice to mirror into the discovery temp server.
141
149
  * `alias` is included here so callers no longer need to thread it separately.
142
- * Accepts the unresolved config too: `rango generate --runtime` must not
143
- * resolve it (that runs rango()'s configResolved, which writes gen files).
150
+ * Accepts the unresolved config too: `rango generate --runtime` passes the
151
+ * config file's own values, without what plugin `config` hooks add.
144
152
  *
145
153
  * `tsconfigPaths` is forwarded so Vite 8's native tsconfig `paths` resolution
146
154
  * (a top-level `resolve` flag, off by default) reaches the temp server. The