@uniflowed/router 0.0.0-alpha.9 → 0.1.0

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