@rangojs/router 0.24.1 → 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;
@@ -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
  }
@@ -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.1",
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.1",
3
+ "version": "0.24.2",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -527,7 +527,10 @@ a hard load of the target URL renders the full route.
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
529
  `loading()` does not re-flash there. Across different routes, a `layout()`
530
- keeps its instance while the navigation stays under it. `transition()` is not needed for the
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
531
534
  hold; it adds the view-transition animation. Inline `<Suspense>` above each
532
535
  `useLoader` read is still the finer-grained tool when different reads on
533
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
@@ -206,10 +210,11 @@ export function ClientUrlsRoot({
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
  });
@@ -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 })
@@ -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 {
@@ -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
  }