@rangojs/router 0.24.1 → 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/navigation.d.ts +8 -1
- package/dist/types/client-urls/server-projection.d.ts +5 -0
- package/dist/types/client-urls/types.d.ts +6 -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 +10 -4
- package/package.json +1 -1
- package/skills/client-urls/SKILL.md +32 -6
- package/skills/loader/SKILL.md +7 -4
- package/src/browser/segment-reconciler.ts +15 -1
- package/src/client-urls/client-root.tsx +47 -42
- package/src/client-urls/client-urls.ts +4 -0
- package/src/client-urls/layout-loader-copies.ts +56 -0
- package/src/client-urls/navigation.ts +24 -16
- package/src/client-urls/server-projection.ts +56 -6
- package/src/client-urls/types.ts +6 -0
- 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>;
|
|
@@ -2,6 +2,13 @@ import type { ClientUrlPatterns } from "./types.js";
|
|
|
2
2
|
import type { TransitionWhenFn } from "../types/segments.js";
|
|
3
3
|
export interface ClientUrlNavigationIntent {
|
|
4
4
|
readonly routeId: string;
|
|
5
|
+
/**
|
|
6
|
+
* The navigation began while an optimistic frame was on screen (a
|
|
7
|
+
* superseded navigation's): ClientUrlsRoot presents it even when its route
|
|
8
|
+
* is the committed one, so the committed params' data never comes back on
|
|
9
|
+
* screen.
|
|
10
|
+
*/
|
|
11
|
+
readonly fromFrame: boolean;
|
|
5
12
|
/** Destination params from the local trie match (definition-local). */
|
|
6
13
|
readonly params: Readonly<Record<string, string>>;
|
|
7
14
|
/** Absolute destination pathname (mount included) and search ("?..." or ""). */
|
|
@@ -39,7 +46,7 @@ export interface ClientUrlNavigationPresentation {
|
|
|
39
46
|
* gated off), or undefined when nothing was swapped (a same-route
|
|
40
47
|
* navigation decides at the canonical commit).
|
|
41
48
|
*/
|
|
42
|
-
readonly transitionGatedOff
|
|
49
|
+
readonly transitionGatedOff?: boolean;
|
|
43
50
|
clear(): void;
|
|
44
51
|
}
|
|
45
52
|
export declare function registerClientUrlGroup(definition: ClientUrlPatterns, mount: string, namePrefix: string, setIntent: (intent: ClientUrlNavigationIntent | null) => void, routeId?: string): () => void;
|
|
@@ -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>;
|
|
@@ -67,6 +67,12 @@ export interface ClientUrlRouteRecord {
|
|
|
67
67
|
*/
|
|
68
68
|
readonly layoutKeys: readonly number[];
|
|
69
69
|
readonly loaders: readonly ClientUrlLoaderRecord[];
|
|
70
|
+
/**
|
|
71
|
+
* The leading `loaders` its layout()s declare, the same record objects for
|
|
72
|
+
* every route under a layout (flattenLayout creates them once): ClientUrlsRoot
|
|
73
|
+
* keeps a shared one's committed data in the optimistic frame.
|
|
74
|
+
*/
|
|
75
|
+
readonly layoutLoaders: readonly ClientUrlLoaderRecord[];
|
|
70
76
|
readonly loading: ReactNode | undefined;
|
|
71
77
|
readonly transition: Readonly<ClientTransitionConfig> | undefined;
|
|
72
78
|
}
|
|
@@ -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",
|
|
@@ -10900,7 +10900,7 @@ import {
|
|
|
10900
10900
|
Fragment as Fragment4,
|
|
10901
10901
|
Suspense as Suspense3,
|
|
10902
10902
|
useDeferredValue,
|
|
10903
|
-
|
|
10903
|
+
useLayoutEffect as useLayoutEffect3,
|
|
10904
10904
|
useMemo as useMemo10,
|
|
10905
10905
|
useState as useState14
|
|
10906
10906
|
} from "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()`
|
|
@@ -527,7 +549,11 @@ a hard load of the target URL renders the full route.
|
|
|
527
549
|
`startTransition` when no new segments mount, and the wrapping layout sees
|
|
528
550
|
`useOutlet().pending === true` for the held window; a route-level
|
|
529
551
|
`loading()` does not re-flash there. Across different routes, a `layout()`
|
|
530
|
-
keeps its instance while the navigation stays under it
|
|
552
|
+
keeps its instance while the navigation stays under it, and its own
|
|
553
|
+
`layout()` loaders keep their data on the optimistic frame (a cart count does
|
|
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
|
|
531
557
|
hold; it adds the view-transition animation. Inline `<Suspense>` above each
|
|
532
558
|
`useLoader` read is still the finer-grained tool when different reads on
|
|
533
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!`);
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
Fragment,
|
|
6
6
|
Suspense,
|
|
7
7
|
useDeferredValue,
|
|
8
|
-
|
|
8
|
+
useLayoutEffect,
|
|
9
9
|
useMemo,
|
|
10
10
|
useState,
|
|
11
11
|
type ReactNode,
|
|
@@ -17,10 +17,7 @@ import {
|
|
|
17
17
|
registerClientUrlGroup,
|
|
18
18
|
type ClientUrlNavigationIntent,
|
|
19
19
|
} from "./navigation.js";
|
|
20
|
-
import {
|
|
21
|
-
OptimisticLocationContext,
|
|
22
|
-
type OptimisticLocation,
|
|
23
|
-
} from "./optimistic-location.js";
|
|
20
|
+
import { OptimisticLocationContext } from "./optimistic-location.js";
|
|
24
21
|
import type {
|
|
25
22
|
ClientUrlInterceptRecord,
|
|
26
23
|
ClientUrlPatterns,
|
|
@@ -124,11 +121,6 @@ export function ClientUrlsRoot({
|
|
|
124
121
|
// same way client href()/useMount resolve include-relative URLs.
|
|
125
122
|
const mount = useMount();
|
|
126
123
|
const [intent, setIntent] = useState<ClientUrlNavigationIntent | null>(null);
|
|
127
|
-
useEffect(
|
|
128
|
-
() =>
|
|
129
|
-
registerClientUrlGroup(definition, mount, namePrefix, setIntent, routeId),
|
|
130
|
-
[definition, mount, namePrefix, routeId],
|
|
131
|
-
);
|
|
132
124
|
|
|
133
125
|
// Optimistic destination (design: docs/design/client-urls-optimistic-destination.md).
|
|
134
126
|
// `intent` is set urgently at navigation start so `pending` flips at once
|
|
@@ -137,46 +129,58 @@ export function ClientUrlsRoot({
|
|
|
137
129
|
// own keeps the previous content visible (React's transition hold — the
|
|
138
130
|
// pre-existing contract for routes without loading()), one with loading()
|
|
139
131
|
// or inline <Suspense> at its reads presents immediately. Same-route intents
|
|
140
|
-
// never swap: held data + transition() own that
|
|
141
|
-
//
|
|
142
|
-
//
|
|
132
|
+
// from the committed page never swap: held data + transition() own that
|
|
133
|
+
// case; one that began on an optimistic frame does (fromFrame). A
|
|
134
|
+
// navigation its transition({ when }) gated off at the swap presents
|
|
135
|
+
// urgently: no hold, no view transition, the destination's loading()
|
|
136
|
+
// streams.
|
|
143
137
|
const pending = intent !== null;
|
|
144
138
|
const deferred = useDeferredValue(intent);
|
|
145
139
|
const presented = intent?.transitionGatedOff ? intent : deferred;
|
|
146
140
|
const optimisticRoute =
|
|
147
|
-
presented && presented.routeId !== routeId
|
|
141
|
+
presented && (presented.routeId !== routeId || presented.fromFrame)
|
|
148
142
|
? findRoute(definition, presented.routeId)
|
|
149
143
|
: null;
|
|
150
144
|
const route = optimisticRoute ?? findRoute(definition, routeId);
|
|
151
145
|
|
|
146
|
+
// At commit, so a navigation reads what is on screen (no routeId while a
|
|
147
|
+
// frame is): React renders transition lanes one at a time, so the render
|
|
148
|
+
// that presents the next intent may still lack the canonical commit of the
|
|
149
|
+
// one it supersedes.
|
|
150
|
+
useLayoutEffect(
|
|
151
|
+
() =>
|
|
152
|
+
registerClientUrlGroup(
|
|
153
|
+
definition,
|
|
154
|
+
mount,
|
|
155
|
+
namePrefix,
|
|
156
|
+
setIntent,
|
|
157
|
+
optimisticRoute ? undefined : routeId,
|
|
158
|
+
),
|
|
159
|
+
[definition, mount, namePrefix, routeId, optimisticRoute],
|
|
160
|
+
);
|
|
161
|
+
|
|
152
162
|
// Pending entries for the destination's loaders: useLoader use()s a Promise
|
|
153
|
-
// found in `loaderStreams
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
location: OptimisticLocation;
|
|
161
|
-
} | null>(
|
|
163
|
+
// found in `loaderStreams`, so a read suspends instead of throwing "not
|
|
164
|
+
// found in context"; the canonical commit brings the real data. Skips the
|
|
165
|
+
// committed route's layout() records: flattenLayout creates them once per
|
|
166
|
+
// layout, so one the destination holds too is a layout both keep mounted
|
|
167
|
+
// (#1124), reading committed data. Route-own loaders always wait, also on
|
|
168
|
+
// a return to the committed route (fromFrame).
|
|
169
|
+
const loaderStreams = useMemo(
|
|
162
170
|
() =>
|
|
163
|
-
optimisticRoute
|
|
164
|
-
?
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
record
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
},
|
|
177
|
-
}
|
|
178
|
-
: null,
|
|
179
|
-
[optimisticRoute, presented],
|
|
171
|
+
optimisticRoute
|
|
172
|
+
? Object.fromEntries(
|
|
173
|
+
optimisticRoute.loaders
|
|
174
|
+
.filter(
|
|
175
|
+
(record) =>
|
|
176
|
+
!findRoute(definition, routeId).layoutLoaders.includes(
|
|
177
|
+
record,
|
|
178
|
+
),
|
|
179
|
+
)
|
|
180
|
+
.map((record) => [record.loader.$$id, PENDING_FOREVER]),
|
|
181
|
+
)
|
|
182
|
+
: undefined,
|
|
183
|
+
[optimisticRoute, definition, routeId],
|
|
180
184
|
);
|
|
181
185
|
|
|
182
186
|
// The wrapper chain below is IDENTICAL in the optimistic and the canonical
|
|
@@ -206,10 +210,11 @@ export function ClientUrlsRoot({
|
|
|
206
210
|
|
|
207
211
|
return createElement(OutletProvider, {
|
|
208
212
|
content: null,
|
|
209
|
-
loaderStreams
|
|
213
|
+
loaderStreams,
|
|
210
214
|
pending,
|
|
211
215
|
children: createElement(OptimisticLocationContext.Provider, {
|
|
212
|
-
|
|
216
|
+
// The intent carries every OptimisticLocation field.
|
|
217
|
+
value: optimisticRoute ? presented : null,
|
|
213
218
|
children: content,
|
|
214
219
|
}),
|
|
215
220
|
});
|
|
@@ -566,6 +566,9 @@ function createHelpers(): ClientUrlHelpers {
|
|
|
566
566
|
});
|
|
567
567
|
}
|
|
568
568
|
|
|
569
|
+
// A layout()'s loader records are created once here and shared by every
|
|
570
|
+
// route under it (ClientUrlRouteRecord.layoutLoaders); ClientUrlsRoot relies
|
|
571
|
+
// on that identity (client-root.tsx).
|
|
569
572
|
function applyConfig(
|
|
570
573
|
context: FlattenContext,
|
|
571
574
|
items: readonly InternalItem[],
|
|
@@ -619,6 +622,7 @@ function addPathRecord(
|
|
|
619
622
|
layouts: Object.freeze([...routeContext.layouts]),
|
|
620
623
|
layoutKeys: Object.freeze([...routeContext.layoutKeys]),
|
|
621
624
|
loaders: Object.freeze([...routeContext.loaders]),
|
|
625
|
+
layoutLoaders: context.loaders,
|
|
622
626
|
loading: routeContext.loading,
|
|
623
627
|
transition: transitionItem
|
|
624
628
|
? Object.freeze({ ...transitionItem.config })
|
|
@@ -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
|
+
}
|
|
@@ -6,6 +6,13 @@ import type { TransitionWhenFn } from "../types/segments.js";
|
|
|
6
6
|
|
|
7
7
|
export interface ClientUrlNavigationIntent {
|
|
8
8
|
readonly routeId: string;
|
|
9
|
+
/**
|
|
10
|
+
* The navigation began while an optimistic frame was on screen (a
|
|
11
|
+
* superseded navigation's): ClientUrlsRoot presents it even when its route
|
|
12
|
+
* is the committed one, so the committed params' data never comes back on
|
|
13
|
+
* screen.
|
|
14
|
+
*/
|
|
15
|
+
readonly fromFrame: boolean;
|
|
9
16
|
/** Destination params from the local trie match (definition-local). */
|
|
10
17
|
readonly params: Readonly<Record<string, string>>;
|
|
11
18
|
/** Absolute destination pathname (mount included) and search ("?..." or ""). */
|
|
@@ -43,10 +50,13 @@ interface ActiveClientUrlGroup {
|
|
|
43
50
|
readonly mount: string;
|
|
44
51
|
/** include() route-name prefix ("" at root) for canonical name composition. */
|
|
45
52
|
readonly namePrefix: string;
|
|
46
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* The group route on screen (ClientUrlsRoot's committed routeId), undefined
|
|
55
|
+
* while an optimistic frame is. Registered at commit.
|
|
56
|
+
*/
|
|
47
57
|
readonly routeId: string | undefined;
|
|
48
58
|
readonly setIntent: (intent: ClientUrlNavigationIntent | null) => void;
|
|
49
|
-
intent
|
|
59
|
+
intent?: ClientUrlNavigationIntent | null;
|
|
50
60
|
}
|
|
51
61
|
|
|
52
62
|
/**
|
|
@@ -74,7 +84,7 @@ export interface ClientUrlNavigationPresentation {
|
|
|
74
84
|
* gated off), or undefined when nothing was swapped (a same-route
|
|
75
85
|
* navigation decides at the canonical commit).
|
|
76
86
|
*/
|
|
77
|
-
readonly transitionGatedOff
|
|
87
|
+
readonly transitionGatedOff?: boolean;
|
|
78
88
|
clear(): void;
|
|
79
89
|
}
|
|
80
90
|
|
|
@@ -110,7 +120,6 @@ export function registerClientUrlGroup(
|
|
|
110
120
|
namePrefix,
|
|
111
121
|
routeId,
|
|
112
122
|
setIntent,
|
|
113
|
-
intent: null,
|
|
114
123
|
};
|
|
115
124
|
activeGroup = group;
|
|
116
125
|
|
|
@@ -151,9 +160,10 @@ export function beginClientUrlNavigation(
|
|
|
151
160
|
|
|
152
161
|
const state = buildState?.();
|
|
153
162
|
|
|
154
|
-
// A same-route intent never swaps (ClientUrlsRoot),
|
|
155
|
-
// decides at the canonical commit;
|
|
156
|
-
// navigation's first presentation.
|
|
163
|
+
// A same-route intent from the committed page never swaps (ClientUrlsRoot),
|
|
164
|
+
// so transition({ when }) decides at the canonical commit; one that swaps
|
|
165
|
+
// decides here, at the navigation's first presentation. While a frame is on
|
|
166
|
+
// screen, group.routeId is undefined and every intent swaps.
|
|
157
167
|
let transitionGatedOff: boolean | undefined;
|
|
158
168
|
if (decideTransition && match.routeKey !== group.routeId) {
|
|
159
169
|
const originLocal = stripMountPrefix(window.location.pathname, group.mount);
|
|
@@ -171,6 +181,10 @@ export function beginClientUrlNavigation(
|
|
|
171
181
|
|
|
172
182
|
const intent: ClientUrlNavigationIntent = {
|
|
173
183
|
routeId: match.routeKey,
|
|
184
|
+
// From what React committed, not group.intent: a superseded intent is
|
|
185
|
+
// cleared when its canonical commit is handed over, before React renders
|
|
186
|
+
// it, and its frame is still on screen until then.
|
|
187
|
+
fromFrame: !group.routeId,
|
|
174
188
|
params: match.params,
|
|
175
189
|
pathname: targetUrl.pathname,
|
|
176
190
|
search: targetUrl.search,
|
|
@@ -192,16 +206,10 @@ export function beginClientUrlNavigation(
|
|
|
192
206
|
// updates land in order, so this changes nothing there.
|
|
193
207
|
startTransition(() => group.setIntent(null));
|
|
194
208
|
};
|
|
195
|
-
|
|
209
|
+
// An abort after clear() finds another intent or none and does nothing.
|
|
210
|
+
signal.addEventListener("abort", clear);
|
|
196
211
|
|
|
197
|
-
return {
|
|
198
|
-
routeId: match.routeKey,
|
|
199
|
-
transitionGatedOff,
|
|
200
|
-
clear() {
|
|
201
|
-
signal.removeEventListener("abort", clear);
|
|
202
|
-
clear();
|
|
203
|
-
},
|
|
204
|
-
};
|
|
212
|
+
return { ...intent, clear };
|
|
205
213
|
}
|
|
206
214
|
|
|
207
215
|
export function clearClientUrlNavigationRegistry(): void {
|
|
@@ -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/client-urls/types.ts
CHANGED
|
@@ -114,6 +114,12 @@ export interface ClientUrlRouteRecord {
|
|
|
114
114
|
*/
|
|
115
115
|
readonly layoutKeys: readonly number[];
|
|
116
116
|
readonly loaders: readonly ClientUrlLoaderRecord[];
|
|
117
|
+
/**
|
|
118
|
+
* The leading `loaders` its layout()s declare, the same record objects for
|
|
119
|
+
* every route under a layout (flattenLayout creates them once): ClientUrlsRoot
|
|
120
|
+
* keeps a shared one's committed data in the optimistic frame.
|
|
121
|
+
*/
|
|
122
|
+
readonly layoutLoaders: readonly ClientUrlLoaderRecord[];
|
|
117
123
|
readonly loading: ReactNode | undefined;
|
|
118
124
|
readonly transition: Readonly<ClientTransitionConfig> | undefined;
|
|
119
125
|
}
|
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
|