@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.
- package/dist/bin/rango.js +37 -7
- package/dist/types/browser/history-state.d.ts +13 -2
- package/dist/types/browser/react/Link.d.ts +10 -13
- package/dist/types/browser/react/location-state-shared.d.ts +2 -1
- package/dist/types/cache/document-cache-policy.d.ts +1 -1
- package/dist/types/route-definition/redirect.d.ts +5 -0
- package/dist/types/router/middleware-types.d.ts +6 -0
- package/dist/types/rsc/helpers.d.ts +5 -3
- package/dist/types/rsc/request-stages.d.ts +2 -0
- package/dist/types/server/document-location-state.d.ts +56 -0
- package/dist/types/server/request-context.d.ts +23 -6
- package/dist/types/testing/render-route.d.ts +17 -0
- package/dist/types/testing/serve-shell-request.d.ts +9 -0
- package/dist/types/types/handler-context.d.ts +18 -3
- package/dist/types/types/segments.d.ts +6 -0
- package/dist/types/vite/utils/forward-user-plugins.d.ts +32 -2
- package/dist/vite/index.js +3 -3
- package/package.json +1 -1
- package/skills/comparison/references/framework-comparison.md +3 -2
- package/skills/document-cache/SKILL.md +5 -0
- package/skills/hooks/state.md +110 -6
- package/skills/intercept/SKILL.md +2 -1
- package/skills/ppr/SKILL.md +12 -0
- package/skills/server-actions/SKILL.md +8 -0
- package/skills/testing/client-components.md +44 -19
- package/skills/testing/serve-shell-request.md +38 -13
- package/skills/ux-architecture/SKILL.md +6 -3
- package/src/browser/history-state.ts +24 -2
- package/src/browser/react/Link.tsx +10 -13
- package/src/browser/react/location-state-shared.ts +8 -1
- package/src/browser/rsc-router.tsx +5 -2
- package/src/build/runtime-discovery.ts +46 -8
- package/src/cache/document-cache-policy.ts +10 -1
- package/src/cache/document-cache.ts +7 -0
- package/src/route-definition/redirect.ts +5 -0
- package/src/router/match-middleware/cache-lookup.ts +23 -6
- package/src/router/match-result.ts +9 -1
- package/src/router/middleware-types.ts +6 -0
- package/src/router/revalidation.ts +23 -3
- package/src/rsc/helpers.ts +13 -4
- package/src/rsc/progressive-enhancement.ts +5 -0
- package/src/rsc/request-stages.ts +28 -1
- package/src/rsc/rsc-rendering.ts +39 -9
- package/src/server/document-location-state.ts +313 -0
- package/src/server/request-context.ts +24 -5
- package/src/testing/dispatch.ts +3 -0
- package/src/testing/render-route.tsx +40 -6
- package/src/testing/run-revalidate.ts +11 -1
- package/src/testing/serve-shell-request.ts +16 -0
- package/src/types/handler-context.ts +18 -3
- package/src/types/segments.ts +6 -0
- 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
|
|
684
|
-
* metadata and merged into history.
|
|
685
|
-
*
|
|
686
|
-
*
|
|
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"
|
package/src/testing/dispatch.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
*
|
|
386
|
-
*
|
|
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
|
package/src/types/segments.ts
CHANGED
|
@@ -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
|
|
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
|
|
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 ||
|
|
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`
|
|
143
|
-
*
|
|
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
|