@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.
@@ -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;
@@ -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.2",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.24.2",
3
+ "version": "0.24.3",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -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
- only a segment new to the page is deferred, so a same-route click
481
- (`/product/a` to `/product/b`) prefetches the loader as usual.
482
-
483
- - On a `layout()` of the group the flag applies to every route below it.
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); only the destination's loaders and those of layouts it mounts
533
- anew wait for the server. `transition()` is not needed for the
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.
@@ -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 route the browser does not show yet. A loader the page already
1132
- holds, on a same-route navigation such as `/product/a` to `/product/b`, runs
1133
- in the prefetch when it revalidates, exactly as without the flag, and the
1134
- click sends no second request. The rule: a click that adopts a prefetch is
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
- const fromCache = cachedSegments.get(segId);
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
- projection.routes.map(
618
- (route) =>
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();
@@ -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 keysOf(current[name], incoming[name])) {
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
- const currentBy = current[byName]?.[segmentId] as PushedBy;
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
- (current[name]?.[segmentId] ?? []).forEach((value, i) => {
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;
@@ -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(nav.clientSegmentSet);
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
- // As the request listed them, not the copy forced above: see
492
+ // What the page holds, not the copy forced above: see
485
493
  // prefetch-deferral.ts.
486
- nav.clientSegmentSet,
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
- belongsToRoute,
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({
@@ -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
- let progressiveChunkSize = deps.progressiveChunkSize;
836
- if (progressiveChunkSize === undefined) {
837
- const resolved = await payload;
838
- if (resolved.metadata?.segments?.some((s) => s.awaitBeforeFlush)) {
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