@rangojs/router 0.24.2 → 0.24.3
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/types/client-urls/layout-loader-copies.d.ts +11 -0
- package/dist/types/client-urls/server-projection.d.ts +5 -0
- package/dist/types/handles/pushed-by.d.ts +7 -0
- package/dist/types/server/context.d.ts +8 -0
- package/dist/vite/index.js +9 -3
- package/package.json +1 -1
- package/skills/client-urls/SKILL.md +30 -7
- package/skills/loader/SKILL.md +7 -4
- package/src/browser/segment-reconciler.ts +15 -1
- package/src/client-urls/layout-loader-copies.ts +56 -0
- package/src/client-urls/server-projection.ts +56 -6
- package/src/handles/pushed-by.ts +29 -4
- package/src/router/match-api.ts +11 -3
- package/src/router/segment-resolution/revalidation.ts +4 -1
- package/src/server/context.ts +48 -23
- package/src/ssr/index.tsx +63 -7
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { EntryData } from "../server/context.js";
|
|
2
|
+
import type { LoaderDefinition } from "../types.js";
|
|
3
|
+
export declare function setLayoutLoaderCopies(stub: LoaderDefinition<any>, ids: readonly string[]): void;
|
|
4
|
+
/** True for the materialized stub of a clientUrls() layout() loader copy. */
|
|
5
|
+
export declare function isLayoutLoaderCopy(stub: LoaderDefinition<any>): boolean;
|
|
6
|
+
/**
|
|
7
|
+
* `held` with the id of each of `route`'s layout() loader copies whose
|
|
8
|
+
* loader the page holds under another route of the layout; `held` itself
|
|
9
|
+
* when there is none.
|
|
10
|
+
*/
|
|
11
|
+
export declare function withLayoutLoaderCopies(held: ReadonlySet<string>, route: EntryData): ReadonlySet<string>;
|
|
@@ -42,6 +42,11 @@ export interface ClientUrlProjectionRoute {
|
|
|
42
42
|
* the registered predicates (revalidate-registry.ts). Absent when no
|
|
43
43
|
* loader declares one. */
|
|
44
44
|
readonly loaderRevalidate?: readonly (readonly string[])[];
|
|
45
|
+
/** One per leading loader its layout()s declare
|
|
46
|
+
* (ClientUrlRouteRecord.layoutLoaders): the record's ordinal in the
|
|
47
|
+
* definition. Two routes whose loader at an index carries the same ordinal
|
|
48
|
+
* hold one layout() loader (layout-loader-copies.ts). Absent when none. */
|
|
49
|
+
readonly layoutLoaders?: readonly number[];
|
|
45
50
|
/** Data-only transition config (never `when`); absent in projections
|
|
46
51
|
* serialized before transition support. */
|
|
47
52
|
readonly transition?: Readonly<ClientTransitionConfig>;
|
|
@@ -27,5 +27,12 @@ export declare function withSegmentHandles(target: HandleData, source: HandleDat
|
|
|
27
27
|
* the position of the first value they replace (the end when they replace
|
|
28
28
|
* none). New containers throughout: an object a reader was handed (a history
|
|
29
29
|
* entry's data) is never written to.
|
|
30
|
+
*
|
|
31
|
+
* A segment with no values of a handle takes, for each loader `matched`
|
|
32
|
+
* registers on it that did not run, the values that loader pushed under a
|
|
33
|
+
* segment the page leaves, a slot excepted: a clientUrls() layout() loader the
|
|
34
|
+
* server held on a navigation between two routes of the layout
|
|
35
|
+
* (client-urls/layout-loader-copies.ts), whose pushes the page filed under
|
|
36
|
+
* the route it leaves.
|
|
30
37
|
*/
|
|
31
38
|
export declare function mergePartialHandleData(current: HandleData, incoming: HandleData, segmentOrder: readonly string[], matched: readonly string[], resolvedIds: readonly string[] | undefined): HandleData;
|
|
@@ -353,6 +353,14 @@ interface HelperContext {
|
|
|
353
353
|
includeScope?: string;
|
|
354
354
|
}
|
|
355
355
|
export declare const RangoContext: AsyncLocalStorage<HelperContext>;
|
|
356
|
+
type ShortCodeType = "layout" | "parallel" | "route" | "loader" | "cache";
|
|
357
|
+
/**
|
|
358
|
+
* The shortCode the `ahead`-th next `getShortCode(type)` call in this DSL
|
|
359
|
+
* context returns, without taking it. clientUrls() materialization reads the
|
|
360
|
+
* code of every group route this way, including the routes an evaluation for
|
|
361
|
+
* another route skips (client-urls/layout-loader-copies.ts).
|
|
362
|
+
*/
|
|
363
|
+
export declare function peekShortCode(type: ShortCodeType, ahead: number): string;
|
|
356
364
|
export declare const getContext: () => {
|
|
357
365
|
context: AsyncLocalStorage<HelperContext>;
|
|
358
366
|
getStore: () => HelperContext;
|
package/dist/vite/index.js
CHANGED
|
@@ -5929,7 +5929,7 @@ import { resolve } from "node:path";
|
|
|
5929
5929
|
// package.json
|
|
5930
5930
|
var package_default = {
|
|
5931
5931
|
name: "@rangojs/router",
|
|
5932
|
-
version: "0.24.
|
|
5932
|
+
version: "0.24.3",
|
|
5933
5933
|
description: "Django-inspired RSC router with composable URL patterns",
|
|
5934
5934
|
keywords: [
|
|
5935
5935
|
"react",
|
|
@@ -11034,7 +11034,11 @@ function serializeTransition(route2) {
|
|
|
11034
11034
|
function indicesWhere(items, pred) {
|
|
11035
11035
|
return items.flatMap((item, index) => pred(item) ? [index] : []);
|
|
11036
11036
|
}
|
|
11037
|
-
function serializeRoute(route2, options) {
|
|
11037
|
+
function serializeRoute(route2, options, ordinals) {
|
|
11038
|
+
const layoutLoaders = route2.layoutLoaders.map((record) => {
|
|
11039
|
+
if (!ordinals.has(record)) ordinals.set(record, ordinals.size);
|
|
11040
|
+
return ordinals.get(record);
|
|
11041
|
+
});
|
|
11038
11042
|
const loaderIds = route2.loaders.map(({ loader: loader2 }, index) => {
|
|
11039
11043
|
if (typeof loader2.$$id !== "string" || loader2.$$id.length === 0) {
|
|
11040
11044
|
throw projectionError(
|
|
@@ -11078,6 +11082,7 @@ function serializeRoute(route2, options) {
|
|
|
11078
11082
|
loaderRevalidate.map((keys) => Object.freeze(keys))
|
|
11079
11083
|
)
|
|
11080
11084
|
} : {},
|
|
11085
|
+
...layoutLoaders.length > 0 ? { layoutLoaders: Object.freeze(layoutLoaders) } : {},
|
|
11081
11086
|
...transition2 ? { transition: transition2 } : {},
|
|
11082
11087
|
...typeof route2.transition?.when === "function" ? { transitionWhen: true } : {}
|
|
11083
11088
|
});
|
|
@@ -11099,10 +11104,11 @@ function serializeIntercept(record, index) {
|
|
|
11099
11104
|
});
|
|
11100
11105
|
}
|
|
11101
11106
|
function serializeClientUrlPatterns(patterns, options = {}) {
|
|
11107
|
+
const ordinals = /* @__PURE__ */ new Map();
|
|
11102
11108
|
return Object.freeze({
|
|
11103
11109
|
version: 1,
|
|
11104
11110
|
routes: Object.freeze(
|
|
11105
|
-
patterns.routes.map((route2) => serializeRoute(route2, options))
|
|
11111
|
+
patterns.routes.map((route2) => serializeRoute(route2, options, ordinals))
|
|
11106
11112
|
),
|
|
11107
11113
|
intercepts: Object.freeze(
|
|
11108
11114
|
(patterns.intercepts ?? []).map(serializeIntercept)
|
package/package.json
CHANGED
|
@@ -278,6 +278,18 @@ yet (a known gap, left for a follow-up). The build still checks those
|
|
|
278
278
|
predicates like any other, so one that captures a binding of the `"use client"`
|
|
279
279
|
module fails the build although nothing runs it: import it, or drop it.
|
|
280
280
|
|
|
281
|
+
A group `layout()` loader revalidates as a server layout's loader does. With no
|
|
282
|
+
`revalidate()` a navigation keeps it, whichever route of the layout it goes to
|
|
283
|
+
and whatever params or search change (a same-route param navigation too), and
|
|
284
|
+
an action re-runs it. Its `revalidate()` decides on every navigation and
|
|
285
|
+
action, with that default as `defaultShouldRevalidate`: a shop layout's
|
|
286
|
+
`loader(CartLoader, () => [revalidate(({ isAction }) => isAction(CartActions))])`
|
|
287
|
+
runs on the document request and then only on cart actions, and the page
|
|
288
|
+
commits without waiting for it. A navigation into the layout runs it. A layout
|
|
289
|
+
loader that reads the route's params needs a rule that re-runs it:
|
|
290
|
+
`revalidate(({ currentParams, nextParams, defaultShouldRevalidate }) =>
|
|
291
|
+
currentParams.slug !== nextParams.slug ? true : defaultShouldRevalidate)`.
|
|
292
|
+
|
|
281
293
|
Two scars worth copying:
|
|
282
294
|
|
|
283
295
|
- A blunt `() => false` keeps serving the OLD product on product→product
|
|
@@ -455,7 +467,11 @@ makes the first flush, and `ssr: false` guarantees the loader's data does. It
|
|
|
455
467
|
does not cover the client component's module: the first document a build's
|
|
456
468
|
fresh server renders can still be waiting for it. The dev server matches a
|
|
457
469
|
build here: when the shell is ready, it waits for the client components the
|
|
458
|
-
payload has named and that are still loading, and for nothing else.
|
|
470
|
+
payload has named and that are still loading, and for nothing else. On a
|
|
471
|
+
document with an `ssr: false` loader, a part React finishes a moment after the
|
|
472
|
+
shell, such as a `useActionState` form (React suspends it once to encode its
|
|
473
|
+
action), is in the first flush too: the router gives React up to three more
|
|
474
|
+
turns before it, and none when the document is already complete.
|
|
459
475
|
|
|
460
476
|
## `prefetch: false` — keep a loader out of prefetches
|
|
461
477
|
|
|
@@ -477,10 +493,16 @@ too, `useOutlet().pending` ends with that commit, and one follow-up request
|
|
|
477
493
|
own `<Suspense>` to show the page and that fallback at once; with no boundary
|
|
478
494
|
React holds the page being left until the fill answers, as it would with no
|
|
479
495
|
prefetch. Same rules as on a server route (`/loader` → "`prefetch: false`"):
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
496
|
+
the flag skips a loader in a prefetch only when the loader is new to the page.
|
|
497
|
+
A loader already on screen is never deferred: if its revalidation (its
|
|
498
|
+
`revalidate()` or the default) re-runs it, it runs in the prefetch with the
|
|
499
|
+
rest of the destination; otherwise it is held. A same-route click
|
|
500
|
+
(`/product/a` to `/product/b`) is the familiar case.
|
|
501
|
+
|
|
502
|
+
- A group `layout()` loader is on screen on every route of its layout: a
|
|
503
|
+
prefetch between two of them defers nothing for it (its `revalidate()`
|
|
504
|
+
decides, and with no rule it is held), and a prefetch that enters the
|
|
505
|
+
layout defers it.
|
|
484
506
|
- A flagged `loader()` on a server layout above the group is deferred while
|
|
485
507
|
that layout is new to the page (entering the group from outside).
|
|
486
508
|
- `loading()` in `clientUrls()` takes no options, and a flagged `loading()`
|
|
@@ -529,8 +551,9 @@ a hard load of the target URL renders the full route.
|
|
|
529
551
|
`loading()` does not re-flash there. Across different routes, a `layout()`
|
|
530
552
|
keeps its instance while the navigation stays under it, and its own
|
|
531
553
|
`layout()` loaders keep their data on the optimistic frame (a cart count does
|
|
532
|
-
not suspend)
|
|
533
|
-
|
|
554
|
+
not suspend), and in the canonical commit when their `revalidate()` holds
|
|
555
|
+
them; only the destination's loaders and those of layouts it mounts anew
|
|
556
|
+
wait for the server. `transition()` is not needed for the
|
|
534
557
|
hold; it adds the view-transition animation. Inline `<Suspense>` above each
|
|
535
558
|
`useLoader` read is still the finer-grained tool when different reads on
|
|
536
559
|
one route should wait independently.
|
package/skills/loader/SKILL.md
CHANGED
|
@@ -1128,10 +1128,13 @@ What happens:
|
|
|
1128
1128
|
- A **prefetch** of the route does not run the loader and does not read its
|
|
1129
1129
|
`cache()`; the payload marks it deferred. The rest of the page prefetches as
|
|
1130
1130
|
usual.
|
|
1131
|
-
- Only on a
|
|
1132
|
-
|
|
1133
|
-
in the prefetch
|
|
1134
|
-
click sends no second request
|
|
1131
|
+
- Only on a loader new to the page. A loader already on screen is never
|
|
1132
|
+
deferred: if its revalidation (its `revalidate()` or the default) re-runs
|
|
1133
|
+
it, it runs in the prefetch with the rest of the destination, exactly as
|
|
1134
|
+
without the flag, and the click sends no second request; otherwise it is
|
|
1135
|
+
held. The familiar case is a same-route navigation such as `/product/a` to
|
|
1136
|
+
`/product/b`; a `clientUrls()` group `layout()` loader is on screen on every
|
|
1137
|
+
route of its layout the same way. The rule: a click that adopts a prefetch is
|
|
1135
1138
|
never worse than the same click with no prefetch at all (one exception: a
|
|
1136
1139
|
click before its prefetch has answered, under "The limits"), and content on
|
|
1137
1140
|
screen is never swapped for a fallback because of the flag. For the same
|
|
@@ -87,6 +87,19 @@ export function reconcileSegments(input: ReconcileInput): ReconcileResult {
|
|
|
87
87
|
input.cachedSegments.forEach((s) => cachedSegments.set(s.id, s));
|
|
88
88
|
|
|
89
89
|
const diffSet = new Set(diff);
|
|
90
|
+
// A clientUrls() layout() loader the server held on a navigation between
|
|
91
|
+
// two routes of the layout (client-urls/layout-loader-copies.ts): matched
|
|
92
|
+
// names it under the destination route, the page holds it under the group
|
|
93
|
+
// route it leaves. Its copy keeps its data under the new id.
|
|
94
|
+
const origin = input.cachedSegments.find((s) => s.clientGroup);
|
|
95
|
+
const destination =
|
|
96
|
+
origin &&
|
|
97
|
+
input.serverSegments.find((s) => s.clientGroup === origin.clientGroup);
|
|
98
|
+
const carried = (id: string) => {
|
|
99
|
+
const copy =
|
|
100
|
+
destination && cachedSegments.get(id.replace(destination.id, origin.id));
|
|
101
|
+
return copy && { ...copy, id };
|
|
102
|
+
};
|
|
90
103
|
if (IS_BROWSER_DEBUG) {
|
|
91
104
|
debugLog(
|
|
92
105
|
`[reconcile] actor=${actor}, matched=${matched.length}, diff=${diff.length}`,
|
|
@@ -102,7 +115,7 @@ export function reconcileSegments(input: ReconcileInput): ReconcileResult {
|
|
|
102
115
|
const segments = matched
|
|
103
116
|
.map((segId: string) => {
|
|
104
117
|
const fromServer = serverSegments.get(segId);
|
|
105
|
-
|
|
118
|
+
let fromCache = cachedSegments.get(segId);
|
|
106
119
|
|
|
107
120
|
if (fromServer) {
|
|
108
121
|
const inDiff = diffSet.has(segId);
|
|
@@ -179,6 +192,7 @@ export function reconcileSegments(input: ReconcileInput): ReconcileResult {
|
|
|
179
192
|
}
|
|
180
193
|
|
|
181
194
|
// Fall back to cached segment (server expects client to already have it)
|
|
195
|
+
fromCache ??= carried(segId);
|
|
182
196
|
if (!fromCache) {
|
|
183
197
|
if (actor === "action") {
|
|
184
198
|
console.error(`[Browser] MISSING SEGMENT: ${segId} not in cache!`);
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { EntryData } from "../server/context.js";
|
|
2
|
+
import type { LoaderDefinition } from "../types.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A clientUrls() layout()'s loaders are copied into every route record below
|
|
6
|
+
* it (client-urls.ts applyConfig), and a loader segment's id names its route
|
|
7
|
+
* (`${routeShortCode}D${index}.${$$id}`). On a navigation between two of the
|
|
8
|
+
* layout's routes the destination's copy was a segment the client lacked, so
|
|
9
|
+
* it ran whatever its revalidate() said.
|
|
10
|
+
*
|
|
11
|
+
* Materialization (server-projection.ts) records, for each copy, the ids the
|
|
12
|
+
* same layout() loader has under the group's other routes of that layout. A
|
|
13
|
+
* request whose page holds one of them holds the destination's copy: the
|
|
14
|
+
* loader's revalidate() decides, as on a param navigation, and a held copy is
|
|
15
|
+
* left out of the response. The browser carries the page's copy to the id
|
|
16
|
+
* `matched` names (browser/segment-reconciler.ts) with its handle pushes
|
|
17
|
+
* (handles/pushed-by.ts).
|
|
18
|
+
*
|
|
19
|
+
* A copy takes a server layout's revalidation default, not its route's: held
|
|
20
|
+
* on a navigation, params or search changes included, and re-run on an action
|
|
21
|
+
* (isLayoutLoaderCopy, segment-resolution/revalidation.ts).
|
|
22
|
+
*
|
|
23
|
+
* Keyed by the materialized loader stub, created once per evaluation of the
|
|
24
|
+
* route: the ids depend on the route's shortCode and its siblings'.
|
|
25
|
+
*/
|
|
26
|
+
const copies = new WeakMap<LoaderDefinition<any>, readonly string[]>();
|
|
27
|
+
|
|
28
|
+
export function setLayoutLoaderCopies(
|
|
29
|
+
stub: LoaderDefinition<any>,
|
|
30
|
+
ids: readonly string[],
|
|
31
|
+
): void {
|
|
32
|
+
copies.set(stub, ids);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** True for the materialized stub of a clientUrls() layout() loader copy. */
|
|
36
|
+
export function isLayoutLoaderCopy(stub: LoaderDefinition<any>): boolean {
|
|
37
|
+
return copies.has(stub);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* `held` with the id of each of `route`'s layout() loader copies whose
|
|
42
|
+
* loader the page holds under another route of the layout; `held` itself
|
|
43
|
+
* when there is none.
|
|
44
|
+
*/
|
|
45
|
+
export function withLayoutLoaderCopies(
|
|
46
|
+
held: ReadonlySet<string>,
|
|
47
|
+
route: EntryData,
|
|
48
|
+
): ReadonlySet<string> {
|
|
49
|
+
let out: Set<string> | undefined;
|
|
50
|
+
route.loader?.forEach(({ loader }, index) => {
|
|
51
|
+
if (copies.get(loader)?.some((id) => held.has(id))) {
|
|
52
|
+
(out ??= new Set(held)).add(`${route.shortCode}D${index}.${loader.$$id}`);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
return out ?? held;
|
|
56
|
+
}
|
|
@@ -2,8 +2,10 @@ import * as React from "react";
|
|
|
2
2
|
import type { SearchSchemaValue } from "../search-params.js";
|
|
3
3
|
import { getLoaderLazy } from "../server/loader-registry.js";
|
|
4
4
|
import {
|
|
5
|
+
getContext,
|
|
5
6
|
getNamePrefix,
|
|
6
7
|
getUrlPrefix,
|
|
8
|
+
peekShortCode,
|
|
7
9
|
type InterceptSelectorContext,
|
|
8
10
|
} from "../server/context.js";
|
|
9
11
|
import type {
|
|
@@ -21,6 +23,7 @@ import type { AllUseItems } from "../route-types.js";
|
|
|
21
23
|
import { urls } from "../urls/urls-function.js";
|
|
22
24
|
import { isHoistedRevalidateRef } from "./hoisted-revalidate.js";
|
|
23
25
|
import { getClientUrlRevalidator } from "./revalidate-registry.js";
|
|
26
|
+
import { setLayoutLoaderCopies } from "./layout-loader-copies.js";
|
|
24
27
|
import {
|
|
25
28
|
ClientUrlsGroupLayout,
|
|
26
29
|
ClientUrlsInterceptLoading,
|
|
@@ -32,6 +35,7 @@ import { createClientUrlsWhenRef } from "../transition-when-ref.js";
|
|
|
32
35
|
import type {
|
|
33
36
|
ClientTransitionConfig,
|
|
34
37
|
ClientUrlInterceptRecord,
|
|
38
|
+
ClientUrlLoaderRecord,
|
|
35
39
|
ClientUrlPatterns,
|
|
36
40
|
ClientUrlRouteRecord,
|
|
37
41
|
} from "./types.js";
|
|
@@ -106,6 +110,11 @@ export interface ClientUrlProjectionRoute {
|
|
|
106
110
|
* the registered predicates (revalidate-registry.ts). Absent when no
|
|
107
111
|
* loader declares one. */
|
|
108
112
|
readonly loaderRevalidate?: readonly (readonly string[])[];
|
|
113
|
+
/** One per leading loader its layout()s declare
|
|
114
|
+
* (ClientUrlRouteRecord.layoutLoaders): the record's ordinal in the
|
|
115
|
+
* definition. Two routes whose loader at an index carries the same ordinal
|
|
116
|
+
* hold one layout() loader (layout-loader-copies.ts). Absent when none. */
|
|
117
|
+
readonly layoutLoaders?: readonly number[];
|
|
109
118
|
/** Data-only transition config (never `when`); absent in projections
|
|
110
119
|
* serialized before transition support. */
|
|
111
120
|
readonly transition?: Readonly<ClientTransitionConfig>;
|
|
@@ -299,7 +308,13 @@ function indicesWhere<T>(
|
|
|
299
308
|
function serializeRoute(
|
|
300
309
|
route: ClientUrlRouteRecord,
|
|
301
310
|
options: SerializeClientUrlPatternsOptions,
|
|
311
|
+
ordinals: Map<ClientUrlLoaderRecord, number>,
|
|
302
312
|
): ClientUrlProjectionRoute {
|
|
313
|
+
// flattenLayout creates a layout()'s records once for every route below it.
|
|
314
|
+
const layoutLoaders = route.layoutLoaders.map((record) => {
|
|
315
|
+
if (!ordinals.has(record)) ordinals.set(record, ordinals.size);
|
|
316
|
+
return ordinals.get(record)!;
|
|
317
|
+
});
|
|
303
318
|
const loaderIds = route.loaders.map(({ loader }, index) => {
|
|
304
319
|
if (typeof loader.$$id !== "string" || loader.$$id.length === 0) {
|
|
305
320
|
throw projectionError(
|
|
@@ -355,6 +370,9 @@ function serializeRoute(
|
|
|
355
370
|
),
|
|
356
371
|
}
|
|
357
372
|
: {}),
|
|
373
|
+
...(layoutLoaders.length > 0
|
|
374
|
+
? { layoutLoaders: Object.freeze(layoutLoaders) }
|
|
375
|
+
: {}),
|
|
358
376
|
...(transition ? { transition } : {}),
|
|
359
377
|
...(typeof route.transition?.when === "function"
|
|
360
378
|
? { transitionWhen: true as const }
|
|
@@ -388,10 +406,11 @@ export function serializeClientUrlPatterns(
|
|
|
388
406
|
patterns: ClientUrlPatterns,
|
|
389
407
|
options: SerializeClientUrlPatternsOptions = {},
|
|
390
408
|
): ClientUrlProjection {
|
|
409
|
+
const ordinals = new Map<ClientUrlLoaderRecord, number>();
|
|
391
410
|
return Object.freeze({
|
|
392
411
|
version: 1 as const,
|
|
393
412
|
routes: Object.freeze(
|
|
394
|
-
patterns.routes.map((route) => serializeRoute(route, options)),
|
|
413
|
+
patterns.routes.map((route) => serializeRoute(route, options, ordinals)),
|
|
395
414
|
),
|
|
396
415
|
intercepts: Object.freeze(
|
|
397
416
|
(patterns.intercepts ?? []).map(serializeIntercept),
|
|
@@ -613,9 +632,39 @@ function materializeRouteItems(
|
|
|
613
632
|
// builders must run where the items belong: at the module top level for the
|
|
614
633
|
// flat (no-intercept) shape, or inside the group layout's children callback
|
|
615
634
|
// when intercepts wrap the group.
|
|
616
|
-
const buildRouteItems = (): AllUseItems[] =>
|
|
617
|
-
|
|
618
|
-
|
|
635
|
+
const buildRouteItems = (): AllUseItems[] => {
|
|
636
|
+
// Each route's shortCode, read before path() takes them: one per route,
|
|
637
|
+
// in order, from the same counter. An evaluation for one route skips the
|
|
638
|
+
// others' entries, not their codes.
|
|
639
|
+
const codes = projection.routes.map((_, ahead) =>
|
|
640
|
+
peekShortCode("route", ahead),
|
|
641
|
+
);
|
|
642
|
+
// A route's use(), recording each layout() loader copy's ids under the
|
|
643
|
+
// other routes of its layout() (layout-loader-copies.ts). path() runs it
|
|
644
|
+
// with the route entry as parent, whose loaders it has created on return.
|
|
645
|
+
const recordingCopies =
|
|
646
|
+
<T>(routeIndex: number, use: () => T) =>
|
|
647
|
+
(): T => {
|
|
648
|
+
const items = use();
|
|
649
|
+
const entry = getContext().getParent()!;
|
|
650
|
+
projection.routes[routeIndex].layoutLoaders?.forEach(
|
|
651
|
+
(ordinal, loaderIndex) =>
|
|
652
|
+
setLayoutLoaderCopies(
|
|
653
|
+
entry.loader[loaderIndex].loader,
|
|
654
|
+
projection.routes.flatMap((other, otherIndex) =>
|
|
655
|
+
otherIndex !== routeIndex &&
|
|
656
|
+
other.layoutLoaders?.[loaderIndex] === ordinal
|
|
657
|
+
? [
|
|
658
|
+
`${codes[otherIndex]}D${loaderIndex}.${other.loaderIds[loaderIndex]}`,
|
|
659
|
+
]
|
|
660
|
+
: [],
|
|
661
|
+
),
|
|
662
|
+
),
|
|
663
|
+
);
|
|
664
|
+
return items;
|
|
665
|
+
};
|
|
666
|
+
return projection.routes.map(
|
|
667
|
+
(route, routeIndex) =>
|
|
619
668
|
path(
|
|
620
669
|
route.pattern,
|
|
621
670
|
() =>
|
|
@@ -625,7 +674,7 @@ function materializeRouteItems(
|
|
|
625
674
|
namePrefix,
|
|
626
675
|
}),
|
|
627
676
|
materializedPathOptions(route, clientGroup),
|
|
628
|
-
() => [
|
|
677
|
+
recordingCopies(routeIndex, () => [
|
|
629
678
|
...route.loaderIds.map((id, loaderIndex) => {
|
|
630
679
|
const revalidators = loaderRevalidators(
|
|
631
680
|
reference,
|
|
@@ -669,9 +718,10 @@ function materializeRouteItems(
|
|
|
669
718
|
}),
|
|
670
719
|
]
|
|
671
720
|
: []),
|
|
672
|
-
],
|
|
721
|
+
]),
|
|
673
722
|
) as AllUseItems,
|
|
674
723
|
);
|
|
724
|
+
};
|
|
675
725
|
|
|
676
726
|
const intercepts = projection.intercepts ?? [];
|
|
677
727
|
if (intercepts.length === 0) return buildRouteItems();
|
package/src/handles/pushed-by.ts
CHANGED
|
@@ -89,6 +89,13 @@ export function withSegmentHandles(
|
|
|
89
89
|
* the position of the first value they replace (the end when they replace
|
|
90
90
|
* none). New containers throughout: an object a reader was handed (a history
|
|
91
91
|
* entry's data) is never written to.
|
|
92
|
+
*
|
|
93
|
+
* A segment with no values of a handle takes, for each loader `matched`
|
|
94
|
+
* registers on it that did not run, the values that loader pushed under a
|
|
95
|
+
* segment the page leaves, a slot excepted: a clientUrls() layout() loader the
|
|
96
|
+
* server held on a navigation between two routes of the layout
|
|
97
|
+
* (client-urls/layout-loader-copies.ts), whose pushes the page filed under
|
|
98
|
+
* the route it leaves.
|
|
92
99
|
*/
|
|
93
100
|
export function mergePartialHandleData(
|
|
94
101
|
current: HandleData,
|
|
@@ -109,13 +116,31 @@ export function mergePartialHandleData(
|
|
|
109
116
|
if (isPushedByKey(name)) continue;
|
|
110
117
|
const byName = pushedByKey(name);
|
|
111
118
|
const buckets: Record<string, unknown[]> = (out[name] = {});
|
|
112
|
-
for (const segmentId of
|
|
113
|
-
if (!inOrder.has(segmentId)) continue;
|
|
119
|
+
for (const segmentId of segmentOrder) {
|
|
114
120
|
const run = (by: PushedBy, i: number): string => {
|
|
115
121
|
const key = `${segmentId}\u0000${by?.[i]}`;
|
|
116
122
|
return registered.has(key) ? key : segmentId;
|
|
117
123
|
};
|
|
118
|
-
|
|
124
|
+
let currentValues = current[name]?.[segmentId];
|
|
125
|
+
let currentBy = current[byName]?.[segmentId] as PushedBy;
|
|
126
|
+
if (!currentValues) {
|
|
127
|
+
// A loader's values from a segment the page leaves; one that ran for
|
|
128
|
+
// this response replaces them below, like any of its values. Never
|
|
129
|
+
// from a slot (".@"): an intercept's or a parallel's own copy of the
|
|
130
|
+
// loader pushed those.
|
|
131
|
+
const carried: unknown[] = (currentValues = []);
|
|
132
|
+
const carriedBy: (string | null)[] = [];
|
|
133
|
+
for (const from in current[byName]) {
|
|
134
|
+
if (inOrder.has(from) || from.includes(".@")) continue;
|
|
135
|
+
current[byName][from].forEach((_, i, runs) => {
|
|
136
|
+
const key = run(runs as PushedBy, i);
|
|
137
|
+
if (key === segmentId) return;
|
|
138
|
+
carried.push(current[name][from][i]);
|
|
139
|
+
carriedBy.push(runs[i] as string);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
currentBy = carriedBy;
|
|
143
|
+
}
|
|
119
144
|
const incomingValues = incoming[name]?.[segmentId] ?? [];
|
|
120
145
|
const incomingBy = incoming[byName]?.[segmentId] as PushedBy;
|
|
121
146
|
const delivered = new Set(
|
|
@@ -124,7 +149,7 @@ export function mergePartialHandleData(
|
|
|
124
149
|
const values: unknown[] = [];
|
|
125
150
|
const by: (string | null)[] = [];
|
|
126
151
|
let at = -1;
|
|
127
|
-
|
|
152
|
+
currentValues.forEach((value, i) => {
|
|
128
153
|
const key = run(currentBy, i);
|
|
129
154
|
if (delivered.has(key) || ran.has(key)) {
|
|
130
155
|
if (at < 0) at = values.length;
|
package/src/router/match-api.ts
CHANGED
|
@@ -54,6 +54,7 @@ import {
|
|
|
54
54
|
type RouteSnapshot,
|
|
55
55
|
} from "./route-snapshot.js";
|
|
56
56
|
import { resolveNavigation } from "./navigation-snapshot.js";
|
|
57
|
+
import { withLayoutLoaderCopies } from "../client-urls/layout-loader-copies.js";
|
|
57
58
|
import {
|
|
58
59
|
partialDeferralMode,
|
|
59
60
|
planPrefetchDeferral,
|
|
@@ -419,9 +420,16 @@ export async function createMatchContextForPartial<TEnv>(
|
|
|
419
420
|
)
|
|
420
421
|
: null);
|
|
421
422
|
|
|
423
|
+
// The page's copy of a clientUrls() layout() loader under the route it
|
|
424
|
+
// leaves holds the destination route's (client-urls/layout-loader-copies.ts).
|
|
425
|
+
const held = withLayoutLoaderCopies(
|
|
426
|
+
nav.clientSegmentSet,
|
|
427
|
+
snapshot.manifestEntry,
|
|
428
|
+
);
|
|
429
|
+
|
|
422
430
|
// Make a mutable copy of clientSegmentSet so we can delete entries
|
|
423
431
|
// for same-route navigation forcing
|
|
424
|
-
const clientSegmentSet = new Set(
|
|
432
|
+
const clientSegmentSet = new Set(held);
|
|
425
433
|
|
|
426
434
|
if (
|
|
427
435
|
nav.isSameRouteNavigation &&
|
|
@@ -481,9 +489,9 @@ export async function createMatchContextForPartial<TEnv>(
|
|
|
481
489
|
}),
|
|
482
490
|
matched,
|
|
483
491
|
cacheScope,
|
|
484
|
-
//
|
|
492
|
+
// What the page holds, not the copy forced above: see
|
|
485
493
|
// prefetch-deferral.ts.
|
|
486
|
-
|
|
494
|
+
held,
|
|
487
495
|
);
|
|
488
496
|
(handlerContext as InternalHandlerContext<any, TEnv>)._prefetchDeferral =
|
|
489
497
|
deferral;
|
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
buildLoaderErrorContext,
|
|
56
56
|
} from "./helpers.js";
|
|
57
57
|
import { applyViewTransitionDefault } from "./view-transition-default.js";
|
|
58
|
+
import { isLayoutLoaderCopy } from "../../client-urls/layout-loader-copies.js";
|
|
58
59
|
import { getRouterContext } from "../router-context.js";
|
|
59
60
|
import { observeEvent, observeSegmentHandler } from "../instrument.js";
|
|
60
61
|
import { runInSegmentTagScope } from "../../cache/cache-tag.js";
|
|
@@ -254,7 +255,9 @@ export async function resolveLoadersWithRevalidation<TEnv>(
|
|
|
254
255
|
component: null,
|
|
255
256
|
params: ctx.params,
|
|
256
257
|
loaderId: loader.$$id,
|
|
257
|
-
|
|
258
|
+
// A clientUrls() layout() loader's copy is projected onto the
|
|
259
|
+
// route but owned by a layout: it takes a layout's default.
|
|
260
|
+
belongsToRoute: belongsToRoute && !isLayoutLoaderCopy(loader),
|
|
258
261
|
};
|
|
259
262
|
|
|
260
263
|
return await revalidateHeld({
|
package/src/server/context.ts
CHANGED
|
@@ -445,6 +445,53 @@ function bumpCounter(store: HelperContext, key: string): number {
|
|
|
445
445
|
return store.counters[key]++;
|
|
446
446
|
}
|
|
447
447
|
|
|
448
|
+
type ShortCodeType = "layout" | "parallel" | "route" | "loader" | "cache";
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* The next shortCode of `type` under the store's parent, which `take`
|
|
452
|
+
* consumes; a peek may look `ahead` past it. A root entry is prefixed with the
|
|
453
|
+
* mount index and counted per mount. A child is counted per parent with
|
|
454
|
+
* includeScope appended: while a lazy include's direct children evaluate,
|
|
455
|
+
* includeScope is a per-include token like "I0" / "I1I0" that partitions the
|
|
456
|
+
* parent's counter namespace so routes inside one include cannot collide with
|
|
457
|
+
* siblings declared outside it.
|
|
458
|
+
*/
|
|
459
|
+
function nextShortCode(
|
|
460
|
+
store: HelperContext,
|
|
461
|
+
type: ShortCodeType,
|
|
462
|
+
take: boolean,
|
|
463
|
+
ahead = 0,
|
|
464
|
+
): string {
|
|
465
|
+
const parent = store.parent;
|
|
466
|
+
const prefix = SHORT_CODE_PREFIX[type];
|
|
467
|
+
const mountPrefix =
|
|
468
|
+
store.mountIndex !== undefined ? `M${store.mountIndex}` : "";
|
|
469
|
+
const includeScope = store.includeScope ?? "";
|
|
470
|
+
const counterKey = parent
|
|
471
|
+
? `${parent.shortCode}${includeScope}_${type}`
|
|
472
|
+
: mountPrefix
|
|
473
|
+
? `${mountPrefix}_root_${type}`
|
|
474
|
+
: `root_${type}`;
|
|
475
|
+
const index = take
|
|
476
|
+
? bumpCounter(store, counterKey)
|
|
477
|
+
: (store.counters[counterKey] ?? 0) + ahead;
|
|
478
|
+
return parent
|
|
479
|
+
? `${parent.shortCode}${includeScope}${prefix}${index}`
|
|
480
|
+
: `${mountPrefix}${prefix}${index}`;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* The shortCode the `ahead`-th next `getShortCode(type)` call in this DSL
|
|
485
|
+
* context returns, without taking it. clientUrls() materialization reads the
|
|
486
|
+
* code of every group route this way, including the routes an evaluation for
|
|
487
|
+
* another route skips (client-urls/layout-loader-copies.ts).
|
|
488
|
+
*/
|
|
489
|
+
export function peekShortCode(type: ShortCodeType, ahead: number): string {
|
|
490
|
+
const store = RangoContext.getStore();
|
|
491
|
+
invariant(store, "No context RangoContext available");
|
|
492
|
+
return nextShortCode(store, type, false, ahead);
|
|
493
|
+
}
|
|
494
|
+
|
|
448
495
|
export const getContext = (): {
|
|
449
496
|
context: AsyncLocalStorage<HelperContext>;
|
|
450
497
|
getStore: () => HelperContext;
|
|
@@ -522,29 +569,7 @@ export const getContext = (): {
|
|
|
522
569
|
) => {
|
|
523
570
|
const store = context.getStore();
|
|
524
571
|
invariant(store, "No context RangoContext available");
|
|
525
|
-
|
|
526
|
-
const parent = store.parent;
|
|
527
|
-
const prefix = SHORT_CODE_PREFIX[type];
|
|
528
|
-
const mountPrefix =
|
|
529
|
-
store.mountIndex !== undefined ? `M${store.mountIndex}` : "";
|
|
530
|
-
|
|
531
|
-
const includeScope = store.includeScope ?? "";
|
|
532
|
-
|
|
533
|
-
if (!parent) {
|
|
534
|
-
// Root entry: prefix with mount index and use mount-scoped counter
|
|
535
|
-
const counterKey = mountPrefix
|
|
536
|
-
? `${mountPrefix}_root_${type}`
|
|
537
|
-
: `root_${type}`;
|
|
538
|
-
return `${mountPrefix}${prefix}${bumpCounter(store, counterKey)}`;
|
|
539
|
-
} else {
|
|
540
|
-
// Child entry: use parent-scoped counter with includeScope appended.
|
|
541
|
-
// When we're evaluating a lazy include's direct children, includeScope
|
|
542
|
-
// is a per-include token like "I0" / "I1I0" that partitions the
|
|
543
|
-
// parent's counter namespace so routes inside one include cannot
|
|
544
|
-
// collide with siblings declared outside it.
|
|
545
|
-
const counterKey = `${parent.shortCode}${includeScope}_${type}`;
|
|
546
|
-
return `${parent.shortCode}${includeScope}${prefix}${bumpCounter(store, counterKey)}`;
|
|
547
|
-
}
|
|
572
|
+
return nextShortCode(store, type, true);
|
|
548
573
|
},
|
|
549
574
|
runWithStore: <T>(
|
|
550
575
|
store: HelperContext,
|
package/src/ssr/index.tsx
CHANGED
|
@@ -403,6 +403,62 @@ function trackDocumentClientLoads<T>(decode: () => T): {
|
|
|
403
403
|
};
|
|
404
404
|
}
|
|
405
405
|
|
|
406
|
+
/**
|
|
407
|
+
* Cap on settleSsrFalseFirstFlush's turns. Each turn retries one generation of
|
|
408
|
+
* tasks parked on thenables that settle in microtasks. A useActionState form
|
|
409
|
+
* is one generation (the Flight client caches the encoding, so the retry
|
|
410
|
+
* encodes synchronously); a formAction button inside it renders on that retry
|
|
411
|
+
* and is a second; the same form behind `lazy()` adds a third. ssr-handler
|
|
412
|
+
* tests pin all three. A deeper chain flushes its fallback as before.
|
|
413
|
+
*/
|
|
414
|
+
const SSR_FALSE_FIRST_FLUSH_TURNS = 3;
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* True when the matched chain has a `loader(Def, { ssr: false })` entry: its
|
|
418
|
+
* payload segments carry `awaitBeforeFlush` (fresh.ts).
|
|
419
|
+
*/
|
|
420
|
+
function hasAwaitBeforeFlush(payload: {
|
|
421
|
+
metadata?: { segments?: readonly { awaitBeforeFlush?: true }[] };
|
|
422
|
+
}): boolean {
|
|
423
|
+
return payload.metadata?.segments?.some((s) => s.awaitBeforeFlush) === true;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* ssr: false documents only: before the first flush, give Fizz the turns a
|
|
428
|
+
* task waiting on an already-settling thenable needs, so a boundary that
|
|
429
|
+
* waits on nothing else completes in place (#1138).
|
|
430
|
+
*
|
|
431
|
+
* Fizz's useActionState binds its state onto the action, and the
|
|
432
|
+
* `<form action>` encode of that bound server reference throws the Flight
|
|
433
|
+
* client's encodeFormData thenable once (defaultEncodeFormAction). It settles
|
|
434
|
+
* in microtasks, but once the request has left its opening status, Fizz's
|
|
435
|
+
* pingTask retries the task on a setTimeout(0) turn. renderToReadableStream
|
|
436
|
+
* resolves at shell-ready, so the first read would flush the boundary's
|
|
437
|
+
* fallback before that turn, which a browser without JavaScript keeps (the
|
|
438
|
+
* form lands in `<div hidden id="S:n">`).
|
|
439
|
+
*
|
|
440
|
+
* A document already whole (allReady settled) returns with no macrotask.
|
|
441
|
+
* Otherwise one setTimeout(0) at a time until allReady, at most
|
|
442
|
+
* SSR_FALSE_FIRST_FLUSH_TURNS: a timer set after Fizz's ping fires after it.
|
|
443
|
+
* A boundary waiting on I/O (an unflagged loader, a streamed promise) still
|
|
444
|
+
* streams its fallback, after at most that many turns.
|
|
445
|
+
*/
|
|
446
|
+
async function settleSsrFalseFirstFlush(
|
|
447
|
+
allReady: Promise<void>,
|
|
448
|
+
): Promise<void> {
|
|
449
|
+
let whole = false;
|
|
450
|
+
const settle = (): void => {
|
|
451
|
+
whole = true;
|
|
452
|
+
};
|
|
453
|
+
// Promise.resolve: a stub renderer's stream may carry no allReady.
|
|
454
|
+
Promise.resolve(allReady).then(settle, settle);
|
|
455
|
+
// A settled allReady's reaction was queued before this continuation's.
|
|
456
|
+
await Promise.resolve();
|
|
457
|
+
for (let turn = 0; !whole && turn < SSR_FALSE_FIRST_FLUSH_TURNS; turn++) {
|
|
458
|
+
await macrotask();
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
406
462
|
/**
|
|
407
463
|
* Route an SSR error through the deps.onError notification callback with the
|
|
408
464
|
* "rendering" phase. Swallows callback failures so a broken reporter never
|
|
@@ -832,13 +888,10 @@ export function createSSRHandler<TEnv = unknown>(deps: SSRDependencies<TEnv>) {
|
|
|
832
888
|
// Awaiting the payload here is latency-neutral: fizz cannot emit even
|
|
833
889
|
// <html> until this same promise settles. A rejection lands in the
|
|
834
890
|
// catch below — the same reportRenderError path a shell error takes.
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
progressiveChunkSize = Number.MAX_SAFE_INTEGER;
|
|
840
|
-
}
|
|
841
|
-
}
|
|
891
|
+
const awaitsBeforeFlush = hasAwaitBeforeFlush(await payload);
|
|
892
|
+
const progressiveChunkSize =
|
|
893
|
+
deps.progressiveChunkSize ??
|
|
894
|
+
(awaitsBeforeFlush ? Number.MAX_SAFE_INTEGER : undefined);
|
|
842
895
|
|
|
843
896
|
// Render React tree to HTML stream
|
|
844
897
|
// Pass formState for useActionState progressive enhancement if provided
|
|
@@ -870,6 +923,9 @@ export function createSSRHandler<TEnv = unknown>(deps: SSRDependencies<TEnv>) {
|
|
|
870
923
|
// cannot process streamed HTML.
|
|
871
924
|
if (streamMode === "allReady") {
|
|
872
925
|
await htmlStream.allReady;
|
|
926
|
+
} else if (awaitsBeforeFlush) {
|
|
927
|
+
// The shell rendered from the payload, so it has settled.
|
|
928
|
+
await settleSsrFalseFirstFlush(htmlStream.allReady);
|
|
873
929
|
}
|
|
874
930
|
|
|
875
931
|
// The entry's Fizz hint at default priority, after the head chunk
|