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

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