@uniflowed/router 0.0.0-alpha.4 → 0.0.0-alpha.41

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.
Files changed (46) hide show
  1. package/action.js +301 -0
  2. package/client.js +295 -8
  3. package/handler.js +127 -106
  4. package/http-client.js +104 -0
  5. package/index.js +72 -4
  6. package/internal/action-endpoint.js +435 -0
  7. package/internal/action-wire.js +608 -0
  8. package/internal/base-path.js +175 -0
  9. package/internal/boundaries.js +481 -0
  10. package/internal/boundary-data.js +88 -0
  11. package/internal/compose.js +490 -0
  12. package/internal/devtools.js +131 -0
  13. package/internal/diagnostics.js +169 -0
  14. package/internal/error-view.js +193 -0
  15. package/internal/flight-browser.js +238 -0
  16. package/internal/flight-chunks.js +205 -0
  17. package/internal/flight-ssr.js +78 -0
  18. package/internal/flight.js +183 -0
  19. package/internal/head.js +219 -0
  20. package/internal/hydration.js +1085 -0
  21. package/internal/inspector.js +626 -0
  22. package/internal/native-links.js +67 -0
  23. package/internal/native-tree.js +89 -0
  24. package/internal/navigation-cache.js +181 -0
  25. package/internal/payload-rows.js +270 -0
  26. package/internal/payload.js +685 -0
  27. package/internal/prepare-document.js +49 -0
  28. package/internal/react-version.js +77 -0
  29. package/internal/request.js +43 -0
  30. package/internal/resolve.js +1613 -0
  31. package/internal/resolved-summary.js +199 -0
  32. package/internal/routing.js +478 -0
  33. package/internal/runtime.js +1613 -548
  34. package/internal/server-route.js +58 -0
  35. package/internal/shell.js +115 -0
  36. package/internal/stream.js +1099 -0
  37. package/middleware.js +350 -0
  38. package/native-navigation.js +217 -0
  39. package/native.js +416 -0
  40. package/package.json +48 -7
  41. package/routing.js +51 -0
  42. package/rsc-client.js +120 -0
  43. package/rsc-ssr.js +446 -0
  44. package/rsc.js +381 -0
  45. package/server-components.js +159 -0
  46. package/server.js +450 -75
@@ -1,430 +1,315 @@
1
1
  // @flow
2
2
  //
3
- // The router runtime: matching, loading, navigation, and the React binding.
3
+ // The router runtime: the browser's binding.
4
+ //
5
+ // A client module, and the directive is load-bearing rather than descriptive:
6
+ // in the module graph React Server Components render in, every export of this
7
+ // file is a client reference — `Link` renders as markup on the server and runs
8
+ // in the browser — and `../server-components.js` is what that graph gets for
9
+ // the hooks instead. Everywhere else the directive changes nothing.
4
10
  //
5
11
  // A route table is data — the virtual module `virtual:uf/routes` that
6
12
  // `@uniflowed/vite` generates from the `app/` directory — and this module is
7
- // everything that turns it into a running application. The same code runs on
8
- // the server (`./server.js` renders one URL) and in the browser (`./client.js`
9
- // hydrates it and then navigates), so a page's loader, layouts and metadata
10
- // resolve identically in both places.
13
+ // what turns it into a running application in a page: the provider that holds
14
+ // the current route, the hooks that read it, navigation, view transitions and
15
+ // `Link`. What a URL resolves to is `./resolve.js`, the tree a resolved route
16
+ // renders is `./compose.js`, and the metadata elements are `./head.js`; the
17
+ // three are split out because none of them may reach a hook, a context or a
18
+ // class component, which is what lets a server graph resolved under React's
19
+ // `react-server` condition import them (ubugeeei-prod/uf#519).
20
+
21
+ "use client";
11
22
 
12
- import * as React from "react";
23
+ import type * as React from "react";
13
24
  import {
25
+ Suspense,
14
26
  createContext,
15
27
  startTransition,
16
- useCallback,
28
+ use,
17
29
  useContext,
18
30
  useEffect,
19
- useMemo,
31
+ useRef,
20
32
  useState,
21
33
  useSyncExternalStore,
34
+ useTransition,
22
35
  } from "react";
23
-
24
- /** One parameter a route path captures. */
25
- export type RouteParamSpec = {| readonly name: string, readonly catchAll: boolean |};
26
-
27
- /** The parameters captured from a URL. A catch-all captures the rest as a list. */
28
- export type RouteParams = { readonly [string]: string | $ReadOnlyArray<string> };
29
-
30
- /** The query string, as a read-only map. */
31
- export type SearchParams = { readonly [string]: string };
32
-
33
- /** What a page module may export. The component is `default` or `Page`. */
34
- export type PageModule = {
35
- readonly default?: React.ComponentType<any>,
36
- readonly Page?: React.ComponentType<any>,
37
- readonly loader?: (args: LoaderArgs) => mixed | Promise<mixed>,
38
- readonly metadata?: Metadata,
39
- readonly generateMetadata?: (args: MetadataArgs) => Metadata | Promise<Metadata>,
40
- readonly generateStaticParams?: () =>
41
- | $ReadOnlyArray<RouteParams>
42
- | Promise<$ReadOnlyArray<RouteParams>>,
43
- readonly frontmatter?: { readonly title?: string, readonly description?: string, ... },
44
- ...
45
- };
46
-
47
- /** What a layout module may export. The component is `default` or `Layout`. */
48
- export type LayoutModule = {
49
- readonly default?: React.ComponentType<any>,
50
- readonly Layout?: React.ComponentType<any>,
51
- readonly metadata?: Metadata,
52
- ...
53
- };
54
-
55
- /** Document metadata a page or layout declares. */
56
- export type Metadata = {
57
- readonly title?: string,
58
- readonly description?: string,
59
- readonly openGraph?: {
60
- readonly title?: string,
61
- readonly description?: string,
62
- readonly images?: $ReadOnlyArray<string>,
63
- },
64
- };
65
-
66
- /** Arguments a loader receives. */
67
- export type LoaderArgs = {|
68
- readonly params: RouteParams,
69
- readonly searchParams: SearchParams,
70
- readonly pathname: string,
71
- |};
72
-
73
- /** Arguments `generateMetadata` receives. */
74
- export type MetadataArgs = {|
75
- readonly params: RouteParams,
76
- readonly searchParams: SearchParams,
77
- readonly data: mixed,
78
- |};
79
-
80
- /** One entry of the generated route table. */
81
- export type RouteRecord = {|
82
- readonly path: string,
83
- readonly params: $ReadOnlyArray<RouteParamSpec>,
84
- readonly mdx: boolean,
85
- readonly file: string,
86
- readonly page: () => Promise<PageModule>,
87
- readonly layouts: $ReadOnlyArray<() => Promise<LayoutModule>>,
88
- |};
89
-
90
- /** The not-found page, when the app declares one. */
91
- export type NotFoundRecord = {|
92
- readonly mdx: boolean,
93
- readonly file: string,
94
- readonly page: () => Promise<PageModule>,
95
- readonly layouts: $ReadOnlyArray<() => Promise<LayoutModule>>,
96
- |};
97
-
98
- /** A route table plus the not-found page. */
99
- export type RouteTable = {|
100
- readonly routes: $ReadOnlyArray<RouteRecord>,
101
- readonly notFound: ?NotFoundRecord,
102
- |};
103
-
104
- /** A URL matched against the table. */
105
- export type RouteMatch = {|
106
- readonly route: RouteRecord,
107
- readonly params: RouteParams,
108
- |};
109
-
110
- /** A match whose modules are loaded and whose loader has run. */
111
- export type ResolvedRoute = {|
112
- readonly pathname: string,
113
- readonly search: string,
114
- readonly path: string,
115
- readonly params: RouteParams,
116
- readonly searchParams: SearchParams,
117
- readonly page: PageModule,
118
- readonly layouts: $ReadOnlyArray<LayoutModule>,
119
- readonly data: mixed,
120
- readonly metadata: Metadata,
121
- readonly status: 200 | 404,
122
- |};
123
-
124
- /** Thrown by `notFound()`; the renderer answers with the not-found page. */
125
- export class NotFoundError extends Error {
126
- constructor() {
127
- super("not found");
128
- this.name = "NotFoundError";
129
- }
130
- }
131
-
132
- /** Thrown by `redirect()`; the renderer answers with a redirect. */
133
- export class RedirectError extends Error {
134
- to: string;
135
- permanent: boolean;
136
-
137
- constructor(to: string, permanent: boolean) {
138
- super(`redirect to ${to}`);
139
- this.name = "RedirectError";
140
- this.to = to;
141
- this.permanent = permanent;
142
- }
143
- }
36
+ // The one thing in this module that only a browser can do, and the reason it
37
+ // is imported here rather than from `../client.js`: a view transition needs
38
+ // the DOM updated inside the callback it was handed, and `startTransition`
39
+ // schedules. "View transitions", below, is the argument. Importing `react-dom`
40
+ // costs the server bundle nothing it did not already have — `internal/stream.js`
41
+ // imports `react-dom/server` — and this entry touches no document while it is
42
+ // being evaluated.
43
+ import { flushSync } from "react-dom";
44
+
45
+ // The two things a render has to fix — its instant and its random seed — and
46
+ // the provider that fixes them. Imported here rather than left to the
47
+ // application, because a hydration guarantee nobody wires is not a guarantee:
48
+ // see [`routerView`] and ubugeeei-prod/uf#559.
49
+ import { RenderProvider } from "@uniflowed/hooks/render";
50
+
51
+ // The id of the script the loader data is embedded in. It moved out of the
52
+ // head and into the tree with ubugeeei-prod/uf#373 — see [`payloadElements`]
53
+ // — so the module that renders it is this one rather than `../server.js`.
54
+ import { DATA_ID } from "./document.js";
55
+
56
+ // The payload the loader's answer is written as, and the rows it defers. Row 0
57
+ // is the element `DATA_ID` names and is byte-identical to what this file wrote
58
+ // inline before the payload existed whenever nothing is deferred; a promise
59
+ // anywhere in the data turns into a reference and a row of its own. See
60
+ // `./payload.js` for the format and ubugeeei-prod/uf#519 for the half of it
61
+ // that is still an element payload rather than a data one.
62
+ import {
63
+ type PayloadRowMessage,
64
+ PayloadRowError,
65
+ encodePayload,
66
+ encodeRowValue,
67
+ payloadJson,
68
+ } from "./payload.js";
69
+
70
+ // The development-only half of [`RouteView`]: the marks that say which DOM
71
+ // subtree each boundary owns, and the report that reads them. Every reference
72
+ // to it is inside a `BOUNDARY_MARKS` branch, which is why a static import is
73
+ // safe here where `../client.js` needs a dynamic one — a component cannot be
74
+ // awaited in the middle of a render, and `false` folds the references away
75
+ // before the bundler is asked to keep the module. See [`BOUNDARY_MARKS`].
76
+ import { BoundaryReporter } from "./boundaries.js";
77
+ import { routeBoundaries } from "./boundary-data.js";
78
+ import { composeRoute, pageComponent } from "./compose.js";
79
+ import {
80
+ type FetchedFlight,
81
+ type FlightFetchOptions,
82
+ type FlightRoot,
83
+ type RouteState,
84
+ routeState,
85
+ } from "./flight.js";
86
+ import { Head } from "./head.js";
87
+ import { addressOf, applicationPathOf, canonicalAddress } from "./base-path.js";
88
+ import {
89
+ clearNavigationCache,
90
+ flightNavigations,
91
+ keepsNavigations,
92
+ navigationKey,
93
+ routeNavigations,
94
+ } from "./navigation-cache.js";
95
+ import { hasClientPage, matchRoute, nearestBoundary } from "./routing.js";
96
+ import type { RouteParams, SearchParams } from "./routing.js";
97
+ import {
98
+ beneath,
99
+ interceptingRoutes,
100
+ loadOnce,
101
+ resolveInterception,
102
+ resolveMatch,
103
+ } from "./resolve.js";
104
+ import type { Metadata, ResolvedRoute, RouteTable } from "./resolve.js";
105
+
106
+ export type { RouteError, RouteParamSpec, RouteParams, SearchParams } from "./routing.js";
107
+
108
+ export {
109
+ ForbiddenError,
110
+ NotFoundError,
111
+ RedirectError,
112
+ UnauthorizedError,
113
+ buildRoute,
114
+ forbidden,
115
+ hasClientPage,
116
+ matchRoute,
117
+ notFound,
118
+ parseSearch,
119
+ permanentRedirect,
120
+ redirect,
121
+ routeErrorStatus,
122
+ splitUrl,
123
+ unauthorized,
124
+ } from "./routing.js";
125
+
126
+ export type {
127
+ ErrorBoundary,
128
+ ErrorModule,
129
+ Interception,
130
+ JsonLd,
131
+ LayoutModule,
132
+ LoaderArgs,
133
+ LoadingModule,
134
+ LoadingRecord,
135
+ Metadata,
136
+ MetadataArgs,
137
+ NotFoundBoundary,
138
+ PageModule,
139
+ ResolveOptions,
140
+ ResolvedRoute,
141
+ ResolvedSlot,
142
+ Robots,
143
+ RouteMatch,
144
+ RouteRecord,
145
+ RouteTable,
146
+ SlotRecord,
147
+ SlotRouteRecord,
148
+ TemplateModule,
149
+ TemplateRecord,
150
+ TwitterCard,
151
+ } from "./resolve.js";
152
+
153
+ export { resolveFailure, resolveMatch } from "./resolve.js";
154
+
155
+ // `app.router.basePath` and `trailingSlash`, installed by the entry that starts
156
+ // the application; see `./base-path.js`.
157
+ export type { RoutingSettings, TrailingSlash } from "./base-path.js";
158
+ export { basePath, installRouting } from "./base-path.js";
159
+
160
+ // `app.rendering.staleTime`, installed by the same entry; see
161
+ // `./navigation-cache.js`.
162
+ export { installStaleTime } from "./navigation-cache.js";
144
163
 
145
164
  // ---------------------------------------------------------------------------
146
- // Matching
165
+ // View transitions
147
166
  // ---------------------------------------------------------------------------
148
-
149
- type Segment =
150
- | {| readonly kind: "static", readonly value: string |}
151
- | {| readonly kind: "param", readonly name: string |}
152
- | {| readonly kind: "catchAll", readonly name: string |};
153
-
154
- function compile(routePath: string): $ReadOnlyArray<Segment> {
155
- return routePath
156
- .split("/")
157
- .filter((segment) => segment !== "")
158
- .map((segment): Segment => {
159
- if (segment.startsWith(":") && segment.endsWith("*")) {
160
- return { kind: "catchAll", name: segment.slice(1, -1) };
161
- }
162
- if (segment.startsWith(":")) {
163
- return { kind: "param", name: segment.slice(1) };
164
- }
165
- return { kind: "static", value: segment };
166
- });
167
- }
167
+ //
168
+ // A client navigation replaces the tree and the browser paints the new one,
169
+ // which is a cut. `document.startViewTransition` is the platform's answer, and
170
+ // it is opt-in per navigation rather than per site — so somebody has to call
171
+ // it, and the somebody is whatever replaced the tree. That is this module.
172
+ // Leaving it to the application would mean every application reimplementing
173
+ // the same four decisions below, and getting the last one wrong.
174
+ //
175
+ // # Why `flushSync` rather than `startTransition`
176
+ //
177
+ // The browser captures the old frame, calls the callback, and waits on the
178
+ // promise the callback returns before capturing the new one. So the callback
179
+ // has to leave the DOM updated, and `startTransition` deliberately does not:
180
+ // it schedules, and returns having changed nothing.
181
+ //
182
+ // The alternative was to hand the browser a promise resolved from a layout
183
+ // effect after the commit, which keeps the render concurrent and can hang: a
184
+ // running view transition blocks input until its callback settles, so a commit
185
+ // React decides not to make — an interrupted transition, an unmounted provider
186
+ // — is a frozen page with no way back. `flushSync` cannot hang.
187
+ //
188
+ // The cost is real and worth stating rather than discovering. Inside a
189
+ // transition the commit is synchronous, so a route that suspends *while
190
+ // rendering* shows its `$loading.js` fallback instead of leaving the
191
+ // previous page up until it resolves. Its modules and its loader are already
192
+ // finished by this point — `resolveMatch` awaited both — so what is left is a
193
+ // component suspending on something else, and it degrades to the fallback the
194
+ // project wrote for exactly that.
195
+ //
196
+ // # Why not React's `<ViewTransition>`
197
+ //
198
+ // It is not in a stable React. This package's peer range is `react >= 19`, and
199
+ // reaching for a component that exists only in an experimental build would
200
+ // turn an animation into a reason a project cannot use the router at all.
201
+ // `startViewTransition` is the same feature one layer down, and it is in the
202
+ // browser rather than in a dependency.
203
+ //
204
+ // # What must not change
205
+ //
206
+ // A browser without `startViewTransition` navigates exactly as it did before
207
+ // any of this. A reader who asked for less motion gets the cut they asked for,
208
+ // without the application having to remember to ask on their behalf. And the
209
+ // server renders nothing about it: a transition is a client-only concern, and
210
+ // the moment one reaches the markup it is a hydration difference instead.
168
211
 
169
212
  /**
170
- * How specific a route is, for ranking: a static segment outranks a parameter,
171
- * which outranks a catch-all, and a longer path outranks a shorter one.
213
+ * The attribute a running transition's name reaches CSS through.
214
+ *
215
+ * On the document element, because that is where the `::view-transition`
216
+ * pseudo-elements hang and therefore the only element a selector can reach
217
+ * them from.
172
218
  */
173
- function specificity(segments: $ReadOnlyArray<Segment>): number {
174
- let score = 0;
175
- for (const segment of segments) {
176
- score += match (segment) {
177
- {kind: "static"} => 3,
178
- {kind: "param"} => 2,
179
- {kind: "catchAll"} => 1,
180
- };
181
- }
182
- return score;
183
- }
184
-
185
- function matchSegments(
186
- segments: $ReadOnlyArray<Segment>,
187
- parts: $ReadOnlyArray<string>,
188
- ): ?RouteParams {
189
- const params: { [string]: string | $ReadOnlyArray<string> } = {};
190
- let index = 0;
191
- for (const segment of segments) {
192
- match (segment) {
193
- {kind: "static", value: const value} => {
194
- if (parts[index] !== value) {
195
- return null;
196
- }
197
- index += 1;
198
- }
199
- {kind: "param", name: const name} => {
200
- if (index >= parts.length) {
201
- return null;
202
- }
203
- params[name] = decodeSegment(parts[index]);
204
- index += 1;
205
- }
206
- {kind: "catchAll", name: const name} => {
207
- params[name] = parts.slice(index).map(decodeSegment);
208
- index = parts.length;
209
- }
210
- }
211
- }
212
- return index === parts.length ? params : null;
213
- }
214
-
215
- function decodeSegment(segment: string): string {
216
- try {
217
- return decodeURIComponent(segment);
218
- } catch {
219
- return segment;
220
- }
221
- }
219
+ const VIEW_TRANSITION_ATTRIBUTE = "data-uf-view-transition";
222
220
 
223
221
  /**
224
- * Match a pathname against the table, preferring the most specific route.
222
+ * The part of a running transition this module reads.
223
+ *
224
+ * One property, because one is what a navigation needs: `finished` settles
225
+ * when the animation is over, which is when the document may stop saying which
226
+ * transition is running. `ready` and `updateCallbackDone` are for an
227
+ * application animating something itself, and a router holding them would be
228
+ * claiming to know what they were for.
225
229
  */
226
- export function matchRoute(routes: $ReadOnlyArray<RouteRecord>, pathname: string): ?RouteMatch {
227
- const parts = pathname.split("/").filter((part) => part !== "");
228
- let best: ?RouteMatch = null;
229
- let bestScore = -1;
230
- for (const route of routes) {
231
- const segments = compile(route.path);
232
- const params = matchSegments(segments, parts);
233
- if (params == null) {
234
- continue;
235
- }
236
- const score = specificity(segments);
237
- if (score > bestScore) {
238
- best = { route, params };
239
- bestScore = score;
240
- }
241
- }
242
- return best;
243
- }
244
-
245
- /** Split a URL into its pathname and search string. */
246
- export function splitUrl(url: string): {| readonly pathname: string, readonly search: string |} {
247
- const hash = url.indexOf("#");
248
- const withoutHash = hash === -1 ? url : url.slice(0, hash);
249
- const question = withoutHash.indexOf("?");
250
- if (question === -1) {
251
- return { pathname: normalizePathname(withoutHash), search: "" };
252
- }
253
- return {
254
- pathname: normalizePathname(withoutHash.slice(0, question)),
255
- search: withoutHash.slice(question),
256
- };
257
- }
258
-
259
- function normalizePathname(pathname: string): string {
260
- if (pathname === "" || pathname === "/") {
261
- return "/";
262
- }
263
- const trimmed = pathname.replace(/\/+$/, "");
264
- return trimmed === "" ? "/" : trimmed;
265
- }
230
+ type ViewTransition = { readonly finished: Promise<mixed>, ... };
266
231
 
267
- /** Parse a search string into a flat map; a repeated key keeps its last value. */
268
- export function parseSearch(search: string): SearchParams {
269
- const params: { [string]: string } = {};
270
- for (const [key, value] of new URLSearchParams(search)) {
271
- params[key] = value;
272
- }
273
- return params;
232
+ /**
233
+ * The document, under the one description this module has of it.
234
+ *
235
+ * Flow's library definitions have no `startViewTransition` — the API is newer
236
+ * than they are — and reading it off `any` would leave the one call that
237
+ * performs a navigation unchecked, where a wrong type is a broken navigation
238
+ * rather than a broken animation. Optional, because "this browser may not have
239
+ * it" is the entire point.
240
+ *
241
+ * An `interface` rather than an object type, because a `Document` is a class
242
+ * instance and class instances are not subtypes of object types. `documentElement`
243
+ * is nullable for the same reason it is in Flow's own libdef: a document parsed
244
+ * from nothing has no root element.
245
+ */
246
+ interface ViewTransitionDocument {
247
+ readonly startViewTransition?: (update: () => mixed) => ViewTransition;
248
+ readonly documentElement: HTMLElement | null;
274
249
  }
275
250
 
276
- // ---------------------------------------------------------------------------
277
- // Loading
278
- // ---------------------------------------------------------------------------
279
-
280
- const moduleCache: Map<() => Promise<mixed>, Promise<mixed>> = new Map();
281
-
282
- function loadOnce<T>(load: () => Promise<T>): Promise<T> {
283
- let pending = moduleCache.get(load);
284
- if (pending == null) {
285
- pending = load();
286
- moduleCache.set(load, pending);
287
- }
288
- // $FlowFixMe[incompatible-return] the cache is keyed by the loader, whose result type it stores.
289
- return pending;
251
+ /**
252
+ * Whether the reader has asked for less motion.
253
+ *
254
+ * Asked at the moment of the navigation rather than subscribed to, because it
255
+ * is not a rendered value: nothing re-renders when the preference changes, and
256
+ * the only question is what to do with the click that just happened.
257
+ * `usePrefersReducedMotion` in `@uniflowed/hooks` is the rendered form of the
258
+ * same query and answers a different question.
259
+ *
260
+ * `matchMedia` is optional here because a document installed by a test runner
261
+ * may not have one, and a media query that cannot be asked is not a reason to
262
+ * fail a navigation.
263
+ */
264
+ function prefersReducedMotion(): boolean {
265
+ const query = window.matchMedia?.("(prefers-reduced-motion: reduce)");
266
+ return query != null && query.matches === true;
290
267
  }
291
268
 
292
269
  /**
293
- * Load a match's modules and run its loader.
270
+ * Apply `update`, inside a view transition where there is one to be had.
271
+ *
272
+ * Two ways out and they are one decision: with no `startViewTransition`, or
273
+ * with a reader who asked for less motion, this is the `startTransition` the
274
+ * router did before any of this existed — same commit, same concurrency, no
275
+ * animation.
294
276
  *
295
- * `data` is what the loader returned; on the client after hydration it is the
296
- * value the server embedded, so the loader does not run twice for the first
297
- * page.
277
+ * `name` is the route's, and it reaches CSS as an attribute for as long as the
278
+ * transition runs. The other spelling is the `types` option, which is the
279
+ * platform's own vocabulary for the same idea and is *newer than
280
+ * `startViewTransition` itself* — so passing the options object to a browser
281
+ * that has only the callback form is a `TypeError` thrown out of the call that
282
+ * performs the navigation. Naming a transition would then need a second and
283
+ * finer feature detection than the one for having transitions at all, and the
284
+ * cost of getting that one wrong is the navigation rather than the animation.
285
+ * One attribute needs no detection and is removed again when the transition
286
+ * ends.
298
287
  */
299
- export async function resolveMatch(
300
- table: RouteTable,
301
- url: string,
302
- options?: {| readonly data?: mixed, readonly skipLoader?: boolean |},
303
- ): Promise<ResolvedRoute> {
304
- const { pathname, search } = splitUrl(url);
305
- const searchParams = parseSearch(search);
306
- const matched = matchRoute(table.routes, pathname);
307
-
308
- if (matched == null) {
309
- return resolveNotFound(table, pathname, search, searchParams);
288
+ function withViewTransition(name: ?string, update: () => void): void {
289
+ const owner: ViewTransitionDocument = document;
290
+ const start = owner.startViewTransition?.bind(owner);
291
+ if (start == null || prefersReducedMotion()) {
292
+ startTransition(update);
293
+ return;
310
294
  }
311
295
 
312
- const [page, ...layouts] = await Promise.all([
313
- loadOnce(matched.route.page),
314
- ...matched.route.layouts.map((layout) => loadOnce(layout)),
315
- ]);
316
-
317
- let data: mixed = options?.data;
318
- if (options?.skipLoader !== true && typeof page.loader === "function") {
319
- try {
320
- data = await page.loader({ params: matched.params, searchParams, pathname });
321
- } catch (error) {
322
- if (error instanceof NotFoundError) {
323
- return resolveNotFound(table, pathname, search, searchParams);
324
- }
325
- throw error;
326
- }
327
- }
328
-
329
- const metadata = await resolveMetadata(page, layouts, {
330
- params: matched.params,
331
- searchParams,
332
- data,
333
- });
334
- return {
335
- pathname,
336
- search,
337
- path: matched.route.path,
338
- params: matched.params,
339
- searchParams,
340
- page,
341
- layouts,
342
- data,
343
- metadata,
344
- status: 200,
345
- };
346
- }
347
-
348
- async function resolveNotFound(
349
- table: RouteTable,
350
- pathname: string,
351
- search: string,
352
- searchParams: SearchParams,
353
- ): Promise<ResolvedRoute> {
354
- const record = table.notFound;
355
- if (record == null) {
356
- return {
357
- pathname,
358
- search,
359
- path: "*",
360
- params: {},
361
- searchParams,
362
- page: { default: DefaultNotFound },
363
- layouts: [],
364
- data: undefined,
365
- metadata: { title: "Not found" },
366
- status: 404,
367
- };
296
+ const root = owner.documentElement;
297
+ if (name != null && root != null) {
298
+ root.setAttribute(VIEW_TRANSITION_ATTRIBUTE, name);
368
299
  }
369
- const [page, ...layouts] = await Promise.all([
370
- loadOnce(record.page),
371
- ...record.layouts.map((layout) => loadOnce(layout)),
372
- ]);
373
- const metadata = await resolveMetadata(page, layouts, {
374
- params: {},
375
- searchParams,
376
- data: undefined,
377
- });
378
- return {
379
- pathname,
380
- search,
381
- path: "*",
382
- params: {},
383
- searchParams,
384
- page,
385
- layouts,
386
- data: undefined,
387
- metadata,
388
- status: 404,
389
- };
390
- }
391
-
392
- async function resolveMetadata(
393
- page: PageModule,
394
- layouts: $ReadOnlyArray<LayoutModule>,
395
- args: MetadataArgs,
396
- ): Promise<Metadata> {
397
- let merged: Metadata = {};
398
- for (const layout of layouts) {
399
- if (layout.metadata != null) {
400
- merged = { ...merged, ...layout.metadata };
300
+ const ended = () => {
301
+ if (name != null && root != null) {
302
+ root.removeAttribute(VIEW_TRANSITION_ATTRIBUTE);
401
303
  }
402
- }
403
- if (page.frontmatter != null) {
404
- const { title, description } = page.frontmatter;
405
- merged = {
406
- ...merged,
407
- ...(title != null ? { title } : {}),
408
- ...(description != null ? { description } : {}),
409
- };
410
- }
411
- if (page.metadata != null) {
412
- merged = { ...merged, ...page.metadata };
413
- }
414
- if (typeof page.generateMetadata === "function") {
415
- merged = { ...merged, ...(await page.generateMetadata(args)) };
416
- }
417
- return merged;
418
- }
419
-
420
- component DefaultNotFound() {
421
- return (
422
- <main>
423
- <title>Not found</title>
424
- <h1>404</h1>
425
- <p>This page does not exist.</p>
426
- </main>
427
- );
304
+ };
305
+ // Both settlements do the same thing, and the rejection is not a failure:
306
+ // `finished` rejects when the transition is skipped — a second navigation
307
+ // before this one finished, a tab that went to the background — and a
308
+ // skipped transition has still ended. Handling it is also what keeps a
309
+ // routine interruption from being reported as an unhandled rejection.
310
+ start(() => {
311
+ flushSync(update);
312
+ }).finished.then(ended, ended);
428
313
  }
429
314
 
430
315
  // ---------------------------------------------------------------------------
@@ -432,7 +317,22 @@ component DefaultNotFound() {
432
317
  // ---------------------------------------------------------------------------
433
318
 
434
319
  /** How a navigation is performed. */
435
- export type NavigateOptions = {| readonly replace?: boolean, readonly scroll?: boolean |};
320
+ export type NavigateOptions = {|
321
+ readonly replace?: boolean,
322
+ readonly scroll?: boolean,
323
+ /**
324
+ * Whether this navigation may animate. Defaults to `true`, which is what
325
+ * every navigation does.
326
+ *
327
+ * `false` is how a caller says this one is a change of state rather than a
328
+ * change of place — a tab within a page, a filter written into the query
329
+ * string — and should be a cut. `true` does not *force* one: a browser
330
+ * without `startViewTransition` and a reader who asked for less motion still
331
+ * get the cut, because an application able to override the second would
332
+ * eventually override it.
333
+ */
334
+ readonly transition?: boolean,
335
+ |};
436
336
 
437
337
  /** What `useRouter()` returns. */
438
338
  export type Router = {|
@@ -454,17 +354,111 @@ export type RouteInfo = {|
454
354
  readonly pending: boolean,
455
355
  |};
456
356
 
357
+ /**
358
+ * What this application does when a visitor follows a link.
359
+ *
360
+ * `app.rendering.navigation` in `uf.config.js`, and the same two words: the
361
+ * client router takes the link over, or the browser does.
362
+ */
363
+ export type Navigation = "client" | "document";
364
+
365
+ /**
366
+ * What the router holds, and what every hook and `RouteView` read.
367
+ *
368
+ * Two halves, because a route arrives two ways. `route` is what a hook reads —
369
+ * the path, the parameters, the loader's answer — and it is the same shape
370
+ * whichever way the route was rendered. `view` is what `RouteView` renders:
371
+ * the tree a server composed for React Server Components, or a route resolved
372
+ * from its modules, which the browser composes itself.
373
+ */
457
374
  type RouterState = {|
458
- readonly resolved: ResolvedRoute,
375
+ readonly route: RouteState,
376
+ readonly view: RouteViewState,
459
377
  readonly router: Router,
460
378
  readonly pending: boolean,
379
+ readonly navigation: Navigation,
461
380
  |};
462
381
 
382
+ /** What `RouteView` renders: a server's tree, or a route to compose. */
383
+ type RouteViewState =
384
+ | {| readonly kind: "flight", readonly tree: React.Node |}
385
+ | {| readonly kind: "modules", readonly resolved: ResolvedRoute |};
386
+
463
387
  const RouterContext: React.Context<?RouterState> = createContext(null);
464
388
 
465
389
  /** The route table the application was started with. */
466
390
  let installedTable: ?RouteTable = null;
467
391
 
392
+ /**
393
+ * How the application navigates, installed by the entry that started it.
394
+ *
395
+ * Module state beside `installedTable`, and for the same reason: the entry is
396
+ * the only thing that knows, and every component that needs the answer is
397
+ * somewhere under a `RouterProvider` it did not construct. `routerView` builds
398
+ * that provider from two props the server handed it, and threading a third one
399
+ * from the entry through the application root would have made every
400
+ * hand-written `<App>` in a test a place the default lives.
401
+ *
402
+ * `"client"` until something says otherwise, which is what every uf
403
+ * application did before `app.rendering.navigation` existed and what a test
404
+ * that renders `routerView` directly still gets.
405
+ */
406
+ let installedNavigation: Navigation = "client";
407
+
408
+ /**
409
+ * Say how this application navigates. Called once, by the client entry.
410
+ *
411
+ * `@uniflowed/vite` generates the call into `virtual:uf/client` from
412
+ * `app.rendering.navigation`; nothing else should call it, and calling it after
413
+ * the first render is a change no rendered `Link` will notice.
414
+ */
415
+ export function installNavigation(navigation: Navigation): void {
416
+ installedNavigation = navigation;
417
+ }
418
+
419
+ /** How this application navigates. */
420
+ export function navigationMode(): Navigation {
421
+ return installedNavigation;
422
+ }
423
+
424
+ /**
425
+ * How a page that React Server Components rendered fetches the next route's
426
+ * payload. `hydrateFlight` in `../rsc-client.js` installs it.
427
+ *
428
+ * Handed in rather than imported, because this module is in every
429
+ * application's bundle: one rendered from its modules, a single-page one, and
430
+ * the server's. The fetch reads its answer with React's Flight client,
431
+ * `react-server-dom-parcel`, which only an application that renders Server
432
+ * Components installs, and which needs React 19.3 while the rest of the router
433
+ * runs on 19.2.3 (ubugeeei-prod/uf#992). A bundler resolves every import it is
434
+ * shown, whether or not anything calls it, so an import here would put that
435
+ * package in every one of those bundles, or fail the build where it is absent.
436
+ */
437
+ let installedFlightFetch:
438
+ | ((url: string, options?: FlightFetchOptions) => Promise<FetchedFlight>)
439
+ | null = null;
440
+
441
+ /** Hand the router the payload fetch. Called once, by `hydrateFlight`, before the first render. */
442
+ export function installFlightFetch(
443
+ fetcher: (url: string, options?: FlightFetchOptions) => Promise<FetchedFlight>,
444
+ ): void {
445
+ installedFlightFetch = fetcher;
446
+ }
447
+
448
+ /** The next route's payload, through the fetch `hydrateFlight` installed. */
449
+ function fetchFlight(url: string, options?: FlightFetchOptions): Promise<FetchedFlight> {
450
+ if (installedFlightFetch == null) {
451
+ return Promise.reject(
452
+ new Error(
453
+ "@uniflowed/router: a page rendered from a Flight payload navigated before anything " +
454
+ "installed the payload fetch. `hydrateFlight` from `@uniflowed/router/rsc/client` " +
455
+ "installs it before it hydrates, so an entry that hydrates a payload has to call that.",
456
+ ),
457
+ );
458
+ }
459
+ return installedFlightFetch(url, options);
460
+ }
461
+
468
462
  /** Register the generated route table. Called once by the client and server entries. */
469
463
  export function installRoutes(table: RouteTable): void {
470
464
  installedTable = table;
@@ -480,45 +474,280 @@ export function routeTable(): RouteTable {
480
474
  return installedTable;
481
475
  }
482
476
 
483
- /** Props the app root receives from the client and server entries. */
477
+ /**
478
+ * Props the app root receives from the client and server entries.
479
+ *
480
+ * One of `flight` and `initial`. A document React Server Components rendered
481
+ * hands the root its payload, on the server and again in the browser, so both
482
+ * sides render the same tree from the same bytes. A single-page application —
483
+ * and a project that turned `app.rsc` off — hands it a route resolved from its
484
+ * modules instead. See ubugeeei-prod/uf#519.
485
+ */
484
486
  export type AppProps = {|
485
487
  readonly url: string,
486
- readonly initial: ResolvedRoute,
488
+ readonly initial?: ResolvedRoute,
489
+ readonly flight?: Promise<FlightRoot>,
487
490
  |};
488
491
 
489
- const isBrowser = typeof window !== "undefined" && typeof document !== "undefined";
492
+ /**
493
+ * Whether there is a document to navigate.
494
+ *
495
+ * Asked every time rather than answered once at module scope, and the
496
+ * difference is not a style preference. The answer is a constant inside a
497
+ * browser bundle and inside a server process; it is *not* a constant inside a
498
+ * test runner, where a DOM is installed on the first render and one worker
499
+ * serves many files out of one module registry. Latched, the first file in a
500
+ * worker to import this module decided for every file after it whether a
501
+ * `Link` navigates or silently does nothing — and a server-rendering test
502
+ * imports it before any document exists. See ubugeeei-prod/uf#445.
503
+ *
504
+ * The cost is a `typeof` per navigation, which is a navigation.
505
+ */
506
+ function isBrowser(): boolean {
507
+ return typeof window !== "undefined" && typeof document !== "undefined";
508
+ }
490
509
 
491
510
  /**
492
511
  * Provides the current route to the tree and performs navigation.
493
512
  *
494
513
  * On the server the route is fixed for the request. In the browser the
495
- * provider listens to history and to `Link` clicks; a navigation resolves the
496
- * next route (loading its chunks and running its loader) *before* committing,
497
- * inside a transition, so the previous page stays interactive meanwhile.
514
+ * provider listens to history and to `Link` clicks; a navigation fetches the
515
+ * next route's payload — or, for a route resolved from its modules, loads its
516
+ * chunks and runs its loader — *before* committing, inside a transition, so the
517
+ * previous page stays interactive meanwhile.
518
+ *
519
+ * Which of the two it does is decided by what it was started with: a Flight
520
+ * payload is [`FlightRouter`], and a resolved route is [`ModuleRouter`].
521
+ *
522
+ * # Unless the application asked the browser to do it
523
+ *
524
+ * Under `app.rendering.navigation: "document"` every one of those sentences
525
+ * stops being true, and the provider is still here: the tree below it still
526
+ * reads `useRoute`, still renders `<RouteView>`, and still hydrates whatever
527
+ * `"use client"` boundary made the document interactive. What it does not do is
528
+ * take the link over. `navigate` hands the URL to the browser, no `popstate`
529
+ * listener is installed, and `prefetch` — which exists to load the chunks of a
530
+ * route this page will render — has no page to load them for.
531
+ *
532
+ * That is one branch rather than a second provider because the two differ in
533
+ * what happens on a click and in nothing else. A second implementation would
534
+ * have had to keep `resolved`, `pending`, the context and every hook that
535
+ * reads it in step with this one, which is four things to keep in step for one
536
+ * that actually differs.
537
+ */
538
+ export component RouterProvider(
539
+ url: string,
540
+ initial?: ResolvedRoute,
541
+ flight?: Promise<FlightRoot>,
542
+ children: React.Node,
543
+ ) {
544
+ if (flight != null) {
545
+ return <FlightRouter flight={flight}>{children}</FlightRouter>;
546
+ }
547
+ if (initial == null) {
548
+ throw new Error(
549
+ "@uniflowed/router: RouterProvider was given neither a Flight payload nor a resolved route " +
550
+ "to start from. `virtual:uf/client` and `virtual:uf/server` hand it one of the two.",
551
+ );
552
+ }
553
+ return (
554
+ <ModuleRouter url={url} initial={initial}>
555
+ {children}
556
+ </ModuleRouter>
557
+ );
558
+ }
559
+
560
+ /**
561
+ * The key an intercepted navigation writes into its history entry.
562
+ *
563
+ * One string in `history.state` rather than the resolved route, because the
564
+ * browser structured-clones the state and keeps it across a reload: it can hold
565
+ * a URL and nothing with a module in it. A URL is also all the entry needs —
566
+ * where the navigation came from, resolved again when that page is not the one
567
+ * on screen, and the entry's own URL for what intercepted it.
568
+ */
569
+ const INTERCEPTED_FROM = "uf:intercepted-from";
570
+
571
+ /**
572
+ * The state a history entry for `resolved` is written with.
573
+ *
574
+ * `null` for a navigation nothing intercepted, which is what every entry this
575
+ * router wrote was before interception existed.
576
+ */
577
+ function historyStateFor(resolved: ResolvedRoute): mixed {
578
+ const interception = resolved.interception;
579
+ if (interception == null) {
580
+ return null;
581
+ }
582
+ return { [INTERCEPTED_FROM]: interception.base.pathname + interception.base.search };
583
+ }
584
+
585
+ /** The state a history entry for a Flight route is written with. */
586
+ function historyStateForRoute(route: RouteState): mixed {
587
+ const from = route.interception?.from;
588
+ return from == null ? null : { [INTERCEPTED_FROM]: from };
589
+ }
590
+
591
+ /** Where the history entry holding `state` was intercepted from, if it was. */
592
+ function interceptedFrom(state: mixed): ?string {
593
+ if (state == null || typeof state !== "object" || Array.isArray(state)) {
594
+ return null;
595
+ }
596
+ const from = state[INTERCEPTED_FROM];
597
+ return typeof from === "string" ? from : null;
598
+ }
599
+
600
+ /**
601
+ * `state` without the interception in it.
602
+ *
603
+ * What is left is handed back rather than cleared, because an entry's state is
604
+ * not only this router's to write: another library may have put something
605
+ * beside it.
606
+ */
607
+ function withoutInterception(state: mixed): mixed {
608
+ if (state == null || typeof state !== "object" || Array.isArray(state)) {
609
+ return state;
610
+ }
611
+ const rest: { [string]: mixed } = {};
612
+ for (const key of Object.keys(state)) {
613
+ if (key !== INTERCEPTED_FROM) {
614
+ rest[key] = state[key];
615
+ }
616
+ }
617
+ return Object.keys(rest).length === 0 ? null : rest;
618
+ }
619
+
620
+ /** The page a Flight navigation is leaving from, as an application path and query. */
621
+ function flightNavigationOrigin(root: FlightRoot): string {
622
+ return root.route.interception?.from ?? root.route.pathname + root.route.search;
623
+ }
624
+
625
+ /** The optional fetch settings for an intercepted payload request. */
626
+ function flightFetchOptions(from: ?string): FlightFetchOptions | void {
627
+ return from == null ? undefined : { interceptedFrom: from };
628
+ }
629
+
630
+ /** The cache key for a Flight payload, separated by the page it renders over. */
631
+ function flightNavigationKey(pathname: string, search: string, from: ?string): string {
632
+ const key = navigationKey(pathname, search);
633
+ return from == null ? key : `${key}\0${from}`;
634
+ }
635
+
636
+ /**
637
+ * The provider for a route resolved from its modules: a single-page
638
+ * application, and a project that turned `app.rsc` off.
639
+ *
640
+ * It is also the provider that intercepts. Whether a navigation is intercepted
641
+ * is a question about the slots on screen, and only a router holding a route
642
+ * resolved from its modules has them to ask; a payload holds a rendered tree.
643
+ * See [`resolveInterception`].
498
644
  */
499
- export component RouterProvider(url: string, initial: ResolvedRoute, children: React.Node) {
645
+ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node) {
500
646
  const [resolved, setResolved] = useState<ResolvedRoute>(initial);
501
647
  const [pending, setPending] = useState<boolean>(false);
648
+ // Read once per render rather than per navigation: it is installed by the
649
+ // entry before the first render and never changes after it, and a `Link`
650
+ // that asked at click time would be asking a question whose answer decided
651
+ // what it rendered.
652
+ const navigation = navigationMode();
653
+ // The route on screen, for the code that runs after a render has finished.
654
+ //
655
+ // State is what renders, and a closure only sees the state of the render that
656
+ // made it: the `popstate` listener below is installed once and would go on
657
+ // reading the first route forever, and a navigation awaits between reading
658
+ // what is on screen and replacing it. Interception is what needs the answer —
659
+ // whether a navigation is intercepted is a question about the page it starts
660
+ // on — and `show` writes both in the same breath, so the two cannot disagree
661
+ // about what was last committed.
662
+ const shown = useRef<ResolvedRoute>(initial);
663
+ const show = (next: ResolvedRoute) => {
664
+ shown.current = next;
665
+ setResolved(next);
666
+ };
502
667
 
503
- const navigate = useCallback(async (to: string, options?: NavigateOptions): Promise<void> => {
504
- if (!isBrowser) {
668
+ const navigate = async (to: string, options?: NavigateOptions): Promise<void> => {
669
+ if (!isBrowser()) {
505
670
  return;
506
671
  }
507
- const target = new URL(to, window.location.href);
508
- const next = target.pathname + target.search;
672
+ const target = new URL(addressOf(to), window.location.href);
673
+ // The application path the route table is asked about, and the address the
674
+ // history entry keeps: one URL, with and without `app.router.basePath`.
675
+ const applicationPath = applicationPathOf(target.pathname);
676
+ const next = (applicationPath ?? target.pathname) + target.search;
677
+ const address = target.pathname + target.search;
678
+ // The browser's job in this application. `assign` and `replace` rather
679
+ // than the history API, because the point is a document request: the
680
+ // history entry, the scroll position, the `Referer` and the unload
681
+ // handlers are then the browser's, done the way they are done for a link
682
+ // in a page with no JavaScript on it at all.
683
+ if (navigation === "document") {
684
+ if (options?.replace === true) {
685
+ window.location.replace(target.href);
686
+ } else {
687
+ window.location.assign(target.href);
688
+ }
689
+ return;
690
+ }
691
+ // Interception first, because it is a question about the page this
692
+ // navigation starts on rather than about the one it reaches. A slot on
693
+ // screen that intercepts the URL renders a page of its own, so whether the
694
+ // URL's ordinary page is in this bundle — the paragraph below — is not a
695
+ // question this navigation has to ask.
696
+ // An address outside the base path is not this application's to render.
697
+ if (applicationPath == null) {
698
+ window.location.assign(target.href);
699
+ return;
700
+ }
701
+ const origin = beneath(shown.current);
702
+ const intercepting = interceptingRoutes(origin.slots, applicationPath).length > 0;
703
+ // The half of the split that is not about bytes. A route whose page is not
704
+ // in this bundle is not a route this router can render, and pretending
705
+ // otherwise is the silent break: the navigation would resolve to nothing
706
+ // and the visitor would be left on the page they clicked from. The browser
707
+ // has the document, so the browser does the navigation — which is what a
708
+ // link does when there is no JavaScript at all, and what the anchor
709
+ // `Link` renders would have done on its own.
710
+ if (!intercepting) {
711
+ const matched = matchRoute(routeTable().routes, applicationPath);
712
+ if (matched != null && !hasClientPage(matched.route)) {
713
+ window.location.assign(target.href);
714
+ return;
715
+ }
716
+ }
509
717
  setPending(true);
510
718
  try {
511
- const nextResolved = await resolveMatch(routeTable(), next);
719
+ // An interception depends on the page it starts from, so it is resolved
720
+ // every time; any other navigation reads what this page kept while it is
721
+ // fresh. See `./navigation-cache.js`.
722
+ const key = navigationKey(target.pathname, target.search);
723
+ const nextResolved =
724
+ (intercepting ? await resolveInterception(routeTable(), origin, next) : null) ??
725
+ (await (routeNavigations.read(key) ?? keepRoute(key, resolveMatch(routeTable(), next))));
726
+ // An intercepted entry remembers where it was intercepted from, so back
727
+ // and forward can put the page underneath under it again. Every other
728
+ // entry is written the way it always was.
729
+ const state = historyStateFor(nextResolved);
512
730
  if (options?.replace === true) {
513
- window.history.replaceState(null, "", next + target.hash);
731
+ window.history.replaceState(state, "", address + target.hash);
514
732
  } else {
515
- window.history.pushState(null, "", next + target.hash);
733
+ window.history.pushState(state, "", address + target.hash);
516
734
  }
517
- startTransition(() => {
518
- setResolved(nextResolved);
735
+ const commit = () => {
736
+ show(nextResolved);
519
737
  setPending(false);
520
- });
521
- if (options?.scroll !== false) {
738
+ };
739
+ if (options?.transition === false) {
740
+ startTransition(commit);
741
+ } else {
742
+ withViewTransition(nextResolved.viewTransition, commit);
743
+ }
744
+ // An intercepted navigation leaves the page underneath where the reader
745
+ // left it — the modal opens over the post they clicked, not over the top
746
+ // of the feed — so it moves the window only for a caller who asks with
747
+ // `scroll: true`. Every other navigation scrolls unless asked not to.
748
+ const scroll =
749
+ nextResolved.interception == null ? options?.scroll !== false : options?.scroll === true;
750
+ if (scroll) {
522
751
  if (target.hash !== "") {
523
752
  const element = document.getElementById(target.hash.slice(1));
524
753
  if (element != null) {
@@ -532,19 +761,357 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
532
761
  setPending(false);
533
762
  throw error;
534
763
  }
764
+ };
765
+
766
+ useEffect(() => {
767
+ if (!isBrowser()) {
768
+ return undefined;
769
+ }
770
+ // An entry that says it was intercepted, under a provider that has only
771
+ // just mounted, is an entry the browser reloaded or restored — and the
772
+ // document on screen is what a request for its URL returned, which is the
773
+ // ordinary page. Clearing the mark makes the entry say what the reader is
774
+ // looking at, so coming back to it later renders this page again rather
775
+ // than a modal over a page they never saw one on.
776
+ const restored = window.history.state;
777
+ if (interceptedFrom(restored) != null) {
778
+ window.history.replaceState(withoutInterception(restored), "", window.location.href);
779
+ }
780
+ // Nothing pushed a history entry, so there is nothing to pop back into: a
781
+ // document-navigating application left this page when the link was
782
+ // followed, and the back button asks the browser for the previous document
783
+ // rather than asking this listener to rebuild it. Installing one anyway
784
+ // would put a `resolveMatch` on the back button of a page that is about to
785
+ // be replaced by the one the browser already has.
786
+ if (navigation === "document") {
787
+ return undefined;
788
+ }
789
+ const arrive = (nextResolved: ResolvedRoute) => {
790
+ // The back button is a navigation, and a navigation that animates in
791
+ // one direction and cuts in the other would read as a bug in the
792
+ // animation rather than as a decision.
793
+ withViewTransition(nextResolved.viewTransition, () => {
794
+ show(nextResolved);
795
+ });
796
+ };
797
+ const onPopState = () => {
798
+ const next =
799
+ (applicationPathOf(window.location.pathname) ?? window.location.pathname) +
800
+ window.location.search;
801
+ // Back or forward into an entry an interception wrote: the page it was
802
+ // intercepted from, with the interception over it again. That page is
803
+ // resolved afresh only when it is not already the one underneath, so
804
+ // back from the second photo to the first leaves the feed exactly where
805
+ // it is.
806
+ const from = interceptedFrom(window.history.state);
807
+ if (from != null) {
808
+ const underneath = beneath(shown.current);
809
+ const origin =
810
+ underneath.pathname + underneath.search === from
811
+ ? Promise.resolve(underneath)
812
+ : resolveMatch(routeTable(), from);
813
+ origin
814
+ .then((page) => resolveInterception(routeTable(), page, next))
815
+ // Nothing on that page intercepts the entry's URL any more — a
816
+ // module that will not load, a table a development server rebuilt —
817
+ // so the entry is what its URL names.
818
+ .then((intercepted) => intercepted ?? resolveMatch(routeTable(), next))
819
+ .then(arrive);
820
+ return;
821
+ }
822
+ // Back into a route this bundle has no page for. The history entry is
823
+ // already the browser's — it moved before this listener ran — so the
824
+ // document that belongs to it is what has to be fetched.
825
+ const matched = matchRoute(
826
+ routeTable().routes,
827
+ applicationPathOf(window.location.pathname) ?? window.location.pathname,
828
+ );
829
+ if (matched != null && !hasClientPage(matched.route)) {
830
+ window.location.reload();
831
+ return;
832
+ }
833
+ const key = navigationKey(window.location.pathname, window.location.search);
834
+ (routeNavigations.read(key) ?? keepRoute(key, resolveMatch(routeTable(), next))).then(arrive);
835
+ };
836
+ window.addEventListener("popstate", onPopState);
837
+ return () => {
838
+ window.removeEventListener("popstate", onPopState);
839
+ };
535
840
  }, []);
536
841
 
842
+ const router: Router = {
843
+ push: (to, options) => navigate(to, options),
844
+ replace: (to) => navigate(to, { replace: true }),
845
+ prefetch: async (to) => {
846
+ // A prefetch loads the modules the *next render* will need, and under
847
+ // document navigation there is no next render in this page: the browser
848
+ // fetches a document and throws this one away. Loading the chunks would
849
+ // be bytes spent on a page that is leaving, so this declines rather than
850
+ // warming a cache nothing reads.
851
+ if (!isBrowser() || navigation === "document") {
852
+ return;
853
+ }
854
+ const target = new URL(addressOf(to), window.location.href);
855
+ const applicationPath = applicationPathOf(target.pathname);
856
+ if (applicationPath == null) {
857
+ return;
858
+ }
859
+ // What the next render will need is decided the way the navigation will
860
+ // decide it: a URL a slot on screen intercepts renders that slot's page,
861
+ // so that is the module worth having, and the page the URL names is not.
862
+ const intercepting = interceptingRoutes(beneath(shown.current).slots, applicationPath);
863
+ if (intercepting.length > 0) {
864
+ await Promise.all(
865
+ intercepting.flatMap((route) => [
866
+ loadOnce(route.page),
867
+ ...route.layouts.map((layout) => loadOnce(layout)),
868
+ ]),
869
+ );
870
+ return;
871
+ }
872
+ const matched = matchRoute(routeTable().routes, applicationPath);
873
+ const load = matched?.route.page;
874
+ if (matched == null || load == null) {
875
+ return;
876
+ }
877
+ // With `app.rendering.staleTime` set, the whole route: its loader runs
878
+ // now, and the click, a later visit and the back button read what it
879
+ // answered while it is fresh. Otherwise only the modules it will need.
880
+ if (keepsNavigations()) {
881
+ const key = navigationKey(target.pathname, target.search);
882
+ await (
883
+ routeNavigations.read(key) ??
884
+ keepRoute(key, resolveMatch(routeTable(), applicationPath + target.search))
885
+ );
886
+ return;
887
+ }
888
+ await Promise.all([
889
+ loadOnce(load),
890
+ ...matched.route.layouts.map((layout) => loadOnce(layout)),
891
+ ]);
892
+ },
893
+ refresh: async () => {
894
+ if (!isBrowser()) {
895
+ return;
896
+ }
897
+ // The same URL, rendered again — which under document navigation is what
898
+ // the browser calls a reload. Resolving it in the page instead would
899
+ // re-run the loader and commit a tree whose links this application has
900
+ // already said it does not drive.
901
+ if (navigation === "document") {
902
+ window.location.reload();
903
+ return;
904
+ }
905
+ // Everything a navigation kept is older than what this asks for, so none
906
+ // of it is shown again; see `./navigation-cache.js`.
907
+ clearNavigationCache();
908
+ // A refresh of an intercepted page refreshes both of its halves: the
909
+ // page underneath, resolved again for its own URL, and the interception
910
+ // resolved again over it. Resolving only the address bar's URL would
911
+ // close the modal, which is a navigation nobody asked for.
912
+ const interception = shown.current.interception;
913
+ const nextResolved =
914
+ interception == null
915
+ ? await resolveMatch(
916
+ routeTable(),
917
+ (applicationPathOf(window.location.pathname) ?? window.location.pathname) +
918
+ window.location.search,
919
+ )
920
+ : ((await resolveInterception(
921
+ routeTable(),
922
+ await resolveMatch(
923
+ routeTable(),
924
+ interception.base.pathname + interception.base.search,
925
+ ),
926
+ interception.pathname + interception.search,
927
+ )) ?? (await resolveMatch(routeTable(), interception.pathname + interception.search)));
928
+ // No view transition, and it is the one place that is right: a refresh
929
+ // is the same URL resolved again, so a transition would animate a page
930
+ // into itself — a cross-fade between two frames of the same thing,
931
+ // which is a flicker with a name.
932
+ startTransition(() => {
933
+ show(nextResolved);
934
+ });
935
+ },
936
+ back: () => {
937
+ if (isBrowser()) {
938
+ window.history.back();
939
+ }
940
+ },
941
+ forward: () => {
942
+ if (isBrowser()) {
943
+ window.history.forward();
944
+ }
945
+ },
946
+ };
947
+
948
+ const value: RouterState = {
949
+ route: routeState(resolved),
950
+ view: { kind: "modules", resolved },
951
+ router,
952
+ pending,
953
+ navigation,
954
+ };
955
+ return <RouterContext.Provider value={value}>{children}</RouterContext.Provider>;
956
+ }
957
+
958
+ /**
959
+ * The provider for a route React Server Components rendered.
960
+ *
961
+ * What it holds is the payload rather than a resolved route: `use` reads its
962
+ * root — the route a hook reads and the tree `RouteView` renders — and a
963
+ * navigation fetches the next route's payload and swaps the promise. The
964
+ * browser resolves nothing and imports no page, layout or loader; the server
965
+ * did all three, and a component that needs the browser arrived as a client
966
+ * reference inside the tree.
967
+ *
968
+ * A navigation reads the next payload's root before it commits, for the reason
969
+ * [`ModuleRouter`] resolves the next route before it commits: the page on
970
+ * screen stays interactive while the next one is on its way, and a commit
971
+ * inside a view transition is synchronous, so a root that had not arrived would
972
+ * show nothing rather than the page being left. What may still suspend after
973
+ * the commit is a `$loading.js` boundary inside the new tree, which is what that
974
+ * file is for.
975
+ */
976
+ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
977
+ const [current, setCurrent] = useState<Promise<FlightRoot>>(flight);
978
+ const [pending, setPending] = useState<boolean>(false);
979
+ const root = use(current);
980
+ const shown = useRef<FlightRoot>(root);
981
+ const show = (payload: Promise<FlightRoot>, nextRoot: FlightRoot) => {
982
+ shown.current = nextRoot;
983
+ setCurrent(payload);
984
+ };
985
+ // Read once per render, for the reason `ModuleRouter` reads it once.
986
+ const navigation = navigationMode();
987
+
988
+ const navigate = async (to: string, options?: NavigateOptions): Promise<void> => {
989
+ if (!isBrowser()) {
990
+ return;
991
+ }
992
+ // A payload URL is an address, so it keeps the base path; the server takes
993
+ // it off.
994
+ const target = new URL(addressOf(to), window.location.href);
995
+ const next = target.pathname + target.search;
996
+ // The browser's job in this application; `ModuleRouter` has the argument.
997
+ if (navigation === "document") {
998
+ if (options?.replace === true) {
999
+ window.location.replace(target.href);
1000
+ } else {
1001
+ window.location.assign(target.href);
1002
+ }
1003
+ return;
1004
+ }
1005
+ setPending(true);
1006
+ try {
1007
+ // The route this page already has while it is fresh, then a prefetch
1008
+ // still in hand, then the network. See `./navigation-cache.js`.
1009
+ const from = flightNavigationOrigin(shown.current);
1010
+ const ordinaryKey = navigationKey(target.pathname, target.search);
1011
+ const key = flightNavigationKey(target.pathname, target.search, from);
1012
+ const fetched = await (
1013
+ flightNavigations.read(key) ??
1014
+ takePrefetched(next, from) ??
1015
+ keepFlight(key, fetchFlight(next, flightFetchOptions(from)), ordinaryKey)
1016
+ );
1017
+ // Not a payload: a redirect off this origin, or a host that has no payload
1018
+ // for this URL. The browser loads it as a document, which is what the
1019
+ // anchor would have done.
1020
+ if (fetched.kind === "document") {
1021
+ window.location.assign(fetched.url);
1022
+ return;
1023
+ }
1024
+ const payload = fetched.root;
1025
+ const nextRoot = await payload;
1026
+ // The URL the payload came from, which is a redirect's target when the
1027
+ // route redirected: the history entry is where the visitor ended up.
1028
+ // In the trailing-slash policy's spelling: a payload URL names its
1029
+ // document without the slash, and the history entry should be the
1030
+ // address the server answers without a redirect.
1031
+ const arrived = new URL(fetched.url, window.location.href);
1032
+ const landed = canonicalAddress(arrived.pathname) + arrived.search + target.hash;
1033
+ const state = historyStateForRoute(nextRoot.route);
1034
+ if (options?.replace === true) {
1035
+ window.history.replaceState(state, "", landed);
1036
+ } else {
1037
+ window.history.pushState(state, "", landed);
1038
+ }
1039
+ const commit = () => {
1040
+ show(payload, nextRoot);
1041
+ setPending(false);
1042
+ };
1043
+ if (options?.transition === false) {
1044
+ startTransition(commit);
1045
+ } else {
1046
+ withViewTransition(nextRoot.route.viewTransition, commit);
1047
+ }
1048
+ const scroll =
1049
+ nextRoot.route.interception == null ? options?.scroll !== false : options?.scroll === true;
1050
+ if (scroll) {
1051
+ if (target.hash !== "") {
1052
+ const element = document.getElementById(target.hash.slice(1));
1053
+ if (element != null) {
1054
+ element.scrollIntoView();
1055
+ return;
1056
+ }
1057
+ }
1058
+ window.scrollTo(0, 0);
1059
+ }
1060
+ } catch (error) {
1061
+ setPending(false);
1062
+ throw error;
1063
+ }
1064
+ };
1065
+
537
1066
  useEffect(() => {
538
- if (!isBrowser) {
1067
+ if (!isBrowser()) {
1068
+ return undefined;
1069
+ }
1070
+ // A document reload rendered the URL's ordinary page. If the browser kept
1071
+ // an intercepted history marker for it, clear that marker before a later
1072
+ // back/forward asks for a modal over a page this document did not show.
1073
+ const restored = window.history.state;
1074
+ if (interceptedFrom(restored) != null) {
1075
+ window.history.replaceState(withoutInterception(restored), "", window.location.href);
1076
+ }
1077
+ // No history entry was pushed, so there is nothing to pop back into; see
1078
+ // `ModuleRouter`.
1079
+ if (navigation === "document") {
539
1080
  return undefined;
540
1081
  }
541
1082
  const onPopState = () => {
542
1083
  const next = window.location.pathname + window.location.search;
543
- resolveMatch(routeTable(), next).then((nextResolved) => {
544
- startTransition(() => {
545
- setResolved(nextResolved);
546
- });
547
- });
1084
+ // The history entry already moved; a payload that cannot be had for it is
1085
+ // a document to load, and a reload is the browser's way to load it. While
1086
+ // this page keeps the route fresh, what it kept is what comes back.
1087
+ const from = interceptedFrom(window.history.state);
1088
+ const ordinaryKey = navigationKey(window.location.pathname, window.location.search);
1089
+ const key = flightNavigationKey(window.location.pathname, window.location.search, from);
1090
+ (
1091
+ flightNavigations.read(key) ??
1092
+ keepFlight(key, fetchFlight(next, flightFetchOptions(from)), ordinaryKey)
1093
+ ).then(
1094
+ (fetched) => {
1095
+ if (fetched.kind === "document") {
1096
+ window.location.reload();
1097
+ return;
1098
+ }
1099
+ const payload = fetched.root;
1100
+ payload.then(
1101
+ (nextRoot) => {
1102
+ withViewTransition(nextRoot.route.viewTransition, () => {
1103
+ show(payload, nextRoot);
1104
+ });
1105
+ },
1106
+ () => {
1107
+ window.location.reload();
1108
+ },
1109
+ );
1110
+ },
1111
+ () => {
1112
+ window.location.reload();
1113
+ },
1114
+ );
548
1115
  };
549
1116
  window.addEventListener("popstate", onPopState);
550
1117
  return () => {
@@ -552,58 +1119,199 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
552
1119
  };
553
1120
  }, []);
554
1121
 
555
- const router = useMemo<Router>(
556
- () => ({
557
- push: (to, options) => navigate(to, options),
558
- replace: (to) => navigate(to, { replace: true }),
559
- prefetch: async (to) => {
560
- if (!isBrowser) {
561
- return;
562
- }
563
- const target = new URL(to, window.location.href);
564
- const matched = matchRoute(routeTable().routes, target.pathname);
565
- if (matched == null) {
566
- return;
567
- }
568
- await Promise.all([
569
- loadOnce(matched.route.page),
570
- ...matched.route.layouts.map((layout) => loadOnce(layout)),
571
- ]);
572
- },
573
- refresh: async () => {
574
- if (!isBrowser) {
575
- return;
576
- }
577
- const nextResolved = await resolveMatch(
578
- routeTable(),
579
- window.location.pathname + window.location.search,
1122
+ const router: Router = {
1123
+ push: (to, options) => navigate(to, options),
1124
+ replace: (to) => navigate(to, { replace: true }),
1125
+ prefetch: async (to) => {
1126
+ // Under document navigation there is no next render in this page to
1127
+ // fetch a payload for; see `ModuleRouter`'s prefetch.
1128
+ if (!isBrowser() || navigation === "document") {
1129
+ return;
1130
+ }
1131
+ const target = new URL(addressOf(to), window.location.href);
1132
+ if (target.origin !== window.location.origin) {
1133
+ return;
1134
+ }
1135
+ const next = target.pathname + target.search;
1136
+ const from = flightNavigationOrigin(shown.current);
1137
+ // Kept for every navigation to it while it is fresh, when a project set
1138
+ // `app.rendering.staleTime`; otherwise held for the one click after it.
1139
+ if (keepsNavigations()) {
1140
+ const ordinaryKey = navigationKey(target.pathname, target.search);
1141
+ const key = flightNavigationKey(target.pathname, target.search, from);
1142
+ await (
1143
+ flightNavigations.read(key) ??
1144
+ keepFlight(key, fetchFlight(next, flightFetchOptions(from)), ordinaryKey)
580
1145
  );
581
- startTransition(() => {
582
- setResolved(nextResolved);
583
- });
584
- },
585
- back: () => {
586
- if (isBrowser) {
587
- window.history.back();
588
- }
589
- },
590
- forward: () => {
591
- if (isBrowser) {
592
- window.history.forward();
593
- }
594
- },
595
- }),
596
- [navigate],
597
- );
1146
+ return;
1147
+ }
1148
+ await prefetchFlight(next, from);
1149
+ },
1150
+ refresh: async () => {
1151
+ if (!isBrowser()) {
1152
+ return;
1153
+ }
1154
+ if (navigation === "document") {
1155
+ window.location.reload();
1156
+ return;
1157
+ }
1158
+ // Everything a navigation kept is older than what this asks for, so none
1159
+ // of it is shown again; see `./navigation-cache.js`.
1160
+ clearNavigationCache();
1161
+ const from = root.route.interception?.from ?? null;
1162
+ const ordinaryKey = navigationKey(window.location.pathname, window.location.search);
1163
+ const fetched = await keepFlight(
1164
+ flightNavigationKey(window.location.pathname, window.location.search, from),
1165
+ fetchFlight(window.location.pathname + window.location.search, flightFetchOptions(from)),
1166
+ ordinaryKey,
1167
+ );
1168
+ if (fetched.kind === "document") {
1169
+ window.location.reload();
1170
+ return;
1171
+ }
1172
+ const payload = fetched.root;
1173
+ const nextRoot = await payload;
1174
+ // No view transition: a refresh is the same URL rendered again. See
1175
+ // `ModuleRouter`'s refresh.
1176
+ startTransition(() => {
1177
+ show(payload, nextRoot);
1178
+ });
1179
+ },
1180
+ back: () => {
1181
+ if (isBrowser()) {
1182
+ window.history.back();
1183
+ }
1184
+ },
1185
+ forward: () => {
1186
+ if (isBrowser()) {
1187
+ window.history.forward();
1188
+ }
1189
+ },
1190
+ };
598
1191
 
599
- const value = useMemo<RouterState>(
600
- () => ({ resolved, router, pending }),
601
- [resolved, router, pending],
602
- );
1192
+ const value: RouterState = {
1193
+ route: root.route,
1194
+ view: { kind: "flight", tree: root.tree },
1195
+ router,
1196
+ pending,
1197
+ navigation,
1198
+ };
603
1199
  return <RouterContext.Provider value={value}>{children}</RouterContext.Provider>;
604
1200
  }
605
1201
 
606
- hook useRouterState(): RouterState {
1202
+ /**
1203
+ * Payloads a `Link` fetched on intent, kept for the navigation that follows it.
1204
+ *
1205
+ * Bounded and short-lived, because a payload is the rendering of a route at one
1206
+ * moment: one old enough to disagree with the server is one a navigation should
1207
+ * not show, and a page with a hundred links hovered over must not hold a hundred
1208
+ * renderings. Taken rather than read, so a prefetched payload serves exactly one
1209
+ * navigation and the next visit to the same URL asks again.
1210
+ */
1211
+ const PREFETCH_LIMIT = 32;
1212
+ const PREFETCH_LIFETIME_MS = 30000;
1213
+ const prefetchedFlights: Map<
1214
+ string,
1215
+ {| readonly fetched: Promise<FetchedFlight>, readonly at: number |},
1216
+ > = new Map();
1217
+
1218
+ /**
1219
+ * `fetched`, kept under `key` for as long as `app.rendering.staleTime` says,
1220
+ * and forgotten again if it turns out not to be a route to show twice: a
1221
+ * request that failed, an answer that was a document rather than a payload, or
1222
+ * a payload React could not read.
1223
+ */
1224
+ function keepFlight(
1225
+ key: string,
1226
+ fetched: Promise<FetchedFlight>,
1227
+ ordinaryKey?: string,
1228
+ ): Promise<FetchedFlight> {
1229
+ if (!keepsNavigations()) {
1230
+ return fetched;
1231
+ }
1232
+ flightNavigations.store(key, fetched);
1233
+ const forget = () => {
1234
+ flightNavigations.forget(key, fetched);
1235
+ if (ordinaryKey != null) {
1236
+ flightNavigations.forget(ordinaryKey, fetched);
1237
+ }
1238
+ };
1239
+ void fetched.then((answer) => {
1240
+ if (answer.kind === "document") {
1241
+ forget();
1242
+ return;
1243
+ }
1244
+ // A route that answered with its error or not-found boundary is shown this
1245
+ // once: asked again, it may have recovered.
1246
+ void answer.root.then((root) => {
1247
+ if (root.route.status !== 200) {
1248
+ forget();
1249
+ return;
1250
+ }
1251
+ if (ordinaryKey != null && root.route.interception == null) {
1252
+ flightNavigations.store(ordinaryKey, fetched);
1253
+ }
1254
+ }, forget);
1255
+ }, forget);
1256
+ return fetched;
1257
+ }
1258
+
1259
+ /**
1260
+ * `resolved`, kept under `key` for as long as `app.rendering.staleTime` says,
1261
+ * and forgotten again if it did not resolve to a page: a loader that threw or
1262
+ * redirected, or a route that answered with its error or not-found boundary.
1263
+ */
1264
+ function keepRoute(key: string, resolved: Promise<ResolvedRoute>): Promise<ResolvedRoute> {
1265
+ if (!keepsNavigations()) {
1266
+ return resolved;
1267
+ }
1268
+ routeNavigations.store(key, resolved);
1269
+ const forget = () => {
1270
+ routeNavigations.forget(key, resolved);
1271
+ };
1272
+ void resolved.then((route) => {
1273
+ if (route.status !== 200) {
1274
+ forget();
1275
+ }
1276
+ }, forget);
1277
+ return resolved;
1278
+ }
1279
+
1280
+ function prefetchedFlightKey(url: string, from: ?string): string {
1281
+ return from == null ? url : `${url}\0${from}`;
1282
+ }
1283
+
1284
+ function prefetchFlight(url: string, from: ?string): Promise<FetchedFlight> {
1285
+ const key = prefetchedFlightKey(url, from);
1286
+ const existing = prefetchedFlights.get(key);
1287
+ if (existing != null && Date.now() - existing.at < PREFETCH_LIFETIME_MS) {
1288
+ return existing.fetched;
1289
+ }
1290
+ if (existing == null && prefetchedFlights.size >= PREFETCH_LIMIT) {
1291
+ const oldest = prefetchedFlights.keys().next();
1292
+ if (oldest.done !== true) {
1293
+ prefetchedFlights.delete(oldest.value);
1294
+ }
1295
+ }
1296
+ const fetched = fetchFlight(url, flightFetchOptions(from));
1297
+ prefetchedFlights.set(key, { fetched, at: Date.now() });
1298
+ fetched.catch(() => {
1299
+ prefetchedFlights.delete(key);
1300
+ });
1301
+ return fetched;
1302
+ }
1303
+
1304
+ function takePrefetched(url: string, from: ?string): Promise<FetchedFlight> | null {
1305
+ const key = prefetchedFlightKey(url, from);
1306
+ const entry = prefetchedFlights.get(key);
1307
+ prefetchedFlights.delete(key);
1308
+ if (entry == null || Date.now() - entry.at >= PREFETCH_LIFETIME_MS) {
1309
+ return null;
1310
+ }
1311
+ return entry.fetched;
1312
+ }
1313
+
1314
+ export hook useRouterState(): RouterState {
607
1315
  const state = useContext(RouterContext);
608
1316
  if (state == null) {
609
1317
  throw new Error(
@@ -615,118 +1323,452 @@ hook useRouterState(): RouterState {
615
1323
 
616
1324
  /** The current route. */
617
1325
  export hook useRoute(): RouteInfo {
618
- const { resolved, pending } = useRouterState();
1326
+ const { route, pending } = useRouterState();
619
1327
  return {
620
- path: resolved.path,
621
- pathname: resolved.pathname,
622
- params: resolved.params,
623
- searchParams: resolved.searchParams,
624
- data: resolved.data,
1328
+ path: route.path,
1329
+ pathname: route.pathname,
1330
+ params: route.params,
1331
+ searchParams: route.searchParams,
1332
+ data: useResolvedData(route),
625
1333
  pending,
626
1334
  };
627
1335
  }
628
1336
 
1337
+ /**
1338
+ * The loader's answer, waiting for it if the router deferred it.
1339
+ *
1340
+ * Both hooks that expose the data go through here, and both therefore suspend
1341
+ * when the answer is not in yet. That is the conservative choice rather than
1342
+ * the clever one: the alternative is handing back `undefined` for a value that
1343
+ * is on its way, which is a page reading a field that is about to exist and
1344
+ * finding nothing there, with nothing anywhere to say why.
1345
+ *
1346
+ * Suspending costs a caller *above* the innermost `<Suspense>` — a layout, a
1347
+ * masthead — the streaming it would otherwise have got, because React holds the
1348
+ * shell for a component that suspends with no boundary above it. That is
1349
+ * exactly what such a route did before the loader could be deferred at all, so
1350
+ * it is a benefit not taken rather than a regression, and it is visible: the
1351
+ * fallback does not appear.
1352
+ */
1353
+ hook useResolvedData(route: RouteState): mixed {
1354
+ const loader = route.deferred;
1355
+ return loader == null ? route.data : use(loader);
1356
+ }
1357
+
629
1358
  /** Navigation. */
630
1359
  export hook useRouter(): Router {
631
1360
  return useRouterState().router;
632
1361
  }
633
1362
 
634
- /** The current page's loader data. */
635
- export hook useLoaderData<T>(): T {
636
- // $FlowFixMe[unclear-type] loader data is typed by the page that declares the loader.
637
- return useRouterState().resolved.data as any;
1363
+ /**
1364
+ * The current page's loader data.
1365
+ *
1366
+ * `mixed`, so the page that reads it says what it is and the checker watches
1367
+ * it do so. This was `useLoaderData<T>(): T`, which looks like inference and
1368
+ * is a cast a caller writes at a distance: `useLoaderData<Post>()` asserted
1369
+ * that a loader three files away returned a `Post` and nothing anywhere
1370
+ * checked it, so a loader that changed shape produced a `Post`-shaped
1371
+ * `undefined` at the first property read rather than an error where the shape
1372
+ * was decided.
1373
+ *
1374
+ * Narrowing is a line at the top of the page — `if (typeof data !== "object"
1375
+ * || data == null) { … }`, or the page's own validator schema, which is what
1376
+ * `@uniflowed/validator` is for at exactly this boundary.
1377
+ *
1378
+ * The type that would need no narrowing is a *generated* one: the route table
1379
+ * already produces `RoutePath` and `RouteParams` from the `app/` directory
1380
+ * (`crates/uf_router/src/lib.rs`), and a loader's return type belongs in the
1381
+ * same file, keyed by route. Until it is there, this says what is true.
1382
+ */
1383
+ export hook useLoaderData(): mixed {
1384
+ return useResolvedData(useRouterState().route);
638
1385
  }
639
1386
 
1387
+ /**
1388
+ * Whether this bundle marks the boundaries it renders.
1389
+ *
1390
+ * `import.meta.hot` is the same gate `../client.js` uses for the hydration
1391
+ * report and the DevTools check, chosen there for the reason it is chosen here:
1392
+ * Vite defines it while serving and replaces it with `undefined` in a build, so
1393
+ * every branch below is statically dead in a production bundle and the module
1394
+ * behind it — `@uniflowed/router` is `sideEffects: false` — is dropped rather
1395
+ * than shipped unused. Node leaves it undefined, so a host that imports this
1396
+ * file without a bundler gets the production path, and so does the test suite.
1397
+ *
1398
+ * It is a module constant rather than a per-render question because the branch
1399
+ * has to be foldable, and it may answer differently in the browser and on the
1400
+ * server without costing anything: a mark renders nothing until it has mounted,
1401
+ * so neither the server's markup nor the tree React hydrates against it can
1402
+ * contain one. See `./boundaries.js`, which has the argument.
1403
+ */
1404
+ const BOUNDARY_MARKS: boolean = import.meta.hot != null;
1405
+
640
1406
  /**
641
1407
  * Renders the matched page inside its layouts, innermost last, with the
642
1408
  * document metadata as hoistable head elements.
1409
+ *
1410
+ * The walk itself is `composeRoute` in `./compose.js`, which has the whole
1411
+ * argument for where each boundary goes. What is left here is the half that
1412
+ * reads the router: which route, the page element that carries the loader's
1413
+ * answer and the payload the browser hydrates it from, and — under `uf dev` —
1414
+ * the boundary marks and the report that watches them. See ubugeeei-prod/uf#520.
643
1415
  */
644
1416
  export component RouteView() {
645
- const { resolved } = useRouterState();
646
- const Page = pageComponent(resolved.page);
647
- let element: React.Node = (
648
- <Page params={resolved.params} searchParams={resolved.searchParams} data={resolved.data} />
649
- );
650
- for (let index = resolved.layouts.length - 1; index >= 0; index -= 1) {
651
- const Layout = layoutComponent(resolved.layouts[index]);
652
- element = <Layout params={resolved.params}>{element}</Layout>;
1417
+ const { view } = useRouterState();
1418
+ // A tree a server composed for React Server Components is the whole of it:
1419
+ // the boundaries, the fallbacks, the marks and the head were placed by
1420
+ // `composeRoute` on the server, before any of it was written into the payload.
1421
+ if (view.kind === "flight") {
1422
+ return view.tree;
653
1423
  }
1424
+ const resolved = view.resolved;
1425
+ const loader = resolved.deferred;
1426
+ // The route's boundaries, named once and read by both the marks the
1427
+ // composition places and the report that watches them. `installedTable`
1428
+ // rather than [`routeTable`], which throws: a test may render this view
1429
+ // without an entry having installed a table, and an error boundary named by
1430
+ // its depth alone is worth less than one named by its file rather than wrong.
1431
+ const marks = BOUNDARY_MARKS
1432
+ ? routeBoundaries(
1433
+ resolved,
1434
+ nearestBoundary(installedTable?.errors ?? [], resolved.pathname)?.file,
1435
+ )
1436
+ : null;
1437
+ const page =
1438
+ loader == null ? <RenderedPage data={resolved.data} /> : <AwaitedPage loader={loader} />;
654
1439
  return (
655
1440
  <>
656
- <Head metadata={resolved.metadata} />
657
- {element}
1441
+ {composeRoute(resolved, { page, marks })}
1442
+ {/* After the tree rather than before it, so its effect runs once every
1443
+ mark below has had its own — which is the commit the marks are in. */}
1444
+ {BOUNDARY_MARKS && marks != null ? (
1445
+ <BoundaryReporter path={resolved.path} boundaries={marks} />
1446
+ ) : null}
658
1447
  </>
659
1448
  );
660
1449
  }
661
1450
 
662
1451
  /**
663
- * The component a page module renders: its default export, or the named
664
- * `Page` that `uf create` scaffolds. An MDX page always has a default export.
1452
+ * The page, with the loader's answer and the copy of it the browser hydrates
1453
+ * from.
1454
+ *
1455
+ * The two are rendered together because they are one fact told twice, and
1456
+ * anything that could put them out of step is a page whose first client render
1457
+ * disagrees with the document it was sent. Being one component is what keeps
1458
+ * the script in the same position in the tree on both sides — inside the
1459
+ * innermost `<Suspense>` when the route deferred its loader on the server, and
1460
+ * exactly there again on the client, where the data is already in hand and
1461
+ * nothing suspends at all.
1462
+ *
1463
+ * "The loader's answer" is now a payload rather than a value, so what
1464
+ * [`payloadElements`] renders is that script plus one boundary per value the
1465
+ * answer deferred. The same argument covers all of them: the browser's copy of
1466
+ * this component renders the same rows in the same places, from the values it
1467
+ * read out of those very elements.
665
1468
  */
666
- function pageComponent(module: PageModule): React.ComponentType<any> {
667
- const component = module.default ?? module.Page;
668
- if (component == null) {
669
- throw new Error(
670
- "@uniflowed/router: a page module must export a component as `default` or `Page`",
671
- );
1469
+ component RenderedPage(data: mixed) {
1470
+ const { view } = useRouterState();
1471
+ // Only ever rendered by `RouteView` for a route resolved from its modules.
1472
+ if (view.kind !== "modules") {
1473
+ return null;
672
1474
  }
673
- return component;
1475
+ const resolved = view.resolved;
1476
+ const Page = pageComponent(resolved.page);
1477
+ // The route module's own export, looked up by route: `pageComponent` hands
1478
+ // back its `default` or `Page` as it is, so this is the same component on
1479
+ // every render of the same route. The React Compiler cannot see through the
1480
+ // lookup and reports a component created during render. The block form,
1481
+ // because the finding is on a JSX child and a `//` comment cannot stand
1482
+ // between JSX children without becoming text.
1483
+ // uf-lint-disable react-compiler/static-components
1484
+ return (
1485
+ <>
1486
+ <Page params={resolved.params} searchParams={resolved.searchParams} data={data} />
1487
+ {payloadElements(data)}
1488
+ </>
1489
+ );
1490
+ // uf-lint-enable react-compiler/static-components
674
1491
  }
675
1492
 
676
- /** The component a layout module renders: `default`, or the named `Layout`. */
677
- function layoutComponent(module: LayoutModule): React.ComponentType<any> {
678
- const component = module.default ?? module.Layout;
679
- if (component == null) {
680
- throw new Error(
681
- "@uniflowed/router: a layout module must export a component as `default` or `Layout`",
682
- );
683
- }
684
- return component;
1493
+ /**
1494
+ * The same page, once the loader the router deferred has answered.
1495
+ *
1496
+ * A component of its own rather than a `use` guarded by an `if` inside
1497
+ * [`RenderedPage`], so the call is unconditional where it is written: this one
1498
+ * is rendered only when there is a promise, and `RouteView` chooses between
1499
+ * them. `use` may legally be called conditionally, and code that reads as
1500
+ * though it may not is worth avoiding anyway.
1501
+ */
1502
+ component AwaitedPage(loader: Promise<mixed>) {
1503
+ return <RenderedPage data={use(loader)} />;
685
1504
  }
686
1505
 
687
- component Head(metadata: Metadata) {
688
- const { title, description, openGraph } = metadata;
1506
+ /**
1507
+ * The loader's answer, embedded for the browser to hydrate from.
1508
+ *
1509
+ * In the tree rather than in the head, which is the third of the three options
1510
+ * ubugeeei-prod/uf#373 weighed and the only one that survives a deferred
1511
+ * loader. `server.js` wrote this into the head from the resolved route, and a
1512
+ * deferred answer does not exist when the head goes out — losing it would mean
1513
+ * every deferred route's loader running a second time in the browser, on the
1514
+ * way in, for data the document already contained.
1515
+ *
1516
+ * The two rejected options are worth naming. Writing it at the end of the body
1517
+ * from outside React would have worked — uf's client entry is a module script,
1518
+ * so it runs after parsing either way — but it would be markup inside the
1519
+ * hydration root that React did not render, which is the definition of a
1520
+ * mismatch. Emitting it through `bootstrapScriptContent` as a global is
1521
+ * React's own documented pattern and costs the one property this element has
1522
+ * that matters: `application/json` is data a browser does not execute, and a
1523
+ * script that is executed is a script a content security policy has to allow.
1524
+ *
1525
+ * `<` is escaped inside the JSON so a string holding `</script>` cannot end the
1526
+ * element early, and U+2028 and U+2029 because a JSON document is not
1527
+ * JavaScript source but is sometimes read as if it were — the escape moved to
1528
+ * `./payload.js` when the model stopped being the only thing written that way.
1529
+ * `dangerouslySetInnerHTML` rather than a text child because React escapes a
1530
+ * text child and `&quot;` is not JSON any more. `security/no-dangerously-set-
1531
+ * inner-html` is about markup that came from somewhere and has to be sanitized
1532
+ * before a browser parses it as HTML; this is `JSON.stringify`'s output with
1533
+ * `<` escaped, in an element the browser never parses as HTML and never runs.
1534
+ * `docs/app/$layout.js` carries the same suppression for the same reason.
1535
+ *
1536
+ * # And the rows the model deferred
1537
+ *
1538
+ * A promise anywhere in the loader's answer used to be `JSON.stringify`'d to
1539
+ * `{}`. It is now a `"$P<n>"` reference in the element above and a `<script
1540
+ * data-uf-row="n">` of its own, inside a `<Suspense fallback={null}>` — which
1541
+ * is what makes React stream it at the moment the promise settles rather than
1542
+ * holding the document for it. Each row is its own boundary, so two deferred
1543
+ * values arrive in the order they resolved in and not in the order they were
1544
+ * written. `./payload.js` is the format; `./payload-rows.js` is the browser
1545
+ * reading them back.
1546
+ *
1547
+ * The boundaries sit after the page rather than before it, where the data
1548
+ * element already was. A page that suspends with no `$loading.js` above it
1549
+ * holds the whole shell — that is React's rule and uf does not work around it
1550
+ * — so the position buys nothing either way, and "the scripts are where the
1551
+ * script was" is worth more than a rearrangement that is not.
1552
+ *
1553
+ * # Why the rows are inside an element
1554
+ *
1555
+ * Because a `<Suspense>` that is a direct child of the *render root* stops the
1556
+ * shell being flushed at all. React's renderer can only write a segment once
1557
+ * the segment is complete, and the root segment holds an unresolved boundary
1558
+ * open: measured against React 19.2.8, a tree of `[<div>, <Suspense>]` writes
1559
+ * its first byte when the boundary resolves, and the same tree with the
1560
+ * boundary inside any host element writes it immediately. Every component
1561
+ * between the root and here — `RenderProvider`, `RouterProvider`, `RouteView`,
1562
+ * `RouteErrorBoundary` — renders no element of its own, so without this
1563
+ * `<span>` the rows would be exactly that first shape and a payload would have
1564
+ * streamed nothing.
1565
+ *
1566
+ * `hidden` because it holds no content a reader is meant to see: `<script
1567
+ * type="application/json">` renders nothing either way, and the attribute is
1568
+ * what says so to anything that inspects the document. One element for all the
1569
+ * rows rather than one each — the boundaries inside it still resolve
1570
+ * independently, since each is its own.
1571
+ *
1572
+ * The same rule catches a route whose `$loading.js` sits above no layout, so
1573
+ * `RouteView` wraps that specific root shape in `RootStreamFrame`.
1574
+ */
1575
+ function payloadElements(data: mixed): React.Node {
1576
+ if (data === undefined) {
1577
+ return null;
1578
+ }
1579
+ const { model, rows } = encodePayload(data, "the route's loader data");
1580
+ // Before anything renders, so a promise that has already rejected is one
1581
+ // somebody is listening to. `settledRow` is memoized, so the components
1582
+ // below get these same promises rather than a second set.
1583
+ for (const row of rows) {
1584
+ settledRow(row.value);
1585
+ }
1586
+ const html = { __html: payloadJson(model) };
1587
+ // uf-lint-disable-next-line security/no-dangerously-set-inner-html
1588
+ const row0 = <script id={DATA_ID} type="application/json" dangerouslySetInnerHTML={html} />;
689
1589
  return (
690
1590
  <>
691
- {title != null ? <title>{title}</title> : null}
692
- {description != null ? <meta name="description" content={description} /> : null}
693
- {openGraph?.title != null ? <meta property="og:title" content={openGraph.title} /> : null}
694
- {openGraph?.description != null ? (
695
- <meta property="og:description" content={openGraph.description} />
696
- ) : null}
697
- {openGraph?.images != null
698
- ? openGraph.images.map((image) => (
699
- <meta key={image} property="og:image" content={image} />
700
- ))
701
- : null}
1591
+ {row0}
1592
+ {rows.length === 0 ? null : (
1593
+ <span hidden>
1594
+ {rows.map((row) => (
1595
+ <Suspense key={row.id} fallback={null}>
1596
+ <PayloadRow id={row.id} value={row.value} />
1597
+ </Suspense>
1598
+ ))}
1599
+ </span>
1600
+ )}
702
1601
  </>
703
1602
  );
704
1603
  }
705
1604
 
1605
+ /**
1606
+ * One deferred value, written when it settles.
1607
+ *
1608
+ * Rendered on both sides, which is the thing to keep in mind about it. On the
1609
+ * server `value` is the loader's own promise; in the browser it is the promise
1610
+ * `./payload-rows.js` created for this row and resolved out of this very
1611
+ * element. Both then write the element from the settled result through the
1612
+ * same [`payloadJson`], so the bytes agree and hydration has nothing to
1613
+ * report. `encodeRowValue` is what re-applies the reference escape to a value
1614
+ * the browser has already had it removed from.
1615
+ *
1616
+ * It never rejects. `use` on a rejected promise throws, and a throw here would
1617
+ * put the *row's* boundary into the error boundary above it — which is the
1618
+ * page, for a value the page may not even be reading. The failure travels as a
1619
+ * row instead, and the page's own `use` of the same promise is what reaches
1620
+ * the page's boundary, exactly as it would have without a payload.
1621
+ */
1622
+ component PayloadRow(id: number, value: Promise<mixed>) {
1623
+ const message = use(settledRow(value));
1624
+ const html = { __html: payloadJson(message) };
1625
+ return (
1626
+ // uf-lint-disable-next-line security/no-dangerously-set-inner-html
1627
+ <script type="application/json" data-uf-row={String(id)} dangerouslySetInnerHTML={html} />
1628
+ );
1629
+ }
1630
+
1631
+ /**
1632
+ * The message a row will carry, as a promise that always fulfils.
1633
+ *
1634
+ * Keyed by the promise rather than recomputed, because `use` wants the same
1635
+ * promise every render and a render is repeated: React renders a component
1636
+ * again after it suspends, and Strict Mode renders it twice more. A `WeakMap`
1637
+ * so a route that has navigated away takes its rows with it.
1638
+ */
1639
+ const settledRows: WeakMap<Promise<mixed>, Promise<PayloadRowMessage>> = new WeakMap();
1640
+
1641
+ function settledRow(value: Promise<mixed>): Promise<PayloadRowMessage> {
1642
+ const existing = settledRows.get(value);
1643
+ if (existing != null) {
1644
+ return existing;
1645
+ }
1646
+ const settled = value.then(
1647
+ (resolved) => ({ value: encodeRowValue(resolved, "a deferred value") }),
1648
+ (error) => ({ error: rowFailure(error) }),
1649
+ );
1650
+ settledRows.set(value, settled);
1651
+ return settled;
1652
+ }
1653
+
1654
+ /**
1655
+ * What a row says when the value failed.
1656
+ *
1657
+ * A fixed sentence in a build, and the error's own words where
1658
+ * `import.meta.hot` says a developer is reading them — the same gate
1659
+ * [`BOUNDARY_MARKS`] uses, and the same argument: a message that came out of a
1660
+ * loader can name a table, a query or a file path, and a browser is not where
1661
+ * any of those belong.
1662
+ *
1663
+ * A `PayloadRowError` short-circuits both, and has to. That error is what the
1664
+ * browser's reader rejects with, carrying the row's own text, so echoing it is
1665
+ * what makes the element the browser renders equal the one the server sent
1666
+ * whichever of the two builds was the development one.
1667
+ */
1668
+ const ROW_FAILURE = "@uniflowed/router: a deferred value failed on the server.";
1669
+
1670
+ function rowFailure(error: mixed): string {
1671
+ if (error instanceof PayloadRowError) {
1672
+ return error.wire;
1673
+ }
1674
+ if (!BOUNDARY_MARKS) {
1675
+ return ROW_FAILURE;
1676
+ }
1677
+ return error instanceof Error ? `${ROW_FAILURE} ${error.message}` : ROW_FAILURE;
1678
+ }
1679
+
1680
+ /**
1681
+ * Head elements a component contributes while it is rendering.
1682
+ *
1683
+ * `metadata` and `generateMetadata` are how a *route* says what it is, and
1684
+ * both are resolved before anything renders — which is what makes them work
1685
+ * for a crawler that runs no JavaScript. They are also declarations by the
1686
+ * route module, and part of what a page has to say is decided further in: a
1687
+ * paginated list knows its `prev` and `next` in the component that draws the
1688
+ * pager, and a breadcrumb knows the trail it has just walked.
1689
+ *
1690
+ * So this returns elements rather than writing to the head. Writing would have
1691
+ * to happen in an effect, an effect does not run on a server, and the result
1692
+ * would be a page whose tags are right in a browser and missing from the
1693
+ * crawler — `packages/web/head.js` is that escape hatch and says so at the top
1694
+ * of the file. Rendering is what puts a tag in a server-rendered head, so the
1695
+ * caller renders what comes back:
1696
+ *
1697
+ * export component Pager(page: number, of: number) {
1698
+ * const seo = useSeo({
1699
+ * pagination: {
1700
+ * prev: page > 1 ? `/posts?page=${page - 1}` : undefined,
1701
+ * next: page < of ? `/posts?page=${page + 1}` : undefined,
1702
+ * },
1703
+ * });
1704
+ * return <nav className="pager">{seo}…</nav>;
1705
+ * }
1706
+ *
1707
+ * The argument is a `Metadata` — the same type a route exports — because there
1708
+ * is one vocabulary for what a page says about itself, and a second one would
1709
+ * be a second place for it to be wrong. What this adds over rendering the tags
1710
+ * by hand is the thing a component three levels down cannot know:
1711
+ * `metadataBase`, which the root layout declared, and against which the
1712
+ * relative URLs written here are resolved.
1713
+ */
1714
+ export hook useSeo(seo: Metadata): React.Node {
1715
+ const { route } = useRouterState();
1716
+ const base = seo.metadataBase ?? route.metadata.metadataBase;
1717
+ return <Head metadata={base == null ? seo : { ...seo, metadataBase: base }} />;
1718
+ }
1719
+
706
1720
  /** When a `Link` loads the route it points at. */
707
1721
  export type LinkPrefetch = "off" | "intent" | "render";
708
1722
 
1723
+ const LinkStatusContext: React.Context<boolean> = createContext(false);
1724
+
1725
+ /** Whether the containing Link is waiting for the navigation it started. */
1726
+ export hook useLinkStatus(): {| readonly pending: boolean |} {
1727
+ return { pending: useContext(LinkStatusContext) };
1728
+ }
1729
+
709
1730
  /**
710
1731
  * A client-side navigation.
711
1732
  *
712
1733
  * Renders a real anchor, so the link works before hydration and for a right
713
1734
  * click, and takes over only a plain left click. `prefetch="intent"` (the
714
- * default) loads the destination's chunks on hover or focus.
1735
+ * default) loads the destination's chunks on hover or focus, and
1736
+ * `transition={false}` makes this one navigation a cut — most navigations are
1737
+ * a link, so the opt-out in [`NavigateOptions`] has to be reachable from one.
1738
+ *
1739
+ * # Under `app.rendering.navigation: "document"` it is only the anchor
1740
+ *
1741
+ * No click handler of uf's, no `preventDefault`, no prefetch listeners: the
1742
+ * element the browser gets is the one it would have got from `<a href>` in the
1743
+ * source. That is the whole of what changing the mode does to a component,
1744
+ * which is the point — a project moving between the two rewrites its
1745
+ * `uf.config.js` and none of its pages, and a component library built on
1746
+ * `Link` works in both without knowing which it is in.
1747
+ *
1748
+ * It matters that the handler is *absent* rather than a handler that calls
1749
+ * `location.assign`. The two look the same for a left click and are not the
1750
+ * same link: `preventDefault` and a scripted navigation lose `download`, lose
1751
+ * a `target`, and change what the browser does with a middle click and with a
1752
+ * gesture uf has not heard of. An ordinary link is not an approximation of an
1753
+ * ordinary link.
715
1754
  */
716
1755
  export component Link(
717
1756
  to: string,
718
1757
  prefetch?: LinkPrefetch = "intent",
719
1758
  replace?: boolean = false,
1759
+ transition?: boolean = true,
720
1760
  children?: React.Node,
721
1761
  className?: string,
722
1762
  onClick?: (event: SyntheticMouseEvent<HTMLAnchorElement>) => mixed,
723
1763
  ...rest: { readonly [string]: mixed }
724
1764
  ) {
725
- const router = useRouter();
726
- const prefetched = React.useRef(false);
1765
+ const { router, navigation } = useRouterState();
1766
+ const [linkPending, startLinkTransition] = useTransition();
1767
+ const prefetched = useRef(false);
1768
+ const drives = navigation === "client";
727
1769
 
728
1770
  const doPrefetch = () => {
729
- if (prefetch === "off" || prefetched.current || isExternal(to)) {
1771
+ if (!drives || prefetch === "off" || prefetched.current || isExternal(to)) {
730
1772
  return;
731
1773
  }
732
1774
  prefetched.current = true;
@@ -750,28 +1792,43 @@ export component Link(
750
1792
  event.ctrlKey ||
751
1793
  event.shiftKey ||
752
1794
  event.altKey ||
1795
+ (rest.target != null && rest.target !== "_self") ||
1796
+ rest.download != null ||
753
1797
  isExternal(to)
754
1798
  ) {
755
1799
  return;
756
1800
  }
757
1801
  event.preventDefault();
758
- router.push(to, { replace }).catch((error) => {
759
- // A failed navigation falls back to the browser doing it.
760
- console.error(error);
761
- window.location.assign(to);
1802
+ startLinkTransition(async () => {
1803
+ try {
1804
+ await router.push(to, { replace, transition });
1805
+ } catch (error) {
1806
+ // A failed navigation falls back to the browser doing it.
1807
+ console.error(error);
1808
+ window.location.assign(addressOf(to));
1809
+ }
762
1810
  });
763
1811
  };
764
1812
 
1813
+ // The caller's own `onClick` still runs under document navigation — it is
1814
+ // theirs, and an application that closes a menu when a link is clicked is
1815
+ // not asking uf to take the navigation over — so it is passed through rather
1816
+ // than dropped with the rest of the behaviour.
765
1817
  return (
766
1818
  <a
767
1819
  {...rest}
768
- href={to}
1820
+ // `to` is an application path; the anchor is the address, with the base
1821
+ // path in front and the trailing-slash policy's spelling, so a link that
1822
+ // works before hydration and for a right click goes where this one does.
1823
+ href={addressOf(to)}
769
1824
  className={className}
770
- onClick={handleClick}
771
- onMouseEnter={prefetch === "intent" ? doPrefetch : undefined}
772
- onFocus={prefetch === "intent" ? doPrefetch : undefined}
1825
+ aria-busy={linkPending || undefined}
1826
+ data-pending={linkPending ? "" : undefined}
1827
+ onClick={drives ? handleClick : onClick}
1828
+ onMouseEnter={drives && prefetch === "intent" ? doPrefetch : undefined}
1829
+ onFocus={drives && prefetch === "intent" ? doPrefetch : undefined}
773
1830
  >
774
- {children}
1831
+ <LinkStatusContext value={linkPending}>{children}</LinkStatusContext>
775
1832
  </a>
776
1833
  );
777
1834
  }
@@ -786,34 +1843,42 @@ function isExternal(to: string): boolean {
786
1843
  * The argument documents where the routes live; the table itself is generated
787
1844
  * from that directory at build time and installed by the entry that starts
788
1845
  * the app, so the component only has to render it.
1846
+ *
1847
+ * # Why the render anchor is here
1848
+ *
1849
+ * `RenderProvider` fixes the render's instant, time zone and random seed once,
1850
+ * writes them into the markup and reads them back on the client, which is what
1851
+ * makes `useRenderedAt` and `useRandom` agree across hydration. An application
1852
+ * that did not render one got no error — it got the old behaviour, which is a
1853
+ * silent hydration mismatch in every page with a clock or a shuffle on it. A
1854
+ * guarantee that depends on remembering to opt in is not one, so the router
1855
+ * provides it and an application that wants different values *replaces* it by
1856
+ * rendering its own inside this one. See ubugeeei-prod/uf#559.
1857
+ *
1858
+ * Above `RouterProvider` rather than below it, because the route's own
1859
+ * modules — layouts as much as pages — are things that read a clock, and a
1860
+ * masthead showing the time is the first component anybody writes that does.
1861
+ *
1862
+ * It is safe above a root layout that renders `<html>` only because the
1863
+ * envelope's carrier is a `<meta>`: React hoists one into the head of a
1864
+ * document it rendered, and to the front of a tree that is not one, where uf's
1865
+ * shell lifts it into the head it wrote itself. `packages/hooks/render.js` has
1866
+ * the argument, and it is the reason the carrier is no longer a `<script>`.
789
1867
  */
790
1868
  export function routerView(root: string): React.ComponentType<AppProps> {
791
1869
  void root;
792
- component App(url: string, initial: ResolvedRoute) {
1870
+ component App(url: string, initial?: ResolvedRoute, flight?: Promise<FlightRoot>) {
793
1871
  return (
794
- <RouterProvider url={url} initial={initial}>
795
- <RouteView />
796
- </RouterProvider>
1872
+ <RenderProvider>
1873
+ <RouterProvider url={url} initial={initial} flight={flight}>
1874
+ <RouteView />
1875
+ </RouterProvider>
1876
+ </RenderProvider>
797
1877
  );
798
1878
  }
799
1879
  return App;
800
1880
  }
801
1881
 
802
- /** Stop rendering the current page and show the not-found page instead. */
803
- export function notFound(): empty {
804
- throw new NotFoundError();
805
- }
806
-
807
- /** Stop rendering the current page and send the visitor elsewhere. */
808
- export function redirect(to: string): empty {
809
- throw new RedirectError(to, false);
810
- }
811
-
812
- /** `redirect`, with a permanent status. */
813
- export function permanentRedirect(to: string): empty {
814
- throw new RedirectError(to, true);
815
- }
816
-
817
1882
  /**
818
1883
  * Whether the app is being rendered on the server.
819
1884
  *