@rangojs/router 0.30.1 → 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/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/dist/bin/rango.js +7891 -197
- 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/build/generate-route-types.d.ts +1 -1
- package/dist/types/build/route-types/include-resolution.d.ts +1 -1
- package/dist/types/build/route-types/router-processing.d.ts +3 -7
- 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/temp-rsc-server.d.ts +8 -4
- package/dist/types/vite/utils/forward-user-plugins.d.ts +33 -1
- package/dist/vite/index.js +121 -38
- package/package.json +2 -2
- 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/rango/SKILL.md +5 -5
- 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/typesafety/generated-files-and-cli.md +2 -1
- package/skills/ux-architecture/SKILL.md +6 -3
- package/src/bin/rango.ts +13 -8
- 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/generate-route-types.ts +0 -1
- package/src/build/route-types/include-resolution.ts +2 -1
- package/src/build/route-types/router-processing.ts +213 -49
- package/src/build/runtime-discovery.ts +128 -41
- package/src/cache/document-cache-policy.ts +10 -1
- package/src/cache/document-cache.ts +7 -0
- package/src/client-urls/server-projection.ts +3 -1
- 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/temp-rsc-server.ts +9 -4
- package/src/vite/utils/forward-user-plugins.ts +15 -5
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { resolve } from "node:path";
|
|
1
|
+
import { basename, resolve } from "node:path";
|
|
2
2
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import type { Plugin, PluginOption, ViteDevServer } from "vite";
|
|
3
4
|
import {
|
|
4
5
|
generateRouteTypesSource,
|
|
5
6
|
genFileTsPath,
|
|
@@ -35,14 +36,13 @@ export async function discoverAndWriteRouteTypes(
|
|
|
35
36
|
routeCount: number;
|
|
36
37
|
outputFiles: string[];
|
|
37
38
|
}> {
|
|
38
|
-
let createViteServer: typeof import("vite").createServer;
|
|
39
39
|
let loadConfigFromFile: typeof import("vite").loadConfigFromFile;
|
|
40
|
-
let
|
|
40
|
+
let resolveConfig: typeof import("vite").resolveConfig;
|
|
41
41
|
|
|
42
42
|
try {
|
|
43
43
|
const vite = await import("vite");
|
|
44
|
-
createViteServer = vite.createServer;
|
|
45
44
|
loadConfigFromFile = vite.loadConfigFromFile;
|
|
45
|
+
resolveConfig = vite.resolveConfig;
|
|
46
46
|
} catch {
|
|
47
47
|
throw new Error(
|
|
48
48
|
"Runtime discovery requires 'vite'. Install it with: pnpm add -D vite",
|
|
@@ -50,61 +50,93 @@ export async function discoverAndWriteRouteTypes(
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
try {
|
|
53
|
-
|
|
54
|
-
rsc = rscMod.default;
|
|
53
|
+
await import("@vitejs/plugin-rsc");
|
|
55
54
|
} catch {
|
|
56
55
|
throw new Error(
|
|
57
56
|
"Runtime discovery requires '@vitejs/plugin-rsc'. Install it with: pnpm add -D @vitejs/plugin-rsc",
|
|
58
57
|
);
|
|
59
58
|
}
|
|
60
59
|
|
|
61
|
-
const {
|
|
62
|
-
|
|
63
|
-
const {
|
|
64
|
-
await import("../vite/
|
|
60
|
+
const { createTempRscServer } = await import("../vite/temp-rsc-server.js");
|
|
61
|
+
const { createDiscoveryState } = await import("../vite/discovery/state.js");
|
|
62
|
+
const { discoverClientUrlProjections } =
|
|
63
|
+
await import("../vite/discovery/client-urls-projection.js");
|
|
64
|
+
const {
|
|
65
|
+
isFrameworkPlugin,
|
|
66
|
+
pickForwardedRunnerConfig,
|
|
67
|
+
selectForwardableResolvePlugins,
|
|
68
|
+
} = await import("../vite/utils/forward-user-plugins.js");
|
|
65
69
|
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
+
// The discovery temp server of `vite dev/build` (expose-internal-ids, the
|
|
71
|
+
// cloudflare:* stubs, the user's resolver plugins and resolve/define/oxc).
|
|
72
|
+
// Resolver plugins are forwarded from a resolved config, as `vite dev` does:
|
|
73
|
+
// vite-tsconfig-paths and the like build their matchers in config /
|
|
74
|
+
// configResolved, and resolving drops `apply: "build"` plugins. The
|
|
75
|
+
// framework plugins are left out of that resolve: rango()'s configResolved
|
|
76
|
+
// writes the static gen files.
|
|
77
|
+
const entryPath = resolve(opts.entry);
|
|
78
|
+
const state = createDiscoveryState(entryPath, undefined);
|
|
79
|
+
state.projectRoot = opts.root;
|
|
80
|
+
state.userResolveAlias = opts.resolveAlias;
|
|
81
|
+
if (!opts.resolveAlias) {
|
|
70
82
|
try {
|
|
71
83
|
const loaded = await loadConfigFromFile(
|
|
72
84
|
{ command: "serve", mode: "development" },
|
|
73
|
-
|
|
85
|
+
opts.configFile,
|
|
74
86
|
opts.root,
|
|
75
87
|
);
|
|
76
|
-
if (loaded
|
|
77
|
-
|
|
88
|
+
if (loaded) {
|
|
89
|
+
state.userRunnerConfig = pickForwardedRunnerConfig(loaded.config);
|
|
90
|
+
const userPlugins = (
|
|
91
|
+
await flattenPluginOptions(loaded.config.plugins)
|
|
92
|
+
).filter((p) => !isFrameworkPlugin(p.name ?? ""));
|
|
93
|
+
try {
|
|
94
|
+
// The temp server's root, so a resolver's root-relative state
|
|
95
|
+
// matches the server it runs in.
|
|
96
|
+
const resolved = await resolveConfig(
|
|
97
|
+
{
|
|
98
|
+
...loaded.config,
|
|
99
|
+
root: opts.root,
|
|
100
|
+
configFile: false,
|
|
101
|
+
plugins: userPlugins,
|
|
102
|
+
},
|
|
103
|
+
"serve",
|
|
104
|
+
"development",
|
|
105
|
+
);
|
|
106
|
+
state.userResolvePlugins = selectForwardableResolvePlugins(
|
|
107
|
+
resolved.plugins,
|
|
108
|
+
);
|
|
109
|
+
} catch (err: any) {
|
|
110
|
+
console.warn(
|
|
111
|
+
`[rango] Could not resolve the Vite config (${err?.message ?? err}); forwarding its resolver plugins without their config hooks. rango() and rsc() are left out of that resolve, so a plugin that needs the rsc environment fails there`,
|
|
112
|
+
);
|
|
113
|
+
// The resolve would have applied `apply`; do the same for the serve
|
|
114
|
+
// pass, including the function form.
|
|
115
|
+
const serveEnv = { command: "serve", mode: "development" } as const;
|
|
116
|
+
state.userResolvePlugins = selectForwardableResolvePlugins(
|
|
117
|
+
userPlugins.filter((p) =>
|
|
118
|
+
typeof p.apply === "function"
|
|
119
|
+
? p.apply(loaded.config, serveEnv)
|
|
120
|
+
: p.apply !== "build",
|
|
121
|
+
),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
78
124
|
}
|
|
79
|
-
} catch {
|
|
80
|
-
//
|
|
125
|
+
} catch (err: any) {
|
|
126
|
+
// Discovery without the app's resolvers fails later on an unrelated
|
|
127
|
+
// import; name the cause here.
|
|
128
|
+
console.warn(
|
|
129
|
+
`[rango] Could not load the Vite config (${err?.message ?? err}); discovering without its resolve config and plugins`,
|
|
130
|
+
);
|
|
81
131
|
}
|
|
82
132
|
}
|
|
83
133
|
|
|
84
|
-
|
|
85
|
-
let tempServer: any = null;
|
|
134
|
+
let tempServer: ViteDevServer | null = null;
|
|
86
135
|
|
|
87
136
|
try {
|
|
88
|
-
tempServer = await
|
|
89
|
-
root: opts.root,
|
|
90
|
-
configFile: false,
|
|
91
|
-
server: { middlewareMode: true },
|
|
92
|
-
appType: "custom",
|
|
93
|
-
logLevel: "silent",
|
|
137
|
+
tempServer = await createTempRscServer(state, {
|
|
94
138
|
cacheDir: "node_modules/.vite_rango_generate",
|
|
95
|
-
|
|
96
|
-
esbuild: { jsx: "automatic", jsxImportSource: "react" },
|
|
97
|
-
plugins: [
|
|
98
|
-
rsc({
|
|
99
|
-
entries: {
|
|
100
|
-
client: "virtual:entry-client",
|
|
101
|
-
ssr: "virtual:entry-ssr",
|
|
102
|
-
rsc: entryPath,
|
|
103
|
-
},
|
|
104
|
-
}),
|
|
105
|
-
createVersionPlugin(),
|
|
106
|
-
createVirtualStubPlugin(),
|
|
107
|
-
],
|
|
139
|
+
plugins: [missingGenFileStubPlugin()],
|
|
108
140
|
});
|
|
109
141
|
|
|
110
142
|
const rscEnv = (tempServer.environments as any)?.rsc;
|
|
@@ -125,6 +157,14 @@ export async function discoverAndWriteRouteTypes(
|
|
|
125
157
|
);
|
|
126
158
|
}
|
|
127
159
|
|
|
160
|
+
// include() of a clientUrls() module resolves from these projections.
|
|
161
|
+
await discoverClientUrlProjections(
|
|
162
|
+
state,
|
|
163
|
+
(tempServer.environments as any)?.ssr,
|
|
164
|
+
serverMod,
|
|
165
|
+
rscEnv,
|
|
166
|
+
);
|
|
167
|
+
|
|
128
168
|
// Import build utilities for manifest generation
|
|
129
169
|
const buildMod = await rscEnv.runner.import("@rangojs/router/build");
|
|
130
170
|
const generateManifestFull = buildMod.generateManifestFull;
|
|
@@ -149,7 +189,11 @@ export async function discoverAndWriteRouteTypes(
|
|
|
149
189
|
const mountManifests = [];
|
|
150
190
|
for (const mount of mounts) {
|
|
151
191
|
mountManifests.push(
|
|
152
|
-
await generateManifestFull(
|
|
192
|
+
await generateManifestFull(
|
|
193
|
+
mount.patterns,
|
|
194
|
+
globalMountIndex,
|
|
195
|
+
router.__basename ? { urlPrefix: router.__basename } : undefined,
|
|
196
|
+
),
|
|
153
197
|
);
|
|
154
198
|
globalMountIndex++;
|
|
155
199
|
}
|
|
@@ -232,3 +276,46 @@ export async function discoverAndWriteRouteTypes(
|
|
|
232
276
|
}
|
|
233
277
|
}
|
|
234
278
|
}
|
|
279
|
+
|
|
280
|
+
const MISSING_GEN_PREFIX = "\0rango-missing-named-routes-gen:";
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* expose-router-id makes every createRouter() file import its sibling
|
|
284
|
+
* `./<name>.named-routes.gen.js`. `vite dev/build` write those from static
|
|
285
|
+
* parsing before discovery; the CLI does not, so a missing one resolves to an
|
|
286
|
+
* empty NamedRoutes here and discovery writes the real file. An existing gen
|
|
287
|
+
* file loads from disk as usual.
|
|
288
|
+
*/
|
|
289
|
+
function missingGenFileStubPlugin(): Plugin {
|
|
290
|
+
return {
|
|
291
|
+
name: "@rangojs/router:missing-gen-file-stub",
|
|
292
|
+
enforce: "pre",
|
|
293
|
+
resolveId(id, importer) {
|
|
294
|
+
if (!importer || !id.endsWith(".named-routes.gen.js")) return null;
|
|
295
|
+
const genPath = genFileTsPath(importer.split("?", 1)[0]);
|
|
296
|
+
if (id !== `./${basename(genPath, ".ts")}.js` || existsSync(genPath)) {
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
return MISSING_GEN_PREFIX + genPath;
|
|
300
|
+
},
|
|
301
|
+
load(id) {
|
|
302
|
+
return id.startsWith(MISSING_GEN_PREFIX)
|
|
303
|
+
? "export const NamedRoutes = {};\n"
|
|
304
|
+
: null;
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Flatten an unresolved `plugins` option (nested arrays, promises, falsy
|
|
311
|
+
* entries) the way Vite's config resolution does, without running any hook.
|
|
312
|
+
*/
|
|
313
|
+
async function flattenPluginOptions(
|
|
314
|
+
option: PluginOption | undefined,
|
|
315
|
+
): Promise<Plugin[]> {
|
|
316
|
+
const value = await option;
|
|
317
|
+
if (Array.isArray(value)) {
|
|
318
|
+
return (await Promise.all(value.map(flattenPluginOptions))).flat();
|
|
319
|
+
}
|
|
320
|
+
return value ? [value as Plugin] : [];
|
|
321
|
+
}
|
|
@@ -69,7 +69,10 @@ export function shouldCacheResponse(
|
|
|
69
69
|
response: Response,
|
|
70
70
|
requestCtx?: Pick<
|
|
71
71
|
RequestContext,
|
|
72
|
-
|
|
72
|
+
| "_payloadVisitorTheme"
|
|
73
|
+
| "_payloadDeferred"
|
|
74
|
+
| "_payloadVisitorLocation"
|
|
75
|
+
| "_documentLocationState"
|
|
73
76
|
>,
|
|
74
77
|
): CacheDirectives | null {
|
|
75
78
|
// Only cache successful responses
|
|
@@ -100,6 +103,12 @@ export function shouldCacheResponse(
|
|
|
100
103
|
return null;
|
|
101
104
|
}
|
|
102
105
|
|
|
106
|
+
// The payload carries the visitor's location state (#1330,
|
|
107
|
+
// server/document-location-state.ts).
|
|
108
|
+
if (requestCtx?._documentLocationState) {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
103
112
|
// prefetch: false. The payload carries deferred units: it answers one
|
|
104
113
|
// page's prefetch and nothing else. parseCacheControl refuses the
|
|
105
114
|
// Cache-Control it is sent with (rsc-rendering.ts NOT_REUSED), but a
|
|
@@ -276,6 +276,13 @@ export function createDocumentCacheMiddleware<TEnv = any>(
|
|
|
276
276
|
return next();
|
|
277
277
|
}
|
|
278
278
|
|
|
279
|
+
// Location state is already set for this request (a document redirect's
|
|
280
|
+
// cookie, an earlier middleware): the response is the visitor's, so a
|
|
281
|
+
// stored body must not answer it, and it is not stored.
|
|
282
|
+
if ((requestCtx._locationState?.length ?? 0) > 0) {
|
|
283
|
+
return next();
|
|
284
|
+
}
|
|
285
|
+
|
|
279
286
|
// The write gate's start (#977) and the entry's stamp (#1068): the
|
|
280
287
|
// request's, not this middleware's, also for a stale refresh. A
|
|
281
288
|
// middleware ahead of this one can read tagged data the document bakes,
|
|
@@ -170,8 +170,10 @@ function serializeSearchSchema(
|
|
|
170
170
|
throw projectionError(route, 'path option "search" must be an object');
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
+
// Source order, not sorted: the build writes the gen file's search keys from
|
|
174
|
+
// this projection and must match `rango generate`'s static parse (#1322).
|
|
173
175
|
const entries: Array<[string, SearchSchemaValue]> = [];
|
|
174
|
-
for (const key of Object.keys(value)
|
|
176
|
+
for (const key of Object.keys(value)) {
|
|
175
177
|
const schemaValue = (value as Record<string, unknown>)[key];
|
|
176
178
|
if (
|
|
177
179
|
typeof schemaValue !== "string" ||
|
|
@@ -20,6 +20,11 @@ import { prefixBasename } from "../router/basename.js";
|
|
|
20
20
|
* Supports an optional state parameter to carry location state through the
|
|
21
21
|
* redirect. On the target page, state can be read via useLocationState()
|
|
22
22
|
* (use { flash: true } in createLocationState for read-once flash messages).
|
|
23
|
+
* On a client navigation or action the state travels in the redirect's
|
|
24
|
+
* payload. On a document request (a page load, a no-JS form post) it travels
|
|
25
|
+
* in a one-shot cookie the target's document request reads and clears, as
|
|
26
|
+
* JSON: give it plain JSON data there. `{ external: true }` carries none.
|
|
27
|
+
* Two entries of the same definition keep only the last.
|
|
23
28
|
*
|
|
24
29
|
* @param url - The URL to redirect to
|
|
25
30
|
* @param statusOrOptions - HTTP status code (default: 302) or options object
|
|
@@ -313,12 +313,15 @@ function cachedSegmentSends<TEnv>(
|
|
|
313
313
|
* Whether a prerender entry's segment is sent. On a partial navigation the
|
|
314
314
|
* client keeps the segments it holds. The route's own segments (an
|
|
315
315
|
* intercept's included) are sent again when the params changed (e.g., a
|
|
316
|
-
* different guide slug)
|
|
317
|
-
*
|
|
316
|
+
* different guide slug), and kept on equal params whatever the search: a
|
|
317
|
+
* prerender does not vary by search. An overlay entry may be newer than the
|
|
318
|
+
* client's copy: on an action or a stale request they are re-sent too, as a live
|
|
318
319
|
* action re-render re-sends them (revalidation.ts "action:route-segment");
|
|
319
320
|
* the immutable bundled manifest keeps the client's copy. A held segment
|
|
320
321
|
* above the route stays held, as a live walk keeps it ("nav:non-route-skip"),
|
|
321
|
-
* unless its revalidate() rules ask for it when the params changed.
|
|
322
|
+
* unless its revalidate() rules ask for it when the params changed. A route
|
|
323
|
+
* segment, or one with rules, kept on equal params marks the match
|
|
324
|
+
* source-held: the decision hangs on the source's params.
|
|
322
325
|
*
|
|
323
326
|
* Decided once per decoded segment, by object: an intercept variant can hold
|
|
324
327
|
* a layout's parallel and the intercept's slot under one id. decodeStoredEntry
|
|
@@ -333,9 +336,17 @@ function storedEntrySends<TEnv>(
|
|
|
333
336
|
const paramsChanged = !paramsEqual(ctx.matched.params, ctx.prevParams);
|
|
334
337
|
const resendRouteSegments = replaceable && (ctx.isAction || ctx.stale);
|
|
335
338
|
const entryRevalidateMap =
|
|
336
|
-
|
|
337
|
-
? router.buildEntryRevalidateMap(ctx.entries)
|
|
339
|
+
ctx.clientSegmentSet.size > 0
|
|
340
|
+
? router.buildEntryRevalidateMap?.(ctx.entries)
|
|
338
341
|
: undefined;
|
|
342
|
+
// A keep that hangs on the source's params is right for this source only:
|
|
343
|
+
// scope a prefetch of the answer to it, as a predicate that read the
|
|
344
|
+
// source would (#1331).
|
|
345
|
+
const heldForSource = (): false => {
|
|
346
|
+
(ctx.handlerContext as InternalHandlerContext<any, TEnv>)._sourceHeld =
|
|
347
|
+
true;
|
|
348
|
+
return false;
|
|
349
|
+
};
|
|
339
350
|
const decide = (segment: ResolvedSegment): boolean | Promise<boolean> => {
|
|
340
351
|
if (!ctx.clientSegmentSet.has(segment.id)) return true;
|
|
341
352
|
// A fill keeps it (fillSends).
|
|
@@ -346,10 +357,16 @@ function storedEntrySends<TEnv>(
|
|
|
346
357
|
);
|
|
347
358
|
if (fill !== undefined) return fill;
|
|
348
359
|
if (segment.type === "route" || segment.belongsToRoute === true) {
|
|
349
|
-
|
|
360
|
+
if (paramsChanged || resendRouteSegments) return true;
|
|
361
|
+
// Kept on equal params, whatever the search: a source with other
|
|
362
|
+
// params is sent it.
|
|
363
|
+
return heldForSource();
|
|
350
364
|
}
|
|
351
365
|
const revalidate = entryRevalidateMap?.get(segment.id)?.revalidate;
|
|
352
366
|
if (!revalidate?.length) return false;
|
|
367
|
+
// Kept on equal params: from another source the rules run and may send
|
|
368
|
+
// it.
|
|
369
|
+
if (!paramsChanged) return heldForSource();
|
|
353
370
|
return router.evaluateRevalidation({
|
|
354
371
|
segment,
|
|
355
372
|
prevParams: ctx.prevParams,
|
|
@@ -107,7 +107,11 @@
|
|
|
107
107
|
* 3. Diff against previous state with diff[]
|
|
108
108
|
* 4. Render slot content if slots present
|
|
109
109
|
*/
|
|
110
|
-
import type {
|
|
110
|
+
import type {
|
|
111
|
+
InternalHandlerContext,
|
|
112
|
+
MatchResult,
|
|
113
|
+
ResolvedSegment,
|
|
114
|
+
} from "../types.js";
|
|
111
115
|
import { isPlaceholder } from "../types/segments.js";
|
|
112
116
|
import { interceptMatched } from "../browser/intercept-utils.js";
|
|
113
117
|
import type { MatchContext, MatchPipelineState } from "./match-context.js";
|
|
@@ -304,6 +308,10 @@ export function buildMatchResult<TEnv>(
|
|
|
304
308
|
slots: Object.keys(state.slots).length > 0 ? state.slots : undefined,
|
|
305
309
|
...(interceptTargets.length > 0 ? { interceptTargets } : {}),
|
|
306
310
|
...(ctx.interceptTargeted ? { interceptTargeted: true } : {}),
|
|
311
|
+
...((ctx.handlerContext as InternalHandlerContext<any, TEnv> | undefined)
|
|
312
|
+
?._sourceHeld
|
|
313
|
+
? { sourceHeld: true }
|
|
314
|
+
: {}),
|
|
307
315
|
routeMiddleware:
|
|
308
316
|
ctx.routeMiddleware.length > 0 ? ctx.routeMiddleware : undefined,
|
|
309
317
|
};
|
|
@@ -78,6 +78,12 @@ export interface MiddlewareContext<
|
|
|
78
78
|
|
|
79
79
|
setTheme?: (theme: Theme) => void;
|
|
80
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Attach location state entries to this response (see the handler
|
|
83
|
+
* context's `setLocationState`). Entries accumulate across definitions;
|
|
84
|
+
* for one definition the last write wins. Set it here rather than in a
|
|
85
|
+
* handler on a `ppr` route: a shell HIT runs no handler.
|
|
86
|
+
*/
|
|
81
87
|
setLocationState(entries: LocationStateEntry | LocationStateEntry[]): void;
|
|
82
88
|
|
|
83
89
|
/** Global route names only; see {@link GlobalReverseFunction}. */
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import type {
|
|
8
8
|
ResolvedSegment,
|
|
9
9
|
HandlerContext,
|
|
10
|
+
InternalHandlerContext,
|
|
10
11
|
ShouldRevalidateFn,
|
|
11
12
|
} from "../types";
|
|
12
13
|
import type { ActionContext } from "./types";
|
|
@@ -154,11 +155,19 @@ export async function evaluateRevalidation<TEnv>(
|
|
|
154
155
|
// second, contradicting entry would let flushRevalidationTrace count the
|
|
155
156
|
// segment as both revalidated and skipped.
|
|
156
157
|
const floorTrue = defaultOverride?.floor === true && defaultOverride.value;
|
|
158
|
+
// A predicate read the page navigated away from. A segment it keeps then
|
|
159
|
+
// stays held for this source only: a prefetch of the answer, adopted on
|
|
160
|
+
// another page, would keep that page's copy (#1331). A re-render is
|
|
161
|
+
// source-safe, it sends the segment.
|
|
162
|
+
let readSource = false;
|
|
157
163
|
function decide(finalVal: boolean, reason: string): boolean {
|
|
158
164
|
if (floorTrue && !finalVal) {
|
|
159
165
|
pushTrace(defaultShouldRevalidate, true, `${reason}:floored`);
|
|
160
166
|
return true;
|
|
161
167
|
}
|
|
168
|
+
if (!finalVal && readSource) {
|
|
169
|
+
(context as InternalHandlerContext<any, TEnv>)._sourceHeld = true;
|
|
170
|
+
}
|
|
162
171
|
pushTrace(defaultShouldRevalidate, finalVal, reason);
|
|
163
172
|
return finalVal;
|
|
164
173
|
}
|
|
@@ -253,12 +262,20 @@ export async function evaluateRevalidation<TEnv>(
|
|
|
253
262
|
|
|
254
263
|
const isActionFn = makeIsAction(actionContext?.actionId);
|
|
255
264
|
|
|
265
|
+
const currentParams = prevSegment?.params || prevParams;
|
|
266
|
+
|
|
256
267
|
for (const { name, fn } of revalidations) {
|
|
257
268
|
let result: any;
|
|
258
269
|
try {
|
|
259
270
|
result = fn({
|
|
260
|
-
currentParams
|
|
261
|
-
|
|
271
|
+
get currentParams() {
|
|
272
|
+
readSource = true;
|
|
273
|
+
return currentParams;
|
|
274
|
+
},
|
|
275
|
+
get currentUrl() {
|
|
276
|
+
readSource = true;
|
|
277
|
+
return prevUrl;
|
|
278
|
+
},
|
|
262
279
|
nextParams,
|
|
263
280
|
nextUrl,
|
|
264
281
|
defaultShouldRevalidate: currentSuggestion,
|
|
@@ -275,7 +292,10 @@ export async function evaluateRevalidation<TEnv>(
|
|
|
275
292
|
formData: actionContext?.formData,
|
|
276
293
|
method: request.method,
|
|
277
294
|
routeName: toRouteName,
|
|
278
|
-
fromRouteName
|
|
295
|
+
get fromRouteName() {
|
|
296
|
+
readSource = true;
|
|
297
|
+
return fromRouteName;
|
|
298
|
+
},
|
|
279
299
|
toRouteName,
|
|
280
300
|
stale: cacheStale ? true : stale,
|
|
281
301
|
});
|
package/src/rsc/helpers.ts
CHANGED
|
@@ -340,14 +340,23 @@ export function interceptRedirectForPartial(
|
|
|
340
340
|
/**
|
|
341
341
|
* Attach location state set during a request to a payload's metadata.
|
|
342
342
|
* No-op if no location state was set. Callers must ensure payload.metadata
|
|
343
|
-
* is populated (
|
|
344
|
-
* that
|
|
343
|
+
* is populated. A `document` payload (a page load's or a no-JS form post's
|
|
344
|
+
* bootstrap payload) that carries state marks the request
|
|
345
|
+
* `_documentLocationState`: it is the visitor's, so no shared cache keeps it
|
|
346
|
+
* (server/document-location-state.ts).
|
|
345
347
|
*/
|
|
346
|
-
export function attachLocationStateIfPresent(
|
|
348
|
+
export function attachLocationStateIfPresent(
|
|
349
|
+
payload: RscPayload,
|
|
350
|
+
document = false,
|
|
351
|
+
): void {
|
|
347
352
|
const locationState = getLocationState();
|
|
348
|
-
if (locationState) {
|
|
353
|
+
if (locationState && locationState.length > 0) {
|
|
349
354
|
payload.metadata!.locationState =
|
|
350
355
|
resolveLocationStateEntries(locationState);
|
|
356
|
+
if (document) {
|
|
357
|
+
const ctx = _getRequestContext();
|
|
358
|
+
if (ctx) ctx._documentLocationState = true;
|
|
359
|
+
}
|
|
351
360
|
}
|
|
352
361
|
}
|
|
353
362
|
|
|
@@ -25,6 +25,7 @@ import type { RscPayload, ReactFormState } from "./types.js";
|
|
|
25
25
|
import type { MatchResult } from "../types.js";
|
|
26
26
|
import {
|
|
27
27
|
actionRenderRequest,
|
|
28
|
+
attachLocationStateIfPresent,
|
|
28
29
|
createResponseWithMergedHeaders,
|
|
29
30
|
finalizeResponse,
|
|
30
31
|
buildRouteMiddlewareEntries,
|
|
@@ -448,6 +449,10 @@ function renderPeResponse<TEnv>(
|
|
|
448
449
|
// slot instead of failing the payload root and the whole document.
|
|
449
450
|
...(reactFormState ? { formState: Promise.resolve(reactFormState) } : {}),
|
|
450
451
|
};
|
|
452
|
+
// The action's location state rides the re-rendered document's bootstrap
|
|
453
|
+
// payload, as a JS action's rides its response. Not on a failure render,
|
|
454
|
+
// like the JS path.
|
|
455
|
+
if (!failure) attachLocationStateIfPresent(payload, true);
|
|
451
456
|
|
|
452
457
|
const trace = getRequestContext()._activeRoutine;
|
|
453
458
|
|
|
@@ -84,6 +84,11 @@ import {
|
|
|
84
84
|
import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
|
|
85
85
|
import type { CacheErrorCategory } from "../cache/cache-error.js";
|
|
86
86
|
import { WIRE } from "../wire-params.js";
|
|
87
|
+
import {
|
|
88
|
+
carryLocationStateAcrossRedirect,
|
|
89
|
+
consumeDocumentLocationState,
|
|
90
|
+
markDocumentResponsePrivate,
|
|
91
|
+
} from "../server/document-location-state.js";
|
|
87
92
|
|
|
88
93
|
/**
|
|
89
94
|
* The router members the stages read. The handler's `RangoInternal` carries
|
|
@@ -96,6 +101,8 @@ export interface StageRouter<TEnv = unknown> {
|
|
|
96
101
|
readonly telemetry?: TelemetrySink;
|
|
97
102
|
readonly timeouts?: ResolvedTimeouts;
|
|
98
103
|
readonly onTimeout?: OnTimeoutCallback<TEnv>;
|
|
104
|
+
/** Names the cookie location state crosses a document redirect in. */
|
|
105
|
+
readonly resolvedStateCookieName: string;
|
|
99
106
|
}
|
|
100
107
|
|
|
101
108
|
/** Report an error through the router's onError. */
|
|
@@ -409,6 +416,14 @@ export async function runRequestPasses<TEnv>(
|
|
|
409
416
|
opts: RequestPassesOptions<TEnv>,
|
|
410
417
|
): Promise<RequestPassesResult> {
|
|
411
418
|
const { ctx, env, variables } = opts;
|
|
419
|
+
// Before any middleware: a document cache lookup, a PPR shell serve and the
|
|
420
|
+
// render all see the state a document redirect carried here.
|
|
421
|
+
consumeDocumentLocationState(
|
|
422
|
+
opts.reqCtx,
|
|
423
|
+
opts.request,
|
|
424
|
+
opts.url,
|
|
425
|
+
ctx.router.resolvedStateCookieName,
|
|
426
|
+
);
|
|
412
427
|
let passIndex = 0;
|
|
413
428
|
let hasMiddleware = false;
|
|
414
429
|
const runChain = (
|
|
@@ -707,7 +722,19 @@ export function finalizeRequestResponse<TEnv>(
|
|
|
707
722
|
// guarding browser-followed (3xx) redirects once covers them all and any
|
|
708
723
|
// future redirect exit. Soft SPA/Flight redirects are 200/204 and pass
|
|
709
724
|
// through untouched (validated client-side instead).
|
|
710
|
-
|
|
725
|
+
// After the guard, so the cookie names the target the browser follows.
|
|
726
|
+
// A document carrying location state, or a redirect carrying it on, leaves
|
|
727
|
+
// private: set after every middleware, so none can make it shared.
|
|
728
|
+
const guarded = markDocumentResponsePrivate(
|
|
729
|
+
carryLocationStateAcrossRedirect(
|
|
730
|
+
guardOutgoingRedirect(response, url.origin, router.basename),
|
|
731
|
+
reqCtx,
|
|
732
|
+
request,
|
|
733
|
+
url,
|
|
734
|
+
router.resolvedStateCookieName,
|
|
735
|
+
),
|
|
736
|
+
reqCtx,
|
|
737
|
+
);
|
|
711
738
|
|
|
712
739
|
// Stream-idle watchdog (opt-in via timeouts.streamIdleMs): bounds
|
|
713
740
|
// end-to-end idle flow on the streamed body — see rsc/stream-idle.ts
|
package/src/rsc/rsc-rendering.ts
CHANGED
|
@@ -430,6 +430,12 @@ function* requestRenderPlan<TEnv>(
|
|
|
430
430
|
// chain (executeRender wraps it), so no shell byte can precede a guard
|
|
431
431
|
// decision — that ordering is what makes a shared shell safe. Routes
|
|
432
432
|
// without the `ppr` option stay pure axis 1 at zero cost.
|
|
433
|
+
//
|
|
434
|
+
// Location state set by now (middleware, a document redirect's cookie) does
|
|
435
|
+
// not keep a request off the shell: a HIT attaches it to the live tail's
|
|
436
|
+
// payload, and a capture builds its own payload without it, so no shell
|
|
437
|
+
// ever holds a visitor's state (server/document-location-state.ts).
|
|
438
|
+
const stateBeforeRender = reqCtx._locationState?.length ?? 0;
|
|
433
439
|
const shell = yield* scope("shell-serve", shellServePlan(input));
|
|
434
440
|
if (shell.kind === "serve") return shell.response;
|
|
435
441
|
|
|
@@ -441,11 +447,12 @@ function* requestRenderPlan<TEnv>(
|
|
|
441
447
|
);
|
|
442
448
|
if (prepared.kind === "control") return prepared.response;
|
|
443
449
|
|
|
444
|
-
//
|
|
445
|
-
//
|
|
446
|
-
//
|
|
447
|
-
|
|
448
|
-
|
|
450
|
+
// Server-set location state rides the payload: a navigation's, merged at
|
|
451
|
+
// its commit, or a document's bootstrap payload, merged when the client
|
|
452
|
+
// starts (browser/rsc-router.tsx). A document carrying it is per visitor
|
|
453
|
+
// (server/document-location-state.ts).
|
|
454
|
+
if (prepared.payload.metadata) {
|
|
455
|
+
attachLocationStateIfPresent(prepared.payload, !isPartial);
|
|
449
456
|
}
|
|
450
457
|
|
|
451
458
|
const response = yield* step("render", () =>
|
|
@@ -457,8 +464,14 @@ function* requestRenderPlan<TEnv>(
|
|
|
457
464
|
// shell). Capture does not flow through the HTTP pipeline — middleware never
|
|
458
465
|
// re-runs (it already ran for this request; guarding is serve-time).
|
|
459
466
|
// reqCtx._dynamic is re-read here on purpose: a loader may have marked the
|
|
460
|
-
// request dynamic while rendering.
|
|
461
|
-
|
|
467
|
+
// request dynamic while rendering. State a handler set in this render is
|
|
468
|
+
// not captured: a HIT replays the handler's output without running it, so
|
|
469
|
+
// the state would be lost on every HIT. The route renders in full instead.
|
|
470
|
+
if (
|
|
471
|
+
shell.kind === "miss" &&
|
|
472
|
+
!reqCtx._dynamic &&
|
|
473
|
+
(reqCtx._locationState?.length ?? 0) <= stateBeforeRender
|
|
474
|
+
) {
|
|
462
475
|
if (
|
|
463
476
|
response.status === 200 &&
|
|
464
477
|
(response.headers.get("content-type") ?? "").includes("text/html")
|
|
@@ -885,7 +898,10 @@ function* preparePayloadPlan<TEnv>(
|
|
|
885
898
|
stateCookieName: ctx.router.resolvedStateCookieName,
|
|
886
899
|
},
|
|
887
900
|
},
|
|
888
|
-
sourceScoped:
|
|
901
|
+
sourceScoped:
|
|
902
|
+
result.interceptTargeted === true ||
|
|
903
|
+
result.sourceHeld === true ||
|
|
904
|
+
deferring,
|
|
889
905
|
pprReplayStatus,
|
|
890
906
|
healKey,
|
|
891
907
|
};
|
|
@@ -1116,7 +1132,9 @@ function renderPreparedRscResponse<TEnv>(
|
|
|
1116
1132
|
// intercept targets renders the modal or the full page depending on the
|
|
1117
1133
|
// source, so neither response may be reused from another source: a full
|
|
1118
1134
|
// page prefetched where the intercept does not apply would otherwise
|
|
1119
|
-
// serve a later click where it does (#1007).
|
|
1135
|
+
// serve a later click where it does (#1007). So is a diff a revalidate()
|
|
1136
|
+
// decided from the source page: another page adopting it would keep its
|
|
1137
|
+
// own copy of the segment the answer held (#1331).
|
|
1120
1138
|
if (sourceScoped) {
|
|
1121
1139
|
rscHeaders["x-rsc-prefetch-scope"] = "source";
|
|
1122
1140
|
}
|
|
@@ -1712,6 +1730,15 @@ function serveShellHit(
|
|
|
1712
1730
|
// A visitor-keyed shell shows the visitor's URL: the document cache keys
|
|
1713
1731
|
// this response by the target.
|
|
1714
1732
|
if (entry.pathname !== undefined) reqCtx._payloadVisitorLocation = true;
|
|
1733
|
+
// Location state set before the render (middleware, a document redirect's
|
|
1734
|
+
// cookie) rides the tail's payload, not the shell. Marked here, on the
|
|
1735
|
+
// request's own context and before the response leaves (the tail runs on a
|
|
1736
|
+
// derived context, after an await), so the response goes
|
|
1737
|
+
// `private, no-store` and stays out of the document cache
|
|
1738
|
+
// (server/document-location-state.ts).
|
|
1739
|
+
if ((reqCtx._locationState?.length ?? 0) > 0) {
|
|
1740
|
+
reqCtx._documentLocationState = true;
|
|
1741
|
+
}
|
|
1715
1742
|
const metricsStore = reqCtx._metricsStore;
|
|
1716
1743
|
// Per-stage tail timing for the `ppr:tail` Server-Timing mirror and the
|
|
1717
1744
|
// perf console line: always in dev, in production only when this request
|
|
@@ -1768,6 +1795,9 @@ function serveShellHit(
|
|
|
1768
1795
|
| undefined;
|
|
1769
1796
|
// Same for the location: the key's URL, or a visitor-keyed shell's.
|
|
1770
1797
|
payload.metadata.pathname = entry.pathname ?? url.pathname;
|
|
1798
|
+
// The visitor's location state, as a MISS document's payload carries
|
|
1799
|
+
// it. The request was marked per visitor above.
|
|
1800
|
+
attachLocationStateIfPresent(payload);
|
|
1771
1801
|
}
|
|
1772
1802
|
// Full Flight render per request: hydration needs the whole payload (there
|
|
1773
1803
|
// is no Flight-side resume — a React limitation, not ours).
|