@rangojs/router 0.33.1 → 0.34.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/{chunk-TTXC2U3G.js → chunk-4HHFQSDC.js} +10 -2
- package/dist/bin/{chunk-3B7CKBRO.js → chunk-MWOHBDPM.js} +9 -9
- package/dist/bin/{client-urls-projection-DQLWPDAT.js → client-urls-projection-4Q5EQDAL.js} +2 -2
- package/dist/bin/rango.js +1 -1
- package/dist/bin/{runtime-EPUDEHO4.js → runtime-6WLCJCN7.js} +1 -1
- package/dist/bin/{runtime-discovery-3HH6BP3K.js → runtime-discovery-2ZTYRZ3V.js} +2 -2
- package/dist/bin/{temp-rsc-server-JALTIWLT.js → temp-rsc-server-EUNKRFJD.js} +3 -3
- package/dist/testing/vitest.js +5 -0
- package/dist/types/browser/history-state.d.ts +21 -0
- package/dist/types/browser/navigation-transaction.d.ts +2 -0
- package/dist/types/client-urls/server-projection.d.ts +7 -0
- package/dist/types/client-urls/types.d.ts +13 -14
- package/dist/types/errors.d.ts +5 -0
- package/dist/types/loader-store.d.ts +11 -1
- package/dist/types/prefetch-markers.d.ts +13 -4
- package/dist/types/route-definition/helpers-types.d.ts +14 -10
- package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +39 -21
- package/dist/types/server/context.d.ts +6 -5
- package/dist/types/testing/render-route.d.ts +21 -1
- package/dist/types/testing/serve-shell-request.d.ts +7 -7
- package/dist/types/testing/vitest-stubs/plugin-rsc-browser.d.ts +33 -0
- package/dist/types/types/loader-types.d.ts +10 -8
- package/dist/types/urls/path-helper-types.d.ts +9 -8
- package/dist/vite/index.js +30 -13
- package/package.json +1 -1
- package/skills/app-structure/large-app.md +3 -0
- package/skills/client-urls/SKILL.md +13 -10
- package/skills/hooks/data.md +5 -0
- package/skills/hooks/state.md +13 -11
- package/skills/intercept/SKILL.md +8 -4
- package/skills/links/SKILL.md +15 -12
- package/skills/loader/SKILL.md +52 -39
- package/skills/ppr/SKILL.md +1 -1
- package/skills/route/SKILL.md +15 -9
- package/skills/testing/SKILL.md +1 -0
- package/skills/testing/client-components.md +67 -19
- package/skills/testing/serve-shell-request.md +19 -7
- package/skills/ux-architecture/SKILL.md +16 -11
- package/src/browser/history-state.ts +48 -0
- package/src/browser/navigation-bridge.ts +12 -1
- package/src/browser/navigation-transaction.ts +14 -6
- package/src/browser/partial-update.ts +12 -4
- package/src/browser/prefetch/fetch.ts +3 -3
- package/src/client-urls/client-urls.ts +11 -7
- package/src/client-urls/server-projection.ts +21 -3
- package/src/client-urls/types.ts +13 -14
- package/src/errors.ts +17 -0
- package/src/loader-store.ts +41 -4
- package/src/prefetch-markers.ts +18 -4
- package/src/route-definition/dsl-helpers.ts +13 -6
- package/src/route-definition/helpers-types.ts +14 -10
- package/src/router/loader-resolution.ts +4 -4
- package/src/router/match-api.ts +1 -3
- package/src/router/segment-resolution/prefetch-deferral.ts +108 -50
- package/src/rsc/rsc-rendering.ts +1 -1
- package/src/server/context.ts +6 -5
- package/src/testing/render-route.tsx +122 -7
- package/src/testing/serve-shell-request.ts +7 -7
- package/src/testing/vitest-stubs/plugin-rsc-browser.ts +61 -0
- package/src/testing/vitest.ts +5 -0
- package/src/types/loader-types.ts +10 -8
- package/src/urls/path-helper-types.ts +9 -8
- package/src/use-loader.tsx +29 -7
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `prefetch: false`: what a prefetch request defers
|
|
3
|
-
* runs (docs/design/prefetch-false.md).
|
|
2
|
+
* `prefetch: false` and `prefetch: "intent"`: what a prefetch request defers
|
|
3
|
+
* and what its fill request runs (docs/design/prefetch-false.md). A
|
|
4
|
+
* speculative prefetch (viewport, render) defers both flags; an intent
|
|
5
|
+
* prefetch (a hover, `prefetch()`) defers `false` only and runs `"intent"`
|
|
6
|
+
* work as if it were unflagged.
|
|
4
7
|
*
|
|
5
8
|
* Two layers. The SCOPE is static per matched chain: which loader
|
|
6
9
|
* registrations are deferrable and which entries can be a handler unit. The
|
|
@@ -37,19 +40,19 @@ import {
|
|
|
37
40
|
import { requestHeaders } from "../../server/request-headers.js";
|
|
38
41
|
import type { HandlerContext, InternalHandlerContext } from "../../types.js";
|
|
39
42
|
import type { CacheScope } from "../../cache/cache-scope.js";
|
|
40
|
-
import {
|
|
43
|
+
import {
|
|
44
|
+
PREFETCH_HEADER,
|
|
45
|
+
PREFETCH_INTENT,
|
|
46
|
+
isPrefetchFlag,
|
|
47
|
+
type PrefetchFlag,
|
|
48
|
+
} from "../../prefetch-markers.js";
|
|
41
49
|
import { WIRE } from "../../wire-params.js";
|
|
42
50
|
import { isInterceptSegment } from "../../browser/intercept-utils.js";
|
|
43
51
|
|
|
44
52
|
/**
|
|
45
53
|
* What a request says it is, read once when its context is created
|
|
46
54
|
* (RequestContext._requestKind). A fill is marked on the raw URL and wins
|
|
47
|
-
* over the prefetch header
|
|
48
|
-
* render) and `"intent"` a hover or an explicit `prefetch()` call
|
|
49
|
-
* (PREFETCH_INTENT): both are prefetches, and only the first defers. Whether
|
|
50
|
-
* a prefetch defers anything is decided per match, by partialDeferralMode: a
|
|
51
|
-
* shell capture inherits the context of the request that scheduled it and is
|
|
52
|
-
* never a prefetch.
|
|
55
|
+
* over the prefetch header; an intent prefetch carries PREFETCH_INTENT.
|
|
53
56
|
*/
|
|
54
57
|
export function requestKind(
|
|
55
58
|
request: Request,
|
|
@@ -69,8 +72,8 @@ export function isPrefetchKind(kind: RequestKind | undefined): boolean {
|
|
|
69
72
|
return kind === "prefetch" || kind === "intent";
|
|
70
73
|
}
|
|
71
74
|
|
|
72
|
-
/**
|
|
73
|
-
interface
|
|
75
|
+
/** What one kind of prefetch can defer in a matched chain. */
|
|
76
|
+
interface DeferralSet {
|
|
74
77
|
/**
|
|
75
78
|
* Loader registrations a prefetch can defer, each with what makes it so:
|
|
76
79
|
* `true` for its own flag, else the segment ids of the flagged entries whose
|
|
@@ -78,8 +81,6 @@ interface DeferralScope {
|
|
|
78
81
|
* segments is new to the client.
|
|
79
82
|
*/
|
|
80
83
|
readonly loaders: ReadonlyMap<LoaderEntry, true | readonly string[]>;
|
|
81
|
-
/** `$$id`s of `loaders`: the loaders that cannot call ctx.rendered(). */
|
|
82
|
-
readonly loaderIds: ReadonlySet<string>;
|
|
83
84
|
/**
|
|
84
85
|
* Entries whose handler a prefetch can skip (a flagged loading() with a
|
|
85
86
|
* fallback to show). `index` is the chain index that owns the entry: its
|
|
@@ -90,6 +91,24 @@ interface DeferralScope {
|
|
|
90
91
|
readonly units: ReadonlyMap<EntryData, DeferralUnit>;
|
|
91
92
|
}
|
|
92
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Static, per matched chain: what a speculative prefetch defers (every flag,
|
|
96
|
+
* `false` or `"intent"`), and in `intent` what an intent prefetch defers.
|
|
97
|
+
*/
|
|
98
|
+
interface DeferralScope extends DeferralSet {
|
|
99
|
+
/**
|
|
100
|
+
* `$$id`s of `loaders`: the loaders that cannot call ctx.rendered(). The
|
|
101
|
+
* speculative set on every request: a loader an intent prefetch runs is
|
|
102
|
+
* still run by a speculative prefetch's fill, where its handler does not.
|
|
103
|
+
*/
|
|
104
|
+
readonly loaderIds: ReadonlySet<string>;
|
|
105
|
+
/**
|
|
106
|
+
* What an intent prefetch defers: the scope with every `"intent"` flag
|
|
107
|
+
* read as unflagged. Absent when no flag is `"intent"`: the scope itself.
|
|
108
|
+
*/
|
|
109
|
+
readonly intent?: DeferralSet;
|
|
110
|
+
}
|
|
111
|
+
|
|
93
112
|
interface DeferralUnit {
|
|
94
113
|
readonly index: number;
|
|
95
114
|
readonly ids: readonly string[];
|
|
@@ -98,6 +117,11 @@ interface DeferralUnit {
|
|
|
98
117
|
/** Per match: InternalHandlerContext._prefetchDeferral. */
|
|
99
118
|
export interface PrefetchDeferral {
|
|
100
119
|
readonly scope: DeferralScope;
|
|
120
|
+
/**
|
|
121
|
+
* What this request defers: `scope`, or `scope.intent` for an intent
|
|
122
|
+
* prefetch. The ctx.rendered() guards read `scope`.
|
|
123
|
+
*/
|
|
124
|
+
readonly defers: DeferralSet;
|
|
101
125
|
/**
|
|
102
126
|
* "prefetch": deferral replaces execution. "fill": the request that follows
|
|
103
127
|
* an adopted prefetch; a held segment is skipped outright. Unset on every
|
|
@@ -188,10 +212,36 @@ const EMPTY_SCOPE: DeferralScope = {
|
|
|
188
212
|
|
|
189
213
|
const scopes = new WeakMap<EntryData, DeferralScope>();
|
|
190
214
|
|
|
215
|
+
/** A flag an intent prefetch defers: `false` only. */
|
|
216
|
+
const offFlag = (flag: PrefetchFlag | undefined): boolean => flag === false;
|
|
217
|
+
/** A flag only an intent prefetch runs. */
|
|
218
|
+
const intentFlag = (flag: PrefetchFlag | undefined): boolean =>
|
|
219
|
+
flag === PREFETCH_INTENT;
|
|
220
|
+
|
|
221
|
+
const isEmptySet = (set: DeferralSet): boolean =>
|
|
222
|
+
set.loaders.size === 0 && set.units.size === 0;
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The scope of a collector, which returns what a prefetch whose flags
|
|
226
|
+
* `counts` accepts defers. `intent` is set only when an `"intent"` flag
|
|
227
|
+
* changes what is deferred: the collector under `intentFlag` finds something.
|
|
228
|
+
*/
|
|
229
|
+
function scopeOf(
|
|
230
|
+
collect: (counts: (flag: PrefetchFlag | undefined) => boolean) => DeferralSet,
|
|
231
|
+
): DeferralScope {
|
|
232
|
+
const all = collect(isPrefetchFlag);
|
|
233
|
+
if (isEmptySet(all)) return EMPTY_SCOPE;
|
|
234
|
+
return {
|
|
235
|
+
...all,
|
|
236
|
+
loaderIds: new Set([...all.loaders.keys()].map((l) => l.loader.$$id)),
|
|
237
|
+
...(!isEmptySet(collect(intentFlag)) && { intent: collect(offFlag) }),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
191
241
|
/**
|
|
192
242
|
* The deferral scope of a matched chain, memoized on its leaf entry (entries
|
|
193
243
|
* are per-isSSR manifest objects, replaced on HMR). EMPTY_SCOPE when nothing
|
|
194
|
-
* in the tree declares `prefetch: false`.
|
|
244
|
+
* in the tree declares `prefetch: false` or `prefetch: "intent"`.
|
|
195
245
|
*/
|
|
196
246
|
export function resolveDeferralScope(
|
|
197
247
|
entries: readonly EntryData[],
|
|
@@ -200,9 +250,18 @@ export function resolveDeferralScope(
|
|
|
200
250
|
if (!leaf) return EMPTY_SCOPE;
|
|
201
251
|
const cached = scopes.get(leaf);
|
|
202
252
|
if (cached) return cached;
|
|
253
|
+
const scope = scopeOf((counts) => collectDeferrable(entries, counts));
|
|
254
|
+
scopes.set(leaf, scope);
|
|
255
|
+
return scope;
|
|
256
|
+
}
|
|
203
257
|
|
|
258
|
+
/** What a prefetch whose flags are those `counts` accepts defers in a chain. */
|
|
259
|
+
function collectDeferrable(
|
|
260
|
+
entries: readonly EntryData[],
|
|
261
|
+
counts: (flag: PrefetchFlag | undefined) => boolean,
|
|
262
|
+
): DeferralSet {
|
|
263
|
+
const leaf = entries[entries.length - 1]!;
|
|
204
264
|
const loaders = new Map<LoaderEntry, true | readonly string[]>();
|
|
205
|
-
const loaderIds = new Set<string>();
|
|
206
265
|
const units = new Map<EntryData, DeferralUnit>();
|
|
207
266
|
const chain = new Set(entries);
|
|
208
267
|
// A clientUrls() group route renders loading() itself and its handler is
|
|
@@ -212,7 +271,7 @@ export function resolveDeferralScope(
|
|
|
212
271
|
// layout's above it; a flagged loading() defers nothing.
|
|
213
272
|
const clientGroupChain = leaf.clientGroup !== undefined;
|
|
214
273
|
const flagsLoading = (entry: EntryData): boolean =>
|
|
215
|
-
!clientGroupChain && entry.loadingPrefetch
|
|
274
|
+
!clientGroupChain && counts(entry.loadingPrefetch);
|
|
216
275
|
|
|
217
276
|
// `behind`: the segment ids of the flagged entries whose fallback covers
|
|
218
277
|
// `entry`'s loaders.
|
|
@@ -222,12 +281,11 @@ export function resolveDeferralScope(
|
|
|
222
281
|
index: number,
|
|
223
282
|
): void => {
|
|
224
283
|
for (const loaderEntry of entry.loader ?? []) {
|
|
225
|
-
const own = loaderEntry.prefetch
|
|
284
|
+
const own = counts(loaderEntry.prefetch);
|
|
226
285
|
if (!own && behind.length === 0) continue;
|
|
227
286
|
// An ssr: false loader on a ppr route is in scope too: whether this
|
|
228
287
|
// request serves it from a shell pin is per request (defersLoader).
|
|
229
288
|
loaders.set(loaderEntry, own ? true : behind);
|
|
230
|
-
loaderIds.add(loaderEntry.loader.$$id);
|
|
231
289
|
}
|
|
232
290
|
for (const parallelEntry of getParallelEntries(entry.parallel)) {
|
|
233
291
|
// A slot's segment id, as resolveParallelSegments
|
|
@@ -262,13 +320,7 @@ export function resolveDeferralScope(
|
|
|
262
320
|
// A layout's fallback covers its outlet: every deeper chain entry.
|
|
263
321
|
if (flagged && entry.type !== "route") behind = own;
|
|
264
322
|
});
|
|
265
|
-
|
|
266
|
-
const scope: DeferralScope =
|
|
267
|
-
loaders.size === 0 && units.size === 0
|
|
268
|
-
? EMPTY_SCOPE
|
|
269
|
-
: { loaders, loaderIds, units };
|
|
270
|
-
scopes.set(leaf, scope);
|
|
271
|
-
return scope;
|
|
323
|
+
return { loaders, units };
|
|
272
324
|
}
|
|
273
325
|
|
|
274
326
|
const interceptScopes = new WeakMap<InterceptEntry, DeferralScope>();
|
|
@@ -286,24 +338,21 @@ export function resolveInterceptDeferralScope(
|
|
|
286
338
|
): DeferralScope {
|
|
287
339
|
const cached = interceptScopes.get(intercept);
|
|
288
340
|
if (cached) return cached;
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
};
|
|
341
|
+
const scope = scopeOf((counts) => ({
|
|
342
|
+
loaders: new Map(
|
|
343
|
+
intercept.loader
|
|
344
|
+
.filter((l) => counts(l.prefetch))
|
|
345
|
+
.map((l) => [l, true as const]),
|
|
346
|
+
),
|
|
347
|
+
units: new Map(),
|
|
348
|
+
}));
|
|
298
349
|
interceptScopes.set(intercept, scope);
|
|
299
350
|
return scope;
|
|
300
351
|
}
|
|
301
352
|
|
|
302
353
|
/**
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
* only: an intent prefetch defers nothing, like the navigation it starts
|
|
306
|
-
* early (R16). Both halves are GETs: a request of
|
|
354
|
+
* The kind a partial request plans its deferral as, if any (requestKind's
|
|
355
|
+
* answer, filtered). Both halves of the feature are GETs: a request of
|
|
307
356
|
* another method that carries either marker is a normal request. A prefetch
|
|
308
357
|
* defers only on a plain GET navigation: an action re-renders what it
|
|
309
358
|
* changed, and a shell capture is never a prefetch whatever the request that
|
|
@@ -311,17 +360,20 @@ export function resolveInterceptDeferralScope(
|
|
|
311
360
|
* capture runs the full match): each one states its own reason.
|
|
312
361
|
*/
|
|
313
362
|
export function partialDeferralMode(request: {
|
|
314
|
-
|
|
315
|
-
prefetch: boolean;
|
|
363
|
+
kind: RequestKind | undefined;
|
|
316
364
|
method: string;
|
|
317
365
|
isAction: boolean;
|
|
318
366
|
isShellCapture: boolean;
|
|
319
|
-
}):
|
|
367
|
+
}): RequestKind | undefined {
|
|
320
368
|
if (request.method !== "GET") return undefined;
|
|
321
|
-
if (request.fill) return "fill";
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
369
|
+
if (request.kind === "fill") return "fill";
|
|
370
|
+
if (
|
|
371
|
+
isPrefetchKind(request.kind) &&
|
|
372
|
+
(request.isAction || request.isShellCapture)
|
|
373
|
+
) {
|
|
374
|
+
return undefined;
|
|
375
|
+
}
|
|
376
|
+
return request.kind;
|
|
325
377
|
}
|
|
326
378
|
|
|
327
379
|
export const NOTHING_HELD: ReadonlySet<string> = new Set();
|
|
@@ -331,16 +383,22 @@ export const NOTHING_HELD: ReadonlySet<string> = new Set();
|
|
|
331
383
|
* request is not a fill (the common case: nothing to carry). `held` is the
|
|
332
384
|
* client's segment ids as the request listed them. `scope` is the chain's
|
|
333
385
|
* unless the request resolves an intercept (resolveInterceptDeferralScope).
|
|
386
|
+
* An intent prefetch plans as a prefetch over `scope.intent`: a tree whose
|
|
387
|
+
* flags are all `"intent"` defers nothing in it, and its response is the
|
|
388
|
+
* navigation's (no `_payloadDeferred`, so no source scope).
|
|
334
389
|
*/
|
|
335
390
|
export function planPrefetchDeferral(
|
|
336
391
|
entries: readonly EntryData[],
|
|
337
|
-
|
|
392
|
+
requested: RequestKind | undefined,
|
|
338
393
|
matched: { pr?: boolean; od?: boolean },
|
|
339
394
|
cacheScope: CacheScope | null | undefined,
|
|
340
395
|
held: ReadonlySet<string> = NOTHING_HELD,
|
|
341
396
|
scope: DeferralScope = resolveDeferralScope(entries),
|
|
342
397
|
): PrefetchDeferral | undefined {
|
|
343
|
-
if (scope === EMPTY_SCOPE &&
|
|
398
|
+
if (scope === EMPTY_SCOPE && requested !== "fill") return undefined;
|
|
399
|
+
const mode = requested === "intent" ? "prefetch" : requested;
|
|
400
|
+
const defers: DeferralSet =
|
|
401
|
+
requested === "intent" ? (scope.intent ?? scope) : scope;
|
|
344
402
|
|
|
345
403
|
const leaf = entries[entries.length - 1];
|
|
346
404
|
let storedFrom = Infinity;
|
|
@@ -368,7 +426,7 @@ export function planPrefetchDeferral(
|
|
|
368
426
|
const skipped = new Set<string>();
|
|
369
427
|
let skipsChain = false;
|
|
370
428
|
if (mode === "prefetch") {
|
|
371
|
-
for (const [entry, unit] of
|
|
429
|
+
for (const [entry, unit] of defers.units) {
|
|
372
430
|
if (!unitEligible(storedFrom, entry, unit)) continue;
|
|
373
431
|
for (const id of unit.ids) {
|
|
374
432
|
if (held.has(id)) continue;
|
|
@@ -382,7 +440,7 @@ export function planPrefetchDeferral(
|
|
|
382
440
|
write: !skipsChain,
|
|
383
441
|
refresh: mode !== "fill",
|
|
384
442
|
};
|
|
385
|
-
return { scope, mode, storedFrom, held, skipped, record };
|
|
443
|
+
return { scope, defers, mode, storedFrom, held, skipped, record };
|
|
386
444
|
}
|
|
387
445
|
|
|
388
446
|
export function getPrefetchDeferral(
|
|
@@ -529,7 +587,7 @@ export function defersLoader(
|
|
|
529
587
|
): boolean {
|
|
530
588
|
const plan = getPrefetchDeferral(ctx);
|
|
531
589
|
if (plan?.mode !== "prefetch" || plan.held.has(segmentId)) return false;
|
|
532
|
-
const by = plan.
|
|
590
|
+
const by = plan.defers.loaders.get(loaderEntry);
|
|
533
591
|
const deferrable =
|
|
534
592
|
by === true || (by !== undefined && by.some((id) => !plan.held.has(id)));
|
|
535
593
|
return deferrable && !servesPin(loaderEntry);
|
package/src/rsc/rsc-rendering.ts
CHANGED
|
@@ -1120,7 +1120,7 @@ function renderPreparedRscResponse<TEnv>(
|
|
|
1120
1120
|
|
|
1121
1121
|
const rscHeaders: Record<string, string> = {
|
|
1122
1122
|
"content-type": "text/x-component;charset=utf-8",
|
|
1123
|
-
vary: `accept, X-Rango-State, X-RSC-Router-Client-Path, ${SEGMENT_FRAGMENT_CAPABILITY_HEADER}, ${SEGMENT_FRAGMENT_RECOVERY_HEADER}`,
|
|
1123
|
+
vary: `accept, X-Rango-State, X-RSC-Router-Client-Path, X-RSC-Router-Intercept-Source, ${SEGMENT_FRAGMENT_CAPABILITY_HEADER}, ${SEGMENT_FRAGMENT_RECOVERY_HEADER}`,
|
|
1124
1124
|
// Router identity, so the client can verify pre-decode (before importing
|
|
1125
1125
|
// chunks) that this content payload belongs to its app and refuse a
|
|
1126
1126
|
// foreign one (cache/proxy/bug). Control-only reload/redirect responses
|
package/src/server/context.ts
CHANGED
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
import { invariant, DslContextError } from "../errors";
|
|
15
15
|
import type { DefaultRouteName } from "../types/global-namespace.js";
|
|
16
16
|
import type { ContextVar } from "../context-var.js";
|
|
17
|
+
import type { PrefetchFlag } from "../prefetch-markers.js";
|
|
17
18
|
import { PPR_LANE_HINT } from "../rsc/shell-capture-constants.js";
|
|
18
19
|
import type { ShellCaptureState } from "../rsc/shell-capture.js";
|
|
19
20
|
import {
|
|
@@ -125,13 +126,13 @@ export type EntryPropDatas = {
|
|
|
125
126
|
export type EntryPropRender = {
|
|
126
127
|
loading?: ReactNode | false;
|
|
127
128
|
/**
|
|
128
|
-
* loading(fallback, { prefetch: false }) with a renderable
|
|
129
|
-
*
|
|
129
|
+
* loading(fallback, { prefetch: false | "intent" }) with a renderable
|
|
130
|
+
* fallback: a prefetch defers what the fallback covers while the entry's
|
|
130
131
|
* segment is new to the client
|
|
131
132
|
* (router/segment-resolution/prefetch-deferral.ts). Set on both DSL
|
|
132
133
|
* evaluations, whatever `ssr` did to `loading`.
|
|
133
134
|
*/
|
|
134
|
-
loadingPrefetch?:
|
|
135
|
+
loadingPrefetch?: PrefetchFlag;
|
|
135
136
|
/**
|
|
136
137
|
* loading(fallback, { ssr: false }), on both DSL evaluations; a document
|
|
137
138
|
* then sets `loading` to false (applyDocumentModeLoading).
|
|
@@ -172,11 +173,11 @@ export type LoaderEntry = {
|
|
|
172
173
|
*/
|
|
173
174
|
bake?: true;
|
|
174
175
|
/**
|
|
175
|
-
* loader(Def, { prefetch: false }): a
|
|
176
|
+
* loader(Def, { prefetch: false | "intent" }): a prefetch defers this
|
|
176
177
|
* registration while its segment is new to the client
|
|
177
178
|
* (router/segment-resolution/prefetch-deferral.ts).
|
|
178
179
|
*/
|
|
179
|
-
prefetch?:
|
|
180
|
+
prefetch?: PrefetchFlag;
|
|
180
181
|
/**
|
|
181
182
|
* The declaring layout's / parallel's / cache()'s include scope, for the
|
|
182
183
|
* loader body's `ctx.reverse(".name")` (its DSL kickoff carries it,
|
|
@@ -45,6 +45,13 @@
|
|
|
45
45
|
* lane production's Back uses (useNavigation() idle), with the loader
|
|
46
46
|
* data current then. The prefetch request, its decode and the HTTP cache
|
|
47
47
|
* are e2e territory.
|
|
48
|
+
* - useLoader().load() and a refreshGroup refresh reach the loader endpoint:
|
|
49
|
+
* renderRoute answers a GET for `_rsc_loader=<id>` with the last value the
|
|
50
|
+
* test gave that loader (the render seeds or respondToLoads()),
|
|
51
|
+
* unencoded; a pending Promise holds the answer until it settles, and a
|
|
52
|
+
* rejection is the loader's error. Needs the testing kit's
|
|
53
|
+
* `@vitejs/plugin-rsc/browser` alias (rangoTestAliases). A load() with params
|
|
54
|
+
* is answered with a loaderError; a body and non-GET loads are not answered.
|
|
48
55
|
* - A spec's transition({ when }) decides every commit with production's
|
|
49
56
|
* browser decision: navigate() and useRouter().push model kind "push",
|
|
50
57
|
* useRouter().replace kind "replace", refresh() kind "revalidate", and a
|
|
@@ -128,14 +135,18 @@ import {
|
|
|
128
135
|
buildHistoryState,
|
|
129
136
|
applyDocumentLocationState,
|
|
130
137
|
clearLocationStateOnDocumentLoad,
|
|
138
|
+
keptEntryState,
|
|
131
139
|
pushHistoryWithIdx,
|
|
132
140
|
resolveNavigationState,
|
|
141
|
+
shownLocationState,
|
|
133
142
|
} from "../browser/history-state.js";
|
|
134
143
|
import { withLocationStateKey } from "./location-state-key.js";
|
|
135
144
|
import type { Handle } from "../handle.js";
|
|
136
145
|
import type { ThemeConfig } from "../theme/types.js";
|
|
137
146
|
import { resolveThemeConfig } from "../theme/constants.js";
|
|
138
147
|
import { isUnderTestRunner } from "../runtime-env.js";
|
|
148
|
+
import { loaderAnswerResponse } from "./vitest-stubs/plugin-rsc-browser.js";
|
|
149
|
+
import { WIRE } from "../wire-params.js";
|
|
139
150
|
import {
|
|
140
151
|
scheduleStalePrefetchRevalidation,
|
|
141
152
|
setupNavigationBridgeDelegatedPrefetch,
|
|
@@ -532,8 +543,10 @@ export interface TestRouterHandle {
|
|
|
532
543
|
/**
|
|
533
544
|
* Re-render the current location, as router.refresh() does: a spec's
|
|
534
545
|
* transition({ when }) decides it with kind "revalidate" (`to` is `from`).
|
|
546
|
+
* `options.loaders` is the loader data the response carries, merged over
|
|
547
|
+
* the render-time seeds as in navigate().
|
|
535
548
|
*/
|
|
536
|
-
refresh(): Promise<void>;
|
|
549
|
+
refresh(options?: Pick<RenderRouteOptions, "loaders">): Promise<void>;
|
|
537
550
|
/**
|
|
538
551
|
* Prefetch `url`, as a `<Link>` hover or viewport trigger does: the page is
|
|
539
552
|
* kept with the loader data its response would carry now (the render-time
|
|
@@ -575,6 +588,17 @@ export interface TestRouterHandle {
|
|
|
575
588
|
keepClientCache?: boolean;
|
|
576
589
|
},
|
|
577
590
|
): Promise<void>;
|
|
591
|
+
/**
|
|
592
|
+
* Set what the loader endpoint answers from now on: a later
|
|
593
|
+
* `useLoader().load()` or refreshGroup refresh of each loader resolves to
|
|
594
|
+
* its value (a pending Promise holds the answer, a rejection is the
|
|
595
|
+
* loader's error). Renders nothing; the page on screen keeps its data.
|
|
596
|
+
*
|
|
597
|
+
* @example
|
|
598
|
+
* router.respondToLoads({ loaders: [[CartLoader, { count: 3 }]] });
|
|
599
|
+
* fireEvent.click(getByText("Refresh")); // load(): count 3
|
|
600
|
+
*/
|
|
601
|
+
respondToLoads(options: Pick<RenderRouteOptions, "loaders">): void;
|
|
578
602
|
/**
|
|
579
603
|
* The background revalidations a click on a stale prefetch started, in
|
|
580
604
|
* order: the page's pathname and search, and the segment ids the request
|
|
@@ -689,6 +713,38 @@ function LinkInterceptionRegistration({
|
|
|
689
713
|
return null;
|
|
690
714
|
}
|
|
691
715
|
|
|
716
|
+
/**
|
|
717
|
+
* The loader endpoint `useLoader().load()` fetches (`_rsc_loader=<id>`):
|
|
718
|
+
* installed on `fetch` while the tree is mounted, every other request passed
|
|
719
|
+
* through. A test that stubs `fetch` after the mount replaces it.
|
|
720
|
+
*/
|
|
721
|
+
function LoaderEndpoint({
|
|
722
|
+
answer,
|
|
723
|
+
}: {
|
|
724
|
+
answer: (loaderId: string, hasParams: boolean) => Promise<Response>;
|
|
725
|
+
}): null {
|
|
726
|
+
useEffect(() => {
|
|
727
|
+
const passThrough = globalThis.fetch;
|
|
728
|
+
// useLoader fetches with a URL string and an init.
|
|
729
|
+
const endpoint: typeof fetch = (input, init) => {
|
|
730
|
+
const url = new URL(String(input), TEST_ORIGIN);
|
|
731
|
+
const loaderId = url.searchParams.get(WIRE.loader);
|
|
732
|
+
if (
|
|
733
|
+
loaderId !== null &&
|
|
734
|
+
(init?.method ?? "GET").toUpperCase() === "GET"
|
|
735
|
+
) {
|
|
736
|
+
return answer(loaderId, url.searchParams.has(WIRE.loaderParams));
|
|
737
|
+
}
|
|
738
|
+
return passThrough(input, init);
|
|
739
|
+
};
|
|
740
|
+
globalThis.fetch = endpoint;
|
|
741
|
+
return () => {
|
|
742
|
+
if (globalThis.fetch === endpoint) globalThis.fetch = passThrough;
|
|
743
|
+
};
|
|
744
|
+
}, [answer]);
|
|
745
|
+
return null;
|
|
746
|
+
}
|
|
747
|
+
|
|
692
748
|
/**
|
|
693
749
|
* The marker production's root sets from its effect after hydration
|
|
694
750
|
* (browser/rsc-router.tsx Rango), so a boundary that hydrates in a later pass
|
|
@@ -841,6 +897,43 @@ export async function renderRoute(
|
|
|
841
897
|
return out;
|
|
842
898
|
};
|
|
843
899
|
const loaderData = seedLoaders(options.loaderData ?? {}, options.loaders);
|
|
900
|
+
// What the loader endpoint answers (LoaderEndpoint): the render seeds, then
|
|
901
|
+
// what respondToLoads() gave each loader.
|
|
902
|
+
const loadAnswers: Record<string, unknown> = { ...loaderData };
|
|
903
|
+
// The payload has rsc/loader-fetch.ts's shape; the value is taken when the
|
|
904
|
+
// request is made, as a server reads its data then.
|
|
905
|
+
const answerLoad = async (
|
|
906
|
+
loaderId: string,
|
|
907
|
+
hasParams: boolean,
|
|
908
|
+
): Promise<Response> => {
|
|
909
|
+
type Answer = Parameters<typeof loaderAnswerResponse>[0];
|
|
910
|
+
const failure = (name: string, message: string): Answer => ({
|
|
911
|
+
loaderResult: null,
|
|
912
|
+
loaderError: { name, message },
|
|
913
|
+
});
|
|
914
|
+
let payload: Answer;
|
|
915
|
+
if (hasParams) {
|
|
916
|
+
payload = failure(
|
|
917
|
+
"Error",
|
|
918
|
+
`renderRoute does not answer parameterized load() calls (loader "${loaderId}"). Call load() without params, or stub fetch.`,
|
|
919
|
+
);
|
|
920
|
+
} else if (!(loaderId in loadAnswers)) {
|
|
921
|
+
payload = failure(
|
|
922
|
+
"Error",
|
|
923
|
+
`renderRoute: no value for loader "${loaderId}". Seed it with \`loaders\` or router.respondToLoads().`,
|
|
924
|
+
);
|
|
925
|
+
} else {
|
|
926
|
+
try {
|
|
927
|
+
payload = { loaderResult: await loadAnswers[loaderId] };
|
|
928
|
+
} catch (error) {
|
|
929
|
+
payload = failure(
|
|
930
|
+
error instanceof Error ? error.name : "Error",
|
|
931
|
+
error instanceof Error ? error.message : String(error),
|
|
932
|
+
);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
return loaderAnswerResponse(payload);
|
|
936
|
+
};
|
|
844
937
|
|
|
845
938
|
if (typeof window !== "undefined") {
|
|
846
939
|
// The tree has no app version (its bridge reports none), so a seed needs
|
|
@@ -967,6 +1060,8 @@ export async function renderRoute(
|
|
|
967
1060
|
kind?: TransitionWhenKind;
|
|
968
1061
|
transition?: boolean;
|
|
969
1062
|
streamUntil?: Promise<unknown>;
|
|
1063
|
+
/** refresh(): a commit onto the entry on screen. */
|
|
1064
|
+
sameEntry?: boolean;
|
|
970
1065
|
};
|
|
971
1066
|
|
|
972
1067
|
// The page each history entry shows, by the entry's `idx` stamp (the
|
|
@@ -1120,7 +1215,7 @@ export async function renderRoute(
|
|
|
1120
1215
|
// on the event controller, which the provider takes with the payload.
|
|
1121
1216
|
// The URL stays put: renderRoute tracks location on the event
|
|
1122
1217
|
// controller, not window.location. router.navigate() without `state` or
|
|
1123
|
-
// `replace: true
|
|
1218
|
+
// `replace: true` leaves history alone.
|
|
1124
1219
|
const history = navOptions?.history;
|
|
1125
1220
|
const historyState = history
|
|
1126
1221
|
? buildHistoryState(
|
|
@@ -1199,6 +1294,18 @@ export async function renderRoute(
|
|
|
1199
1294
|
history.replace ?? false,
|
|
1200
1295
|
);
|
|
1201
1296
|
eventController.commitLocationState(historyState);
|
|
1297
|
+
} else if (navOptions?.sameEntry) {
|
|
1298
|
+
// refresh() commits onto the entry on screen as production's does
|
|
1299
|
+
// (navigation-transaction.ts sameEntry): the entry keeps its location
|
|
1300
|
+
// state, readers keep what they show.
|
|
1301
|
+
pushHistoryWithIdx(
|
|
1302
|
+
keptEntryState(window.history.state),
|
|
1303
|
+
window.location.href,
|
|
1304
|
+
true,
|
|
1305
|
+
);
|
|
1306
|
+
eventController.commitLocationState(
|
|
1307
|
+
shownLocationState(eventController.getLocationState()),
|
|
1308
|
+
);
|
|
1202
1309
|
}
|
|
1203
1310
|
entryOnScreen = entryIndex();
|
|
1204
1311
|
entries.set(entryOnScreen, {
|
|
@@ -1229,9 +1336,15 @@ export async function renderRoute(
|
|
|
1229
1336
|
});
|
|
1230
1337
|
};
|
|
1231
1338
|
|
|
1232
|
-
const refresh = (
|
|
1339
|
+
const refresh = (
|
|
1340
|
+
refreshOptions?: Pick<RenderRouteOptions, "loaders">,
|
|
1341
|
+
): Promise<void> => {
|
|
1233
1342
|
const current = new URL(eventController.getLocation().href);
|
|
1234
|
-
return navigate(current.pathname + current.search, {
|
|
1343
|
+
return navigate(current.pathname + current.search, {
|
|
1344
|
+
kind: "revalidate",
|
|
1345
|
+
loaders: refreshOptions?.loaders,
|
|
1346
|
+
sameEntry: true,
|
|
1347
|
+
});
|
|
1235
1348
|
};
|
|
1236
1349
|
|
|
1237
1350
|
// prefetch(): the pages prefetched so far, by pathname and search, each with
|
|
@@ -1358,9 +1471,7 @@ export async function renderRoute(
|
|
|
1358
1471
|
keepClientCache?: boolean;
|
|
1359
1472
|
},
|
|
1360
1473
|
): Promise<void> => {
|
|
1361
|
-
|
|
1362
|
-
loaderData[ensureSyntheticId(loader)] = data;
|
|
1363
|
-
}
|
|
1474
|
+
Object.assign(loaderData, seedLoaders({}, actionOptions?.loaders));
|
|
1364
1475
|
const page = await renderPageAgain(
|
|
1365
1476
|
new URL(eventController.getLocation().href),
|
|
1366
1477
|
"action",
|
|
@@ -1508,6 +1619,7 @@ export async function renderRoute(
|
|
|
1508
1619
|
/>
|
|
1509
1620
|
<DelegatedPrefetchRegistration bridge={bridge} />
|
|
1510
1621
|
<LinkInterceptionRegistration bridge={bridge} />
|
|
1622
|
+
<LoaderEndpoint answer={answerLoad} />
|
|
1511
1623
|
{options.hydrate && <HydratedMarker />}
|
|
1512
1624
|
{applyLateHandles && <AfterTree run={applyLateHandles} />}
|
|
1513
1625
|
{resolveDocumentRevealed && <AfterTree run={resolveDocumentRevealed} />}
|
|
@@ -1587,6 +1699,9 @@ export async function renderRoute(
|
|
|
1587
1699
|
refresh,
|
|
1588
1700
|
prefetch,
|
|
1589
1701
|
action,
|
|
1702
|
+
respondToLoads: (respondOptions) => {
|
|
1703
|
+
Object.assign(loadAnswers, seedLoaders({}, respondOptions.loaders));
|
|
1704
|
+
},
|
|
1590
1705
|
revalidations: () => [...revalidations],
|
|
1591
1706
|
pathname: () => new URL(eventController.getLocation().href).pathname,
|
|
1592
1707
|
params: () => eventController.getParams(),
|
|
@@ -100,13 +100,13 @@ export interface ServeShellRequestOptions<TEnv = any> {
|
|
|
100
100
|
* `result.replayStatus`.
|
|
101
101
|
*
|
|
102
102
|
* `prefetch: true` sends the speculative kind a viewport or render
|
|
103
|
-
* `<Link>` prefetch sends (`X-Rango-Prefetch: 1`), so
|
|
104
|
-
*
|
|
105
|
-
* lists them. A hover's
|
|
106
|
-
* serve it with `prefetch: true` and `headers: {
|
|
107
|
-
* "intent" }`, which `prefetch: true` leaves as given.
|
|
108
|
-
* sends the request the browser follows an adopted prefetch
|
|
109
|
-
* (`_rsc_fill`): pass the ids the prefetch delivered, without the deferred
|
|
103
|
+
* `<Link>` prefetch sends (`X-Rango-Prefetch: 1`), so loaders and
|
|
104
|
+
* `loading()` entries flagged `prefetch: false` or `"intent"` are deferred:
|
|
105
|
+
* `result.readDeferred()` lists them. A hover's intent prefetch defers
|
|
106
|
+
* `false` only; serve it with `prefetch: true` and `headers: {
|
|
107
|
+
* "X-Rango-Prefetch": "intent" }`, which `prefetch: true` leaves as given.
|
|
108
|
+
* `fill: true` sends the request the browser follows an adopted prefetch
|
|
109
|
+
* with (`_rsc_fill`): pass the ids the prefetch delivered, without the deferred
|
|
110
110
|
* ones, as `segments`, and the same URL as `from`. `interceptSource` sends
|
|
111
111
|
* the intercept source (`X-RSC-Router-Intercept-Source`) the browser sends
|
|
112
112
|
* from an open modal and with the fill of an intercept, whose `from` is
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stub for `@vitejs/plugin-rsc/browser`, shipped so consumers do not have to
|
|
3
|
+
* write a per-file `vi.mock(...)`. The real entry imports the
|
|
4
|
+
* `virtual:vite-rsc/client-references` Vite virtual at the top level, which a
|
|
5
|
+
* bare Vitest process cannot resolve.
|
|
6
|
+
*
|
|
7
|
+
* The router reaches this module from `useLoader().load()` and a
|
|
8
|
+
* `refreshGroup` refresh (use-loader.tsx imports deps/browser.ts lazily).
|
|
9
|
+
* renderRoute answers those requests (render-route.tsx), and a DOM test has
|
|
10
|
+
* no Flight server to encode an answer: renderRoute's seeds are client values
|
|
11
|
+
* already. So the answer travels with its Response object, and
|
|
12
|
+
* `createFromFetch` hands it back unchanged, as a decoded payload. Any other
|
|
13
|
+
* response is rejected. The remaining exports exist for the re-export in
|
|
14
|
+
* deps/browser.ts; navigations, actions and the document are not decoded
|
|
15
|
+
* here.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const answers = new WeakMap<object, unknown>();
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A loader endpoint Response whose decoded payload is `payload`
|
|
22
|
+
* (`{ loaderResult }` or `{ loaderResult: null, loaderError }`, as
|
|
23
|
+
* rsc/loader-fetch.ts answers).
|
|
24
|
+
*/
|
|
25
|
+
export function loaderAnswerResponse(payload: {
|
|
26
|
+
loaderResult: unknown;
|
|
27
|
+
loaderError?: { message: string; name: string };
|
|
28
|
+
}): Response {
|
|
29
|
+
const response = new Response(null, {
|
|
30
|
+
headers: { "content-type": "text/x-component" },
|
|
31
|
+
});
|
|
32
|
+
answers.set(response, payload);
|
|
33
|
+
return response;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function createFromFetch<T>(
|
|
37
|
+
response: Promise<Response> | Response,
|
|
38
|
+
): Promise<T> {
|
|
39
|
+
const settled = await response;
|
|
40
|
+
if (!answers.has(settled)) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
"@vitejs/plugin-rsc/browser test stub: createFromFetch decodes only " +
|
|
43
|
+
"the loader answers renderRoute gives (useLoader().load(), a " +
|
|
44
|
+
"refreshGroup refresh). Real Flight responses are e2e territory.",
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
return answers.get(settled) as T;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function unavailable(name: string): never {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`@vitejs/plugin-rsc/browser test stub: ${name} is not available in a ` +
|
|
53
|
+
"bare Vitest process. Real Flight responses are e2e territory.",
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const createFromReadableStream = (): Promise<never> =>
|
|
58
|
+
unavailable("createFromReadableStream");
|
|
59
|
+
export const encodeReply = (): Promise<never> => unavailable("encodeReply");
|
|
60
|
+
export const setServerCallback = (): void => {};
|
|
61
|
+
export const createTemporaryReferenceSet = (): unknown => new Map();
|
package/src/testing/vitest.ts
CHANGED
|
@@ -229,6 +229,11 @@ export function rangoTestAliases(
|
|
|
229
229
|
find: /^@vitejs\/plugin-rsc\/rsc(\/(server|client))?$/,
|
|
230
230
|
replacement: here("src/testing/vitest-stubs/plugin-rsc.ts"),
|
|
231
231
|
},
|
|
232
|
+
// useLoader().load() decodes renderRoute's loader answers through it.
|
|
233
|
+
{
|
|
234
|
+
find: /^@vitejs\/plugin-rsc\/browser$/,
|
|
235
|
+
replacement: here("src/testing/vitest-stubs/plugin-rsc-browser.ts"),
|
|
236
|
+
},
|
|
232
237
|
];
|
|
233
238
|
if (rsdServerEdgePath) {
|
|
234
239
|
aliases.push({
|