@rangojs/router 0.24.0 → 0.24.2

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.
@@ -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: boolean | undefined;
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;
@@ -58,7 +58,21 @@ export interface ClientUrlRouteRecord {
58
58
  readonly options: Readonly<ClientPathOptions> | undefined;
59
59
  readonly component: ComponentType;
60
60
  readonly layouts: readonly ComponentType[];
61
+ /**
62
+ * One per entry in `layouts`: the layout() item's ordinal among its sibling
63
+ * layout()s, its React key. Routes under the same layout() share it, so
64
+ * ClientUrlsRoot keeps that layout's instance across them (#1124). Keys only
65
+ * need to be unique among siblings: a differing parent layout remounts the
66
+ * whole subtree anyway.
67
+ */
68
+ readonly layoutKeys: readonly number[];
61
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[];
62
76
  readonly loading: ReactNode | undefined;
63
77
  readonly transition: Readonly<ClientTransitionConfig> | undefined;
64
78
  }
@@ -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.0",
5932
+ version: "0.24.2",
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
- useEffect as useEffect17,
10903
+ useLayoutEffect as useLayoutEffect3,
10904
10904
  useMemo as useMemo10,
10905
10905
  useState as useState14
10906
10906
  } from "react";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.24.0",
3
+ "version": "0.24.2",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -526,7 +526,11 @@ a hard load of the target URL renders the full route.
526
526
  reconciles instead of remounting. The canonical commit runs in a
527
527
  `startTransition` when no new segments mount, and the wrapping layout sees
528
528
  `useOutlet().pending === true` for the held window; a route-level
529
- `loading()` does not re-flash there. `transition()` is not needed for the
529
+ `loading()` does not re-flash there. Across different routes, a `layout()`
530
+ keeps its instance while the navigation stays under it, and its own
531
+ `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
530
534
  hold; it adds the view-transition animation. Inline `<Suspense>` above each
531
535
  `useLoader` read is still the finer-grained tool when different reads on
532
536
  one route should wait independently.
@@ -5,7 +5,7 @@ import {
5
5
  Fragment,
6
6
  Suspense,
7
7
  useDeferredValue,
8
- useEffect,
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 case. A navigation its
141
- // transition({ when }) gated off at the swap presents urgently: no hold,
142
- // no view transition, the destination's loading() streams.
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` (the streaming-loader lane), so a read suspends
154
- // instead of throwing "not found in context". Nothing resolves them — the
155
- // canonical commit mounts the destination's own segment with real data and
156
- // unmounts this branch — so one shared promise serves every loader.
157
- // Memoized on the intent: use() needs a stable identity across replays.
158
- const optimistic = useMemo<{
159
- streams: Record<string, Promise<never>>;
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 && presented
164
- ? {
165
- streams: Object.fromEntries(
166
- optimisticRoute.loaders.map((record) => [
167
- record.loader.$$id,
168
- PENDING_FOREVER,
169
- ]),
170
- ),
171
- location: {
172
- params: presented.params,
173
- pathname: presented.pathname,
174
- search: presented.search,
175
- state: presented.state,
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
@@ -194,22 +198,23 @@ export function ClientUrlsRoot({
194
198
  });
195
199
  }
196
200
 
201
+ // Keyed per layout() item, not per route (#1124).
197
202
  for (let index = route.layouts.length - 1; index >= 0; index--) {
198
- const layoutKey = `${route.id}-layout-${index}`;
199
203
  content = createElement(OutletProvider, {
200
- key: layoutKey,
204
+ key: route.layoutKeys[index],
201
205
  content,
202
206
  pending,
203
- children: createElement(route.layouts[index], { key: layoutKey }),
207
+ children: createElement(route.layouts[index]),
204
208
  });
205
209
  }
206
210
 
207
211
  return createElement(OutletProvider, {
208
212
  content: null,
209
- loaderStreams: optimistic?.streams,
213
+ loaderStreams,
210
214
  pending,
211
215
  children: createElement(OptimisticLocationContext.Provider, {
212
- value: optimistic?.location ?? null,
216
+ // The intent carries every OptimisticLocation field.
217
+ value: optimisticRoute ? presented : null,
213
218
  children: content,
214
219
  }),
215
220
  });
@@ -154,12 +154,14 @@ interface LoadingItem extends ItemBase {
154
154
 
155
155
  interface FlattenContext {
156
156
  readonly layouts: readonly ComponentType[];
157
+ readonly layoutKeys: readonly number[];
157
158
  readonly loaders: readonly ClientUrlLoaderRecord[];
158
159
  readonly loading: ReactNode | undefined;
159
160
  }
160
161
 
161
162
  const EMPTY_CONTEXT: FlattenContext = {
162
163
  layouts: [],
164
+ layoutKeys: [],
163
165
  loaders: [],
164
166
  loading: undefined,
165
167
  };
@@ -564,6 +566,9 @@ function createHelpers(): ClientUrlHelpers {
564
566
  });
565
567
  }
566
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).
567
572
  function applyConfig(
568
573
  context: FlattenContext,
569
574
  items: readonly InternalItem[],
@@ -588,6 +593,7 @@ function applyConfig(
588
593
 
589
594
  return {
590
595
  layouts: context.layouts,
596
+ layoutKeys: context.layoutKeys,
591
597
  loaders,
592
598
  loading,
593
599
  };
@@ -614,7 +620,9 @@ function addPathRecord(
614
620
  options,
615
621
  component: item.component,
616
622
  layouts: Object.freeze([...routeContext.layouts]),
623
+ layoutKeys: Object.freeze([...routeContext.layoutKeys]),
617
624
  loaders: Object.freeze([...routeContext.loaders]),
625
+ layoutLoaders: context.loaders,
618
626
  loading: routeContext.loading,
619
627
  transition: transitionItem
620
628
  ? Object.freeze({ ...transitionItem.config })
@@ -625,6 +633,7 @@ function addPathRecord(
625
633
 
626
634
  function flattenLayout(
627
635
  item: LayoutItem,
636
+ ordinal: number,
628
637
  context: FlattenContext,
629
638
  routes: ClientUrlRouteRecord[],
630
639
  ): void {
@@ -633,6 +642,7 @@ function flattenLayout(
633
642
  {
634
643
  ...context,
635
644
  layouts: [...context.layouts, item.component],
645
+ layoutKeys: [...context.layoutKeys, ordinal],
636
646
  },
637
647
  item.children,
638
648
  );
@@ -650,11 +660,12 @@ function flattenRoutes(
650
660
  context: FlattenContext,
651
661
  routes: ClientUrlRouteRecord[],
652
662
  ): void {
663
+ let layoutOrdinal = 0;
653
664
  for (const item of items) {
654
665
  if (item.type === "path") {
655
666
  addPathRecord(item, context, routes);
656
667
  } else if (item.type === "layout") {
657
- flattenLayout(item, context, routes);
668
+ flattenLayout(item, layoutOrdinal++, context, routes);
658
669
  }
659
670
  }
660
671
  }
@@ -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
- /** The group route on screen (ClientUrlsRoot's committed routeId). */
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: ClientUrlNavigationIntent | null;
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: boolean | undefined;
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), so transition({ when })
155
- // decides at the canonical commit; a cross-route one decides here, at the
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
- signal.addEventListener("abort", clear, { once: true });
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 {
@@ -105,7 +105,21 @@ export interface ClientUrlRouteRecord {
105
105
  readonly options: Readonly<ClientPathOptions> | undefined;
106
106
  readonly component: ComponentType;
107
107
  readonly layouts: readonly ComponentType[];
108
+ /**
109
+ * One per entry in `layouts`: the layout() item's ordinal among its sibling
110
+ * layout()s, its React key. Routes under the same layout() share it, so
111
+ * ClientUrlsRoot keeps that layout's instance across them (#1124). Keys only
112
+ * need to be unique among siblings: a differing parent layout remounts the
113
+ * whole subtree anyway.
114
+ */
115
+ readonly layoutKeys: readonly number[];
108
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[];
109
123
  readonly loading: ReactNode | undefined;
110
124
  readonly transition: Readonly<ClientTransitionConfig> | undefined;
111
125
  }