@uniflowed/router 0.0.0-alpha.33 → 0.0.0-alpha.35

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.
@@ -1,13 +1,24 @@
1
1
  // @flow
2
2
  //
3
- // The router runtime: matching, loading, navigation, and the React binding.
3
+ // The router runtime: the browser's binding.
4
+ //
5
+ // A client module, and the directive is load-bearing rather than descriptive:
6
+ // in the module graph React Server Components render in, every export of this
7
+ // file is a client reference — `Link` renders as markup on the server and runs
8
+ // in the browser — and `../server-components.js` is what that graph gets for
9
+ // the hooks instead. Everywhere else the directive changes nothing.
4
10
  //
5
11
  // A route table is data — the virtual module `virtual:uf/routes` that
6
12
  // `@uniflowed/vite` generates from the `app/` directory — and this module is
7
- // everything that turns it into a running application. The same code runs on
8
- // the server (`./server.js` renders one URL) and in the browser (`./client.js`
9
- // hydrates it and then navigates), so a page's loader, layouts and metadata
10
- // resolve identically in both places.
13
+ // what turns it into a running application in a page: the provider that holds
14
+ // the current route, the hooks that read it, navigation, view transitions and
15
+ // `Link`. What a URL resolves to is `./resolve.js`, the tree a resolved route
16
+ // renders is `./compose.js`, and the metadata elements are `./head.js`; the
17
+ // three are split out because none of them may reach a hook, a context or a
18
+ // class component, which is what lets a server graph resolved under React's
19
+ // `react-server` condition import them (ubugeeei-prod/uf#519).
20
+
21
+ "use client";
11
22
 
12
23
  import * as React from "react";
13
24
  import {
@@ -60,41 +71,22 @@ import {
60
71
  // safe here where `../client.js` needs a dynamic one — a component cannot be
61
72
  // awaited in the middle of a render, and `false` folds the references away
62
73
  // before the bundler is asked to keep the module. See [`BOUNDARY_MARKS`].
74
+ import { BoundaryReporter } from "./boundaries.js";
75
+ import { routeBoundaries } from "./boundary-data.js";
76
+ import { composeRoute, pageComponent } from "./compose.js";
77
+ import { type FetchedFlight, fetchFlight } from "./flight-browser.js";
78
+ import { type FlightRoot, type RouteState, routeState } from "./flight.js";
79
+ import { Head } from "./head.js";
80
+ import { hasClientPage, matchRoute, nearestBoundary } from "./routing.js";
81
+ import type { RouteParams, SearchParams } from "./routing.js";
63
82
  import {
64
- BoundaryReporter,
65
- ROOT_ERROR_ID,
66
- ROUTE_ERROR_ID,
67
- insideBoundary,
68
- routeBoundaries,
69
- suspenseId,
70
- } from "./boundaries.js";
71
- import {
72
- ForbiddenError,
73
- NotFoundError,
74
- RedirectError,
75
- UnauthorizedError,
76
- hasClientPage,
77
- matchIn,
78
- matchRoute,
79
- nearestBoundary,
80
- parseSearch,
81
- routeErrorStatus,
82
- splitUrl,
83
- } from "./routing.js";
84
- import type {
85
- ErrorBoundary as RoutingErrorBoundary,
86
- LoadingRecord as RoutingLoadingRecord,
87
- NotFoundBoundary as RoutingNotFoundBoundary,
88
- RouteError,
89
- RouteMatch as RoutingRouteMatch,
90
- RouteParams,
91
- RouteRecord as RoutingRouteRecord,
92
- RouteTable as RoutingRouteTable,
93
- SearchParams,
94
- SlotRecord as RoutingSlotRecord,
95
- SlotRouteRecord as RoutingSlotRouteRecord,
96
- TemplateRecord as RoutingTemplateRecord,
97
- } from "./routing.js";
83
+ beneath,
84
+ interceptingRoutes,
85
+ loadOnce,
86
+ resolveInterception,
87
+ resolveMatch,
88
+ } from "./resolve.js";
89
+ import type { Metadata, ResolvedRoute, RouteTable } from "./resolve.js";
98
90
 
99
91
  export type { RouteError, RouteParamSpec, RouteParams, SearchParams } from "./routing.js";
100
92
 
@@ -107,1465 +99,43 @@ export {
107
99
  forbidden,
108
100
  hasClientPage,
109
101
  matchRoute,
110
- notFound,
111
- parseSearch,
112
- permanentRedirect,
113
- redirect,
114
- routeErrorStatus,
115
- splitUrl,
116
- unauthorized,
117
- } from "./routing.js";
118
-
119
- /**
120
- * A component found in a route module.
121
- *
122
- * `React.ComponentType<empty>` is "some React component", and it is a claim
123
- * rather than a shrug. `ComponentType` is contravariant in its props — Flow's
124
- * library definition writes it `component(...P)` with `in P` — so `empty` is
125
- * the *top* of the component types: every component is one, and nothing may be
126
- * passed to one until a caller has said which props it is passing. That is
127
- * exactly what is known here. The router finds these by dynamic import, and
128
- * nobody has told it what a page's props are.
129
- *
130
- * It cannot be `React.ComponentType<PageRenderProps>`, the props the router
131
- * actually passes, because Flow's `component` syntax gives a component *exact*
132
- * props and a page is free to want none of them. This repository's own pages
133
- * and layouts are `component NotFound()` and
134
- * `component Layout(children: React.Node)`, and against the props the router
135
- * hands them that reads:
136
- *
137
- * error[incompatible-type]: property `data`, property `params`, and
138
- * property `searchParams` are extra in `PageRenderProps` but missing in
139
- * `props of component NotFound`. Exact objects do not accept extra props.
140
- *
141
- * React passing a component a prop it did not declare is allowed and always
142
- * has been. `renderable` is the one line that says so.
143
- */
144
- type RouteComponent = React.ComponentType<empty>;
145
-
146
- /**
147
- * The props `RouteView` gives the page it renders.
148
- *
149
- * The same three as the public `PageProps` in `../index.js`, at the arguments
150
- * the runtime instantiates it with: the runtime knows the parameters as
151
- * strings and the loader's data as `mixed`, and a page narrows both by
152
- * annotating its own props.
153
- */
154
- type PageRenderProps = {|
155
- readonly params: RouteParams,
156
- readonly searchParams: SearchParams,
157
- readonly data: mixed,
158
- |};
159
-
160
- /**
161
- * The props `RouteView` gives each layout, outermost first.
162
- *
163
- * Inexact, and that is the parallel routes reaching the type: a layout on a
164
- * segment that declares `@team` is handed a `team` prop beside `children`, and
165
- * the names are the project's rather than this file's. Every extra prop is a
166
- * `React.Node` — a rendered slot, or `null` when the URL addressed neither the
167
- * slot's routes nor a `$default.js`.
168
- *
169
- * The exactness is not lost so much as moved: what a layout may be *given* is
170
- * open, and what it *declares* is still its own exact props type, which is
171
- * where a typo in a slot name shows up.
172
- */
173
- type LayoutRenderProps = {
174
- readonly params: RouteParams,
175
- readonly children: React.Node,
176
- ...
177
- };
178
-
179
- /** What a page module may export. The component is `default` or `Page`. */
180
- export type PageModule = {
181
- readonly default?: RouteComponent,
182
- readonly Page?: RouteComponent,
183
- readonly loader?: (args: LoaderArgs) => mixed | Promise<mixed>,
184
- readonly metadata?: Metadata,
185
- readonly generateMetadata?: (args: MetadataArgs) => Metadata | Promise<Metadata>,
186
- readonly generateStaticParams?: () =>
187
- | $ReadOnlyArray<RouteParams>
188
- | Promise<$ReadOnlyArray<RouteParams>>,
189
- readonly frontmatter?: { readonly title?: string, readonly description?: string, ... },
190
- /**
191
- * What a stylesheet calls the transition this route arrives under.
192
- *
193
- * Not a switch. Every client navigation opts into a view transition where
194
- * the browser has one, and this is how one arrival is told from another —
195
- * the name reaches CSS as an attribute on the document element for as long
196
- * as the transition runs:
197
- *
198
- * html[data-uf-view-transition="manual"]::view-transition-old(root) { … }
199
- *
200
- * A layout may declare one too, and then it covers every route under it; the
201
- * page's own wins. That is the rule `metadata` already follows, and there is
202
- * no reason for a second one — "the nearest declaration" is how everything
203
- * else in a route module is resolved.
204
- */
205
- readonly viewTransition?: string,
206
- ...
207
- };
208
-
209
- /** What a layout module may export. The component is `default` or `Layout`. */
210
- export type LayoutModule = {
211
- readonly default?: RouteComponent,
212
- readonly Layout?: RouteComponent,
213
- readonly metadata?: Metadata,
214
- /** A transition name for every route under this layout; see [`PageModule`]. */
215
- readonly viewTransition?: string,
216
- ...
217
- };
218
-
219
- /**
220
- * What a template module may export. The component is `default` or `Template`.
221
- *
222
- * A layout's shape without its `metadata`, and the omission is the type saying
223
- * what a template is for. A layout persists across navigation, so a title it
224
- * declares is a claim about a section of the site; a template is thrown away
225
- * and built again on every navigation, so a title on one would be a claim
226
- * about nothing. Titles come from the page and the layouts above it.
227
- */
228
- export type TemplateModule = {
229
- readonly default?: RouteComponent,
230
- readonly Template?: RouteComponent,
231
- ...
232
- };
233
-
234
- /**
235
- * What an error module may export. The component is `default` or `Error`.
236
- *
237
- * `Error` shadows the global inside the file that writes it, which is the
238
- * cost of naming the export after what it is; a file that needs the
239
- * constructor still has `globalThis.Error`. The alternative was a name the
240
- * convention would have to explain — `ErrorPage`, `Boundary` — for a file
241
- * whose whole job is already in its name.
242
- */
243
- export type ErrorModule = {
244
- readonly default?: RouteComponent,
245
- readonly Error?: RouteComponent,
246
- readonly metadata?: Metadata,
247
- ...
248
- };
249
-
250
- /**
251
- * What a loading module may export. The component is `default` or `Loading`.
252
- *
253
- * No `metadata`, and that is the type saying something true rather than an
254
- * omission. A fallback renders while the route is still resolving, and the
255
- * route's metadata was decided before the first byte — a title on a file that
256
- * renders after the head has gone could never be used. `packages/web/head.js`
257
- * documents the same constraint from the other side.
258
- */
259
- export type LoadingModule = {
260
- readonly default?: RouteComponent,
261
- readonly Loading?: RouteComponent,
262
- ...
263
- };
264
-
265
- /**
266
- * How a Twitter card is laid out, which is the whole of what `card` may be.
267
- *
268
- * A union rather than a string: every one of the four is spelled exactly this
269
- * way and a fifth value is silently ignored by the crawler, so a typo in it
270
- * costs a card and produces no error anywhere.
271
- */
272
- export type TwitterCard = "summary" | "summary_large_image" | "app" | "player";
273
-
274
- /**
275
- * What a crawler may do with a page.
276
- *
277
- * Four fields rather than the whole `robots` vocabulary, and the omissions are
278
- * the argument. `index` and `follow` are the two directives a page has an
279
- * opinion about; `maxSnippet` and `maxImagePreview` are the two that change
280
- * what a result *looks* like and have no other spelling. `nosnippet` is not
281
- * here because `maxSnippet: 0` is the same instruction, and a type with two
282
- * ways to say one thing is a type somebody will eventually ask which of them
283
- * wins.
284
- *
285
- * Every field is optional and every one is only emitted when it is declared,
286
- * because "index, follow" is what a document with no `robots` meta already
287
- * says — the tag exists to say something else.
288
- */
289
- export type Robots = {
290
- readonly index?: boolean,
291
- readonly follow?: boolean,
292
- /** The longest snippet a result may quote; `0` is none, `-1` is no limit. */
293
- readonly maxSnippet?: number,
294
- readonly maxImagePreview?: "none" | "standard" | "large",
295
- };
296
-
297
- /**
298
- * One JSON-LD object, as a page hands it over.
299
- *
300
- * `mixed` values rather than a schema.org type, because there is no useful
301
- * middle: the vocabulary is hundreds of types deep, it grows without asking
302
- * anyone, and a partial transcription of it would reject correct documents far
303
- * more often than it caught wrong ones. What this type does claim is the part
304
- * uf is answerable for — that the thing is an object, and therefore that it
305
- * serialises into one `<script>`.
306
- */
307
- export type JsonLd = { readonly [string]: mixed };
308
-
309
- /** Document metadata a page or layout declares. */
310
- export type Metadata = {
311
- readonly title?: string,
312
- readonly description?: string,
313
- /**
314
- * The absolute URL every other URL here is resolved against.
315
- *
316
- * Open Graph and Twitter both require absolute image URLs, and a route
317
- * module has no way to know the host it will be served from — so without
318
- * this, `openGraph.images: ["/og.png"]` ships exactly as written and is not
319
- * a valid `og:image`. Declare it once on the root layout and every
320
- * descendant inherits it through the same merge as everything else.
321
- *
322
- * Resolution is the URL standard's, so `"/og.png"` is resolved against the
323
- * *origin* and `"og.png"` against the base's own path — not against the
324
- * page's URL, which `Head` does not know.
325
- */
326
- readonly metadataBase?: string,
327
- /**
328
- * This page's canonical URL, for `<link rel="canonical">` and `og:url`.
329
- *
330
- * Relative to `metadataBase` when it is not absolute. A page
331
- * reachable at more than one path — a query a filter added, a duplicate
332
- * under a second section — is one page, and this is how it says so.
333
- */
334
- readonly canonical?: string,
335
- /**
336
- * What a crawler may do with this page. See [`Robots`].
337
- *
338
- * The one field here that is usually declared on a *layout*: a staging
339
- * section, a preview tree or an account area is `index: false` for
340
- * everything under it, and saying so once is the only version of that which
341
- * stays true when a page is added.
342
- */
343
- readonly robots?: Robots,
344
- /**
345
- * The other addresses this same page is published at.
346
- *
347
- * `languages` maps a BCP 47 tag to that translation's URL and becomes one
348
- * `<link rel="alternate" hreflang>` each. The set has to be reciprocal —
349
- * every page in it lists every other one *and itself*, which is what makes a
350
- * search engine read them as translations rather than as duplicates — so it
351
- * is usually the same map on every page of the set, declared on the layout
352
- * they share. `"x-default"` is a tag like any other here, and names what a
353
- * reader whose language is not in the set should be given.
354
- *
355
- * Nested under `alternates` rather than sitting at the top level as
356
- * `languages`, because `alternate` is the link relation and a language is
357
- * only one kind of alternate; the outer name is a fact about the wire rather
358
- * than a shape invented here.
359
- */
360
- readonly alternates?: {
361
- readonly languages?: { readonly [string]: string },
362
- },
363
- /**
364
- * The pages either side of this one in a sequence.
365
- *
366
- * `<link rel="prev">` and `<link rel="next">`, resolved against
367
- * `metadataBase` like every other URL here. A page four of a list, and a
368
- * chapter in the middle of a manual, are the same statement: this document
369
- * is one of a series and here is where the series continues.
370
- *
371
- * `canonical` still belongs to the page itself. Pointing every page of a
372
- * paginated list at page one is the mistake this pair exists to make
373
- * unnecessary — it tells a search engine that pages two onwards are
374
- * duplicates of page one, and everything only reachable from them stops
375
- * being reachable at all.
376
- */
377
- readonly pagination?: {
378
- readonly prev?: string,
379
- readonly next?: string,
380
- },
381
- /**
382
- * Structured data, as JSON-LD.
383
- *
384
- * One `<script type="application/ld+json">` per entry. Unlike everything
385
- * else here it *accumulates* down the tree rather than being replaced by the
386
- * nearest declaration: an `Organization` on the root layout and an `Article`
387
- * on the page are two statements about one page, not two answers to one
388
- * question, and replacing would mean a page that describes itself silently
389
- * deletes the site's description of itself.
390
- *
391
- * The scripts are rendered with the rest of the route rather than hoisted
392
- * into `<head>`, because React hoists `<title>`, `<meta>` and `<link>` and
393
- * not a script it has to keep the body of. JSON-LD is read from anywhere in
394
- * the document, so this costs nothing; it is worth knowing when reading the
395
- * markup.
396
- */
397
- readonly jsonLd?: $ReadOnlyArray<JsonLd>,
398
- readonly openGraph?: {
399
- /**
400
- * The title a share card shows.
401
- *
402
- * Falls back to `title`, because a page that has said what it is called
403
- * has said what its card is called — and a site made to write it twice
404
- * writes it twice once and then lets them drift.
405
- */
406
- readonly title?: string,
407
- /** The description a share card shows. Falls back to `description`. */
408
- readonly description?: string,
409
- /**
410
- * The Open Graph object type. `website` unless a page says otherwise.
411
- *
412
- * Defaulted rather than omitted because `og:type` is one of the four
413
- * properties Open Graph requires, and a document without it is not an
414
- * Open Graph document at all — so leaving it to every project to remember
415
- * is leaving most of them without one.
416
- */
417
- readonly type?: string,
418
- /**
419
- * The name of the site the page belongs to, which a card prints above the
420
- * title. Declared once on the root layout.
421
- */
422
- readonly siteName?: string,
423
- readonly images?: $ReadOnlyArray<string>,
424
- /**
425
- * What the card's image shows, for a reader who cannot see it.
426
- *
427
- * One description rather than one per image: a card shows one image, and
428
- * the array exists so a site can offer a crawler a choice of sizes rather
429
- * than so it can show several.
430
- */
431
- readonly imageAlt?: string,
432
- },
433
- readonly twitter?: {
434
- readonly card?: TwitterCard,
435
- readonly site?: string,
436
- readonly creator?: string,
437
- /** Falls back to `openGraph.title`, and then to `title`. */
438
- readonly title?: string,
439
- /** Falls back to `openGraph.description`, and then to `description`. */
440
- readonly description?: string,
441
- readonly images?: $ReadOnlyArray<string>,
442
- /** Falls back to `openGraph.imageAlt`. */
443
- readonly imageAlt?: string,
444
- },
445
- };
446
-
447
- /** Arguments a loader receives. */
448
- export type LoaderArgs = {|
449
- readonly params: RouteParams,
450
- readonly searchParams: SearchParams,
451
- readonly pathname: string,
452
- |};
453
-
454
- /** Arguments `generateMetadata` receives. */
455
- export type MetadataArgs = {|
456
- readonly params: RouteParams,
457
- readonly searchParams: SearchParams,
458
- readonly data: mixed,
459
- |};
460
-
461
- export type RouteRecord = RoutingRouteRecord<
462
- PageModule,
463
- LayoutModule,
464
- TemplateModule,
465
- LoadingModule,
466
- ErrorModule,
467
- >;
468
-
469
- export type SlotRecord = RoutingSlotRecord<
470
- PageModule,
471
- LayoutModule,
472
- TemplateModule,
473
- LoadingModule,
474
- ErrorModule,
475
- >;
476
-
477
- export type SlotRouteRecord = RoutingSlotRouteRecord<
478
- PageModule,
479
- LayoutModule,
480
- TemplateModule,
481
- LoadingModule,
482
- ErrorModule,
483
- >;
484
-
485
- export type TemplateRecord = RoutingTemplateRecord<TemplateModule>;
486
-
487
- export type LoadingRecord = RoutingLoadingRecord<LoadingModule>;
488
-
489
- export type NotFoundBoundary = RoutingNotFoundBoundary<PageModule, LayoutModule>;
490
-
491
- export type ErrorBoundary = RoutingErrorBoundary<ErrorModule, LayoutModule>;
492
-
493
- export type RouteTable = RoutingRouteTable<
494
- PageModule,
495
- LayoutModule,
496
- TemplateModule,
497
- LoadingModule,
498
- ErrorModule,
499
- >;
500
-
501
- export type RouteMatch = RoutingRouteMatch<RouteRecord>;
502
-
503
- type ResolvedTemplate = {|
504
- readonly above: number,
505
- readonly module: TemplateModule,
506
- |};
507
-
508
- type ResolvedSlotErrorBoundary = {|
509
- readonly above: number,
510
- readonly module: ?ErrorModule,
511
- |};
512
-
513
- type SlotErrorBoundaryLoader = {|
514
- readonly above: number,
515
- readonly module: () => Promise<ErrorModule>,
516
- |};
517
-
518
- /**
519
- * A match whose modules are loaded and whose loader has run or is running — or,
520
- * when `error` is set, the error page that stands in for it.
521
- */
522
- export type ResolvedRoute = {|
523
- readonly pathname: string,
524
- readonly search: string,
525
- readonly path: string,
526
- readonly params: RouteParams,
527
- readonly searchParams: SearchParams,
528
- readonly page: PageModule,
529
- readonly layouts: $ReadOnlyArray<LayoutModule>,
530
- /** What the loader returned, once it has. `undefined` while `deferred` is set. */
531
- readonly data: mixed,
532
- /**
533
- * The loader still running, when the router handed the page its promise
534
- * rather than its value. `null` on every other path, which is most of them.
535
- *
536
- * Two fields rather than a `data` that is sometimes a promise, because a
537
- * loader is free to return something with a `then` on it and no duck test
538
- * could tell that apart from a deferral. This one is the router's own answer
539
- * to a question the router asked, so it says so.
540
- *
541
- * Set only by a streaming render of a route that declares a
542
- * `$loading.js` and generates no metadata from its data — the two
543
- * conditions under which deferring buys anything and costs nothing that was
544
- * not already spent. [`resolveRoute`] is where that is decided and argued.
545
- */
546
- readonly deferred: ?Promise<mixed>,
547
- readonly metadata: Metadata,
548
- /**
549
- * What a stylesheet calls the transition this route arrives under, or `null`
550
- * when neither the page nor a layout above it named one.
551
- *
552
- * Resolved with the route rather than looked up at the moment of the
553
- * navigation, because by then the answer is a property of the destination's
554
- * modules and those are exactly what has just been loaded. A server render
555
- * carries it and never reads it; see "View transitions".
556
- */
557
- readonly viewTransition: ?string,
558
- readonly status: 200 | 401 | 403 | 404 | 500,
559
- /**
560
- * Set when this resolution *is* the error page: the loader threw, or the
561
- * server render did and the renderer resolved again. `null` on the ordinary
562
- * path.
563
- */
564
- readonly error: ?RouteError,
565
- /**
566
- * The boundary that would catch a throw while rendering this route.
567
- *
568
- * Always present, because every route has an answer for a throw: `module`
569
- * is `null` when the project declares no `$error.js` above the path, and
570
- * the framework's own error page renders instead. `above` is how many of
571
- * `layouts` are outside the boundary — the ones that stay mounted, which is
572
- * what "the rest of the document is still interactive" means.
573
- */
574
- readonly errorBoundary: {|
575
- readonly module: ?ErrorModule,
576
- readonly above: number,
577
- |},
578
- /**
579
- * The loading boundaries around this route, root first, already imported.
580
- *
581
- * Imported rather than lazy: React decides to render a fallback
582
- * synchronously, during the render that suspended, so a module that is still
583
- * being fetched is a module that is not there at the only moment it is
584
- * wanted. Empty for a route with no `$loading.js` above it, which is the
585
- * ordinary case and renders exactly the tree it did before.
586
- */
587
- readonly loading: $ReadOnlyArray<{| readonly above: number, readonly module: LoadingModule |}>,
588
- /**
589
- * The templates around this route, root first, already imported.
590
- *
591
- * Empty for a route with no `$template.js` above it, which is the
592
- * ordinary case and renders exactly the tree it did before templates
593
- * existed. Empty too on a resolution that *is* a boundary — a not-found or
594
- * an error page — for the reason its `loading` is: those are matched rather
595
- * than walked to, and templates are accumulated on the walk down to a route
596
- * the URL never reached.
597
- */
598
- readonly templates: $ReadOnlyArray<ResolvedTemplate>,
599
- /**
600
- * The slots this route renders, outermost first, already imported.
601
- *
602
- * Empty for a route with no slot above it, and empty on a resolution that
603
- * *is* a boundary — a not-found or an error page — for the reason its
604
- * `templates` is: a boundary is matched rather than walked to, and a slot
605
- * belongs to the segment the walk went through.
606
- */
607
- readonly slots: $ReadOnlyArray<ResolvedSlot>,
608
- |};
609
-
610
- /**
611
- * One slot, matched against the URL and imported.
612
- *
613
- * `page` is `null` for a slot the URL addressed and that declares no
614
- * `$default.js`, and the layout receives `null` rather than nothing at all:
615
- * a layout that declares a slot always gets that prop, so a project can write
616
- * `{team ?? <Empty />}` and mean it.
617
- *
618
- * `params` are the slot's own. A slot matches the same URL by its own patterns,
619
- * so `@team/[member]` captures `member` while the page beside it captures
620
- * nothing — which is the point of matching twice rather than sharing one match.
621
- */
622
- export type ResolvedSlot = {|
623
- readonly name: string,
624
- readonly above: number,
625
- readonly page: ?PageModule,
626
- readonly params: RouteParams,
627
- readonly layouts: $ReadOnlyArray<LayoutModule>,
628
- readonly loading: $ReadOnlyArray<{| readonly above: number, readonly module: LoadingModule |}>,
629
- readonly templates: $ReadOnlyArray<ResolvedTemplate>,
630
- readonly errorBoundary: ?ResolvedSlotErrorBoundary,
631
- readonly slots: $ReadOnlyArray<ResolvedSlot>,
632
- |};
633
-
634
- // ---------------------------------------------------------------------------
635
- // Loading
636
- // ---------------------------------------------------------------------------
637
-
638
- const moduleCache: Map<() => Promise<mixed>, Promise<mixed>> = new Map();
639
-
640
- function loadOnce<T>(load: () => Promise<T>): Promise<T> {
641
- let pending = moduleCache.get(load);
642
- if (pending == null) {
643
- pending = load();
644
- moduleCache.set(load, pending);
645
- }
646
- // $FlowFixMe[incompatible-return] the cache is keyed by the loader, whose result type it stores.
647
- return pending;
648
- }
649
-
650
- /**
651
- * Load a match's modules and run its loader.
652
- *
653
- * `data` is what the loader returned; on the client after hydration it is the
654
- * value the server embedded, so the loader does not run twice for the first
655
- * page.
656
- *
657
- * # This resolves or redirects; it does not reject
658
- *
659
- * Everything a route can go wrong with is a route to render: no match and
660
- * `notFound()` are the not-found boundary, a loader that threw and
661
- * `forbidden()`/`unauthorized()` are the error boundary. Only `redirect()`
662
- * comes back out, because a redirect is a response rather than a page and the
663
- * caller is what has one to send.
664
- *
665
- * That guarantee is the point rather than a convenience. `hydrate` awaits this
666
- * before `hydrateRoot`, so a rejection there is not an error page — it is no
667
- * `hydrateRoot` call at all, and the document the server sent stays on screen
668
- * with nothing attached to it.
669
- *
670
- * # `onMatch`
671
- *
672
- * Called with the route pattern the moment the URL matches one, before any
673
- * module is imported and before the loader runs. It exists because the server
674
- * has something to do with that fact and does it too late otherwise: the route
675
- * a request turned out to be is what its log line carries, and a loader is
676
- * inside this call, so a server that recorded the route after this resolved
677
- * would have every line a loader wrote saying it belonged to no route.
678
- *
679
- * A callback rather than a return value because both callers already have one
680
- * — the pattern is on the `ResolvedRoute` this hands back — and only one of
681
- * them needs it *early*. The browser passes nothing and pays nothing.
682
- */
683
- export async function resolveMatch(
684
- table: RouteTable,
685
- url: string,
686
- options?: ResolveOptions,
687
- ): Promise<ResolvedRoute> {
688
- try {
689
- return await resolveRoute(table, url, options);
690
- } catch (error) {
691
- if (error instanceof RedirectError) {
692
- throw error;
693
- }
694
- return resolveFailure(table, url, error);
695
- }
696
- }
697
-
698
- /** What a caller may tell [`resolveMatch`] about the resolution it wants. */
699
- export type ResolveOptions = {|
700
- /** The loader's answer, already in hand — the value the server embedded. */
701
- readonly data?: mixed,
702
- /** Do not run the loader at all; `data` is the answer. */
703
- readonly skipLoader?: boolean,
704
- /**
705
- * Whether the caller can render a route whose loader has not answered yet.
706
- *
707
- * Only a streaming server render can, and that is the whole of why this is
708
- * a caller's choice rather than the router's. `createRenderer`'s `render`
709
- * sends a `<Suspense>` fallback now and the content when it arrives, so
710
- * deferring is what turns a slow loader from a delay before the first byte
711
- * into a fallback the reader is already looking at.
712
- *
713
- * Nothing else is in that position, and each for its own reason. `prerender`
714
- * writes a file, which has no first paint to improve and no reader to show a
715
- * fallback to. `hydrate` has the server's answer already. A client
716
- * navigation has a page on screen that stays interactive while the next one
717
- * resolves, which is the browser's version of the same idea and does not
718
- * need this one.
719
- *
720
- * It costs the loader its say in the response: a status is decided when the
721
- * shell goes out, so a deferred `notFound()` reaches the error boundary
722
- * rather than the 404 page, and the document is a 200. That is inherent to
723
- * streaming rather than a shortcut — the bytes have gone — and it is the
724
- * reason this is off unless a caller asks.
725
- */
726
- readonly defer?: boolean,
727
- /** The route pattern, the moment the URL matches one; see [`resolveMatch`]. */
728
- readonly onMatch?: (pattern: string) => void,
729
- |};
730
-
731
- async function resolveRoute(
732
- table: RouteTable,
733
- url: string,
734
- options?: ResolveOptions,
735
- ): Promise<ResolvedRoute> {
736
- const { pathname, search } = splitUrl(url);
737
- const searchParams = parseSearch(search);
738
- const matched = matchRoute(table.routes, pathname);
739
-
740
- if (matched != null) {
741
- options?.onMatch?.(matched.route.path);
742
- }
743
-
744
- if (matched == null) {
745
- return resolveNotFound(table, pathname, search, searchParams);
746
- }
747
-
748
- const load = matched.route.page;
749
- if (load == null) {
750
- // Reachable only by asking this table to render a route it was built
751
- // without. `hydrate` and every navigation check `hasClientPage` first and
752
- // hand the URL to the browser instead, so arriving here means a caller
753
- // went around them — and the honest answer is to say so rather than to
754
- // render an empty page.
755
- throw new Error(
756
- `@uniflowed/router: ${matched.route.path} has no page in this route table; it ships no ` +
757
- "client JavaScript, so the browser navigates to it rather than rendering it",
758
- );
759
- }
760
- const [page, ...layouts] = await Promise.all([
761
- loadOnce(load),
762
- ...matched.route.layouts.map((layout) => loadOnce(layout)),
763
- ]);
764
- // Started here and awaited at the end: the boundary's module does not depend
765
- // on the loader, so importing it alongside costs a navigation nothing. It
766
- // never rejects, so an early throw below leaves no unhandled rejection.
767
- const boundary = resolveErrorBoundary(table, pathname, matched.route.layouts.length);
768
- // Started alongside for the same reason, and awaited at the end: a fallback
769
- // depends on nothing the loader produces.
770
- const loading = resolveLoading(matched.route, matched.route.layouts.length);
771
- const templates = resolveTemplates(matched.route, matched.route.layouts.length);
772
- // Started alongside and awaited at the end, for the reason the boundaries
773
- // are: a slot is matched against the URL and depends on nothing the loader
774
- // produces, so the second match and its imports overlap the first page's
775
- // loader rather than following it.
776
- const slots = resolveSlots(
777
- matched.route.slots ?? [],
778
- pathname,
779
- matched.route.layouts.length,
780
- matched.params,
781
- );
782
-
783
- // The loader, run here and awaited below — or not awaited at all.
784
- //
785
- // A page that suspends while *rendering* has always streamed; a page waiting
786
- // on its loader could not, because this function awaited the loader before it
787
- // returned and by the time React saw the tree the data was already in hand.
788
- // The fallback beside such a page showed for zero milliseconds, which made
789
- // `$loading.js` useful for the one case a page usually is not slow for.
790
- //
791
- // Two things stand in the way of simply not awaiting, and both are about the
792
- // document rather than about the route. Metadata goes in the head and the
793
- // head is written before the body, so a title computed from the data
794
- // genuinely cannot be deferred — that is a rule worth stating rather than a
795
- // limitation to hide, and it is the `generateMetadata` half of the condition
796
- // below. The other is that a route with no `<Suspense>` above it has nothing
797
- // to defer *into*: React holds the whole shell for a page that suspends with
798
- // no boundary, which is the same wait by another name, with an unresolved
799
- // promise flowing through the tree for nothing. So the loader is deferred
800
- // exactly when there is a boundary to defer it into.
801
- //
802
- // See ubugeeei-prod/uf#373, and `ResolveOptions.defer` for who asks.
803
- let data: mixed = options?.data;
804
- let deferred: ?Promise<mixed> = null;
805
- if (options?.skipLoader !== true && typeof page.loader === "function") {
806
- const running = page.loader({ params: matched.params, searchParams, pathname });
807
- const canDefer =
808
- options?.defer === true &&
809
- (matched.route.loading ?? []).length > 0 &&
810
- typeof page.generateMetadata !== "function";
811
- if (canDefer) {
812
- // `Promise.resolve`, because a loader may return a plain value and `use`
813
- // wants a promise either way. A loader that answered without waiting
814
- // costs one microtask and renders in the same pass.
815
- deferred = Promise.resolve(running);
816
- } else {
817
- data = await running;
818
- }
819
- }
820
-
821
- const metadata = await resolveMetadata(page, layouts, {
822
- params: matched.params,
823
- searchParams,
824
- data,
825
- });
826
- return {
827
- pathname,
828
- search,
829
- path: matched.route.path,
830
- params: matched.params,
831
- searchParams,
832
- page,
833
- layouts,
834
- data,
835
- deferred,
836
- metadata,
837
- viewTransition: resolveViewTransition(page, layouts),
838
- status: 200,
839
- error: null,
840
- errorBoundary: await boundary,
841
- loading: await loading,
842
- templates: await templates,
843
- slots: await slots,
844
- };
845
- }
846
-
847
- /**
848
- * The route's slots, matched against the URL and imported.
849
- *
850
- * The second matching pass parallel routes are, and it is a pass rather than a
851
- * branch of the first: a slot has its own patterns over the same path, so
852
- * `/dashboard/members` can be `[member]` to one slot, a static segment to
853
- * another and nothing at all to a third, at once.
854
- *
855
- * A slot that will not load renders nothing rather than taking the page with
856
- * it, which is the judgement `resolveTemplates` and `resolveLoading` already
857
- * make: a slot is a second thing beside the page, and a broken second thing
858
- * must not become a broken route. The entry stays in the list with `page:
859
- * null`, so the layout still receives the prop it declares.
860
- */
861
- async function resolveSlots(
862
- records: $ReadOnlyArray<SlotRecord>,
863
- pathname: string,
864
- layoutCount: number,
865
- fallbackParams: RouteParams,
866
- ): Promise<$ReadOnlyArray<ResolvedSlot>> {
867
- if (records.length === 0) {
868
- return [];
869
- }
870
- return Promise.all(
871
- records.map((record) => resolveSlot(record, pathname, layoutCount, fallbackParams)),
872
- );
873
- }
874
-
875
- async function resolveSlot(
876
- record: SlotRecord,
877
- pathname: string,
878
- layoutCount: number,
879
- fallbackParams: RouteParams,
880
- ): Promise<ResolvedSlot> {
881
- // Clamped exactly as a template's `above` is, and for the same reason: a
882
- // hand-written table, or a `(group)` between the layout and the route, can
883
- // leave a route with fewer layouts than the slot was declared above.
884
- const above = Math.min(record.above, layoutCount);
885
- const empty: ResolvedSlot = {
886
- name: record.name,
887
- above,
888
- page: null,
889
- params: fallbackParams,
890
- layouts: [],
891
- loading: [],
892
- templates: [],
893
- errorBoundary: null,
894
- slots: [],
895
- };
896
-
897
- const matched = matchIn(record.routes, pathname);
898
- if (matched == null) {
899
- // The URL says nothing about this slot. `$default.js` is what it says
900
- // instead, and a slot that declares none renders nothing at all.
901
- const load = record.defaultPage;
902
- if (load == null) {
903
- return empty;
904
- }
905
- const module = await loadOrNull(load);
906
- if (module == null) {
907
- return empty;
908
- }
909
- return {
910
- ...empty,
911
- page: withoutLoader(module, record.defaultFile ?? record.name),
912
- errorBoundary: await resolveSlotErrorBoundary(record.defaultErrorBoundary ?? null, 0),
913
- };
914
- }
915
-
916
- const route = matched.route;
917
- // Started together and awaited apart, so the two `await`s are not a
918
- // waterfall and each keeps the type its loader had.
919
- const pending = loadOrNull(route.page);
920
- const pendingLayouts = Promise.all(route.layouts.map((layout) => loadOrNull(layout)));
921
- const page = await pending;
922
- const layouts = await pendingLayouts;
923
- if (page == null) {
924
- return empty;
925
- }
926
- const loaded = layouts.filter(Boolean);
927
- if (loaded.length !== layouts.length) {
928
- return empty;
929
- }
930
- const loading = await resolveLoadingRecords(route.loading ?? [], loaded.length);
931
- const templates = await resolveTemplateRecords(route.templates ?? [], loaded.length);
932
- const errorBoundary = await resolveSlotErrorBoundary(route.errorBoundary ?? null, loaded.length);
933
- return {
934
- name: record.name,
935
- above,
936
- page: withoutLoader(page, route.file),
937
- params: matched.params,
938
- layouts: loaded,
939
- loading,
940
- templates,
941
- errorBoundary,
942
- // The slot's own layouts are what a nested slot is measured against, so
943
- // the count handed down is this slot's rather than the route's.
944
- slots: await resolveSlots(route.slots, pathname, loaded.length, matched.params),
945
- };
946
- }
947
-
948
- async function resolveSlotErrorBoundary(
949
- boundary: ?SlotErrorBoundaryLoader,
950
- layoutCount: number,
951
- ): Promise<?ResolvedSlotErrorBoundary> {
952
- if (boundary == null) {
953
- return null;
954
- }
955
- const above = Math.min(boundary.above, layoutCount);
956
- try {
957
- return { module: await loadOnce(boundary.module), above };
958
- } catch {
959
- // Keep the declared depth even when the custom file fails to import. The
960
- // framework fallback still contains the slot instead of escalating the
961
- // page beside it.
962
- return { module: null, above };
963
- }
964
- }
965
-
966
- /**
967
- * A module, or `null` when it would not import.
968
- *
969
- * The judgement [`resolveTemplates`] and [`resolveLoading`] already make, at
970
- * the granularity a slot needs it: a slot is a second thing beside the page, so
971
- * a slot whose module is missing renders nothing rather than taking the route
972
- * down with it — and the import error surfaces where it belongs, the next time
973
- * the module is asked for.
974
- */
975
- async function loadOrNull<TModule>(load: () => Promise<TModule>): Promise<?TModule> {
976
- try {
977
- return await loadOnce(load);
978
- } catch {
979
- return null;
980
- }
981
- }
982
-
983
- /**
984
- * The same page module, having said out loud that a slot's loader does not run.
985
- *
986
- * A slot page is a component. It is *not* handed data, and this throws rather
987
- * than passing `undefined` to a page that asked for some, because a slot whose
988
- * loader is quietly skipped is exactly the failure ubugeeei-prod/uf#267 is
989
- * about — a file written to a convention, and nothing that reads it.
990
- *
991
- * Why not run it. A page's loader answer is embedded in the document for the
992
- * browser to hydrate from, once, under one id; a slot's would have nowhere to
993
- * go, so it would run on the server and again in the browser on the way in.
994
- * That is not merely two fetches: a loader that reads `cookies()` succeeds on
995
- * the server and throws in the browser, and the slot would render on one side
996
- * and not the other — a hydration mismatch produced by the router. So the rule
997
- * is the narrow one, and lifting it means embedding per-slot data, which is
998
- * named in the issue as what is left.
999
- */
1000
- function withoutLoader(module: PageModule, file: string): PageModule {
1001
- if (typeof module.loader === "function") {
1002
- throw new Error(
1003
- `@uniflowed/router: ${file} is inside a \`@slot\` and exports a \`loader\`, which the ` +
1004
- "router does not run — a slot's data has nowhere to be embedded for hydration, so it " +
1005
- "would be fetched again in the browser and a server-only loader would render one tree " +
1006
- "on the server and another in the page. Fetch inside the component, or move the data to " +
1007
- "the page the URL names. https://github.com/ubugeeei-prod/uf/issues/267",
1008
- );
1009
- }
1010
- return module;
1011
- }
1012
-
1013
- /**
1014
- * The route's templates, imported.
1015
- *
1016
- * A template that will not load is dropped, the way a fallback is: it is a
1017
- * wrapper around the page, not the page, so a broken wrapper must not become a
1018
- * broken route. The tree renders without it — the page keeps the layout it was
1019
- * inside, and loses only the remount — and the import error surfaces where it
1020
- * belongs, when the module is next asked for.
1021
- */
1022
- async function resolveTemplates(
1023
- route: RouteRecord,
1024
- layoutCount: number,
1025
- ): Promise<$ReadOnlyArray<ResolvedTemplate>> {
1026
- return resolveTemplateRecords(route.templates ?? [], layoutCount);
1027
- }
1028
-
1029
- async function resolveTemplateRecords(
1030
- records: $ReadOnlyArray<TemplateRecord>,
1031
- layoutCount: number,
1032
- ): Promise<$ReadOnlyArray<ResolvedTemplate>> {
1033
- if (records.length === 0) {
1034
- return [];
1035
- }
1036
- const loaded = await Promise.all(
1037
- records.map(async (record) => {
1038
- try {
1039
- return {
1040
- // Clamped exactly as the error and loading boundaries' are: a
1041
- // `(group)` directory can leave a route with fewer layouts than the
1042
- // template declared above it.
1043
- above: Math.min(record.above, layoutCount),
1044
- module: await loadOnce(record.module),
1045
- };
1046
- } catch {
1047
- return null;
1048
- }
1049
- }),
1050
- );
1051
- return loaded.filter(Boolean);
1052
- }
1053
-
1054
- /**
1055
- * The route's loading boundaries, imported.
1056
- *
1057
- * A boundary whose module will not load is dropped rather than thrown for, and
1058
- * this is the same judgement `resolveErrorBoundary` makes one function above: a
1059
- * fallback is what the router shows while it does not yet have the page, so a
1060
- * broken fallback must not become a broken page. The route renders without that
1061
- * boundary — the next one out, or the shell, waits for it instead — and the
1062
- * import error surfaces where it belongs, when the module is next asked for.
1063
- */
1064
- async function resolveLoading(
1065
- route: RouteRecord,
1066
- layoutCount: number,
1067
- ): Promise<$ReadOnlyArray<{| readonly above: number, readonly module: LoadingModule |}>> {
1068
- return resolveLoadingRecords(route.loading ?? [], layoutCount);
1069
- }
1070
-
1071
- async function resolveLoadingRecords(
1072
- records: $ReadOnlyArray<LoadingRecord>,
1073
- layoutCount: number,
1074
- ): Promise<$ReadOnlyArray<{| readonly above: number, readonly module: LoadingModule |}>> {
1075
- if (records.length === 0) {
1076
- return [];
1077
- }
1078
- const loaded = await Promise.all(
1079
- records.map(async (record) => {
1080
- try {
1081
- return {
1082
- // Clamped exactly as the error boundary's is, and for the same
1083
- // reason: a `(group)` directory can leave a route with fewer layouts
1084
- // than the boundary that covers it.
1085
- above: Math.min(record.above, layoutCount),
1086
- module: await loadOnce(record.module),
1087
- };
1088
- } catch {
1089
- return null;
1090
- }
1091
- }),
1092
- );
1093
- return loaded.filter(Boolean);
1094
- }
1095
-
1096
- /**
1097
- * The route to render after something threw.
1098
- *
1099
- * Two callers, one behaviour: [`resolveMatch`] when a loader or a module
1100
- * import threw, and `createRenderer` when the *render* did — React's error
1101
- * boundaries do not run in `renderToString`, so the server has to catch it
1102
- * itself and resolve again.
1103
- */
1104
- export async function resolveFailure(
1105
- table: RouteTable,
1106
- url: string,
1107
- error: mixed,
1108
- ): Promise<ResolvedRoute> {
1109
- const { pathname, search } = splitUrl(url);
1110
- const searchParams = parseSearch(search);
1111
- if (error instanceof NotFoundError) {
1112
- try {
1113
- return await resolveNotFound(table, pathname, search, searchParams);
1114
- } catch (failure) {
1115
- // The not-found page itself would not load. Falling through to the error
1116
- // boundary rather than rethrowing is what keeps the promise above: the
1117
- // page a project wrote to explain a 404 is not more load-bearing than
1118
- // the document staying on screen.
1119
- return resolveError(table, pathname, search, searchParams, routeErrorFor(failure));
1120
- }
1121
- }
1122
- return resolveError(table, pathname, search, searchParams, routeErrorFor(error));
1123
- }
1124
-
1125
- /** What a thrown value means to the router. */
1126
- function routeErrorFor(error: mixed): RouteError {
1127
- if (error instanceof UnauthorizedError) {
1128
- return { kind: "unauthorized" };
1129
- }
1130
- if (error instanceof ForbiddenError) {
1131
- return { kind: "forbidden" };
1132
- }
1133
- return { kind: "thrown", error };
1134
- }
1135
-
1136
- /**
1137
- * The error boundary a route renders inside, loaded with the route rather than
1138
- * when it is needed.
1139
- *
1140
- * React decides to show a boundary's fallback synchronously, during the render
1141
- * that threw. A module that still has to be imported is a module that is not
1142
- * there at the only moment it can be used, so this is one more dynamic import
1143
- * per navigation and not a lazy one.
1144
- *
1145
- * `above` is the boundary's own layout count, clamped to the route's. The
1146
- * first attempt compared the two layout arrays for a shared prefix, which is
1147
- * more precise when a `(group)` directory puts a boundary beside a route
1148
- * rather than above it — and it worked by *reference identity* of the loader
1149
- * functions, which holds only because `routesModuleSource` deduplicates them
1150
- * by file. A rule that depends on an invisible property of the generated
1151
- * module is a rule that reads as zero the moment a table is built any other
1152
- * way, and it did: it put the boundary outside the layouts it was written
1153
- * inside. Nesting a boundary per group needs parallel-route trees (#267);
1154
- * until then this is the honest approximation, and it is stated rather than
1155
- * inferred.
1156
- */
1157
- async function resolveErrorBoundary(
1158
- table: RouteTable,
1159
- pathname: string,
1160
- layoutCount: number,
1161
- ): Promise<{| readonly module: ?ErrorModule, readonly above: number |}> {
1162
- const boundary = nearestBoundary(table.errors, pathname);
1163
- if (boundary == null) {
1164
- return { module: null, above: 0 };
1165
- }
1166
- // Clamped, because a route group can leave a route with fewer layouts than
1167
- // the boundary covering it, and an `above` past the end would compose the
1168
- // layouts out of nothing.
1169
- const above = Math.min(boundary.layouts.length, layoutCount);
1170
- const load = boundary.module;
1171
- // The synthesised root record, which names layouts and no module: the
1172
- // framework's page renders, and `above` still says where — inside the site's
1173
- // own layouts rather than outside everything. See [`NotFoundBoundary`]`.page`.
1174
- if (load == null) {
1175
- return { module: null, above };
1176
- }
1177
- try {
1178
- return { module: await loadOnce(load), above };
1179
- } catch {
1180
- // A boundary whose module will not load cannot be the answer to a throw,
1181
- // and this is why the field is nullable: containment must not itself
1182
- // depend on an import working. The depth is kept, because the layouts the
1183
- // boundary named are still there and the framework's page is better inside
1184
- // them than outside them.
1185
- return { module: null, above };
1186
- }
1187
- }
1188
-
1189
- /**
1190
- * The error page for `pathname`, inside the layouts above the boundary that
1191
- * answers it.
1192
- *
1193
- * The layouts are the boundary's, for the same reason [`resolveNotFound`]
1194
- * gives: they are what stays mounted around the error, and the layouts below
1195
- * the boundary belong to the subtree that just stopped.
1196
- */
1197
- async function resolveError(
1198
- table: RouteTable,
1199
- pathname: string,
1200
- search: string,
1201
- searchParams: SearchParams,
1202
- routeError: RouteError,
1203
- ): Promise<ResolvedRoute> {
1204
- const boundary = nearestBoundary(table.errors, pathname);
1205
- let module: ?ErrorModule = null;
1206
- let layouts: $ReadOnlyArray<LayoutModule> = [];
1207
- if (boundary != null) {
1208
- const load = boundary.module;
1209
- try {
1210
- // The layouts whether or not there is a module, because the synthesised
1211
- // root record has layouts and no module and its whole purpose is that
1212
- // the framework's error page renders inside them: a site whose root
1213
- // layout owns the masthead and the stylesheet answered a 500 with
1214
- // neither. See ubugeeei-prod/uf#351.
1215
- layouts = await Promise.all(boundary.layouts.map((layout) => loadOnce(layout)));
1216
- module = load == null ? null : await loadOnce(load);
1217
- } catch {
1218
- // See `resolveErrorBoundary`: the framework's own page answers instead.
1219
- module = null;
1220
- layouts = [];
1221
- }
1222
- }
1223
-
1224
- const declared = await resolveMetadata(
1225
- module?.metadata != null ? { metadata: module.metadata } : {},
1226
- layouts,
1227
- { params: {}, searchParams, data: undefined },
1228
- );
1229
- return {
1230
- pathname,
1231
- search,
1232
- path: "*",
1233
- params: {},
1234
- searchParams,
1235
- page: { default: ResolvedErrorPage },
1236
- layouts,
1237
- data: undefined,
1238
- deferred: null,
1239
- metadata: declared.title != null ? declared : { ...declared, title: errorTitle(routeError) },
1240
- // The boundary's own layouts may name one; the page cannot, because the
1241
- // page here is this module's. An error arriving under the section's
1242
- // transition is the same answer as a page arriving under it.
1243
- viewTransition: resolveViewTransition({}, layouts),
1244
- status: routeErrorStatus(routeError),
1245
- error: routeError,
1246
- // All of the boundary's layouts are above it, and no inner boundary is
1247
- // inserted around a page that already is one; see `RouteView`.
1248
- errorBoundary: { module, above: layouts.length },
1249
- // An error page has nothing left to wait for: it renders the value it was
1250
- // resolved with. A fallback around it would be a boundary that can never
1251
- // show, which is worse than none.
1252
- loading: [],
1253
- templates: [],
1254
- // And slots for the third time: a slot belongs to the segment the walk went
1255
- // through, and an error page is matched rather than walked to. A layout
1256
- // that declares one is still mounted above the boundary, holding the slot
1257
- // it was rendered with — the boundary replaces what is under it.
1258
- slots: [],
1259
- };
1260
- }
1261
-
1262
- /**
1263
- * The not-found page for `pathname`, inside the layouts above the boundary
1264
- * that answers it.
1265
- *
1266
- * The layouts are the *boundary's*, not the ones the URL had already matched.
1267
- * Taking the matched route's layouts was the other candidate and it is wrong
1268
- * in both directions: for an unmatched URL there is no matched route to take
1269
- * them from, and for `notFound()` thrown from a page they would keep the
1270
- * layouts *below* the boundary — so `app/guide/[slug]/$layout.js` would
1271
- * wrap a 404 that `app/guide/$not-found.js` answered, which is the layout
1272
- * of the page that just said it does not exist.
1273
- *
1274
- * # The record with no page
1275
- *
1276
- * A project that declares no `$not-found.js` anywhere still has a record —
1277
- * the one the build synthesises for the router root — and it names the root's
1278
- * layouts and no module. Before that record existed this function answered
1279
- * with `layouts: []`, so a site whose root layout owns the masthead, the
1280
- * stylesheet and often `<html>` itself answered an unmatched URL with a white
1281
- * page carrying `404` and no way to leave it. That was not the nearest-ancestor
1282
- * rule failing; it was the fallback having no record to take layouts from, and
1283
- * giving it one is the whole of ubugeeei-prod/uf#351.
1284
- *
1285
- * The framework's page then merges its title over the layouts' metadata like
1286
- * any page would, so a `metadataBase` or an `og:site_name` declared on the root
1287
- * layout still applies to the 404.
1288
- */
1289
- async function resolveNotFound(
1290
- table: RouteTable,
1291
- pathname: string,
1292
- search: string,
1293
- searchParams: SearchParams,
1294
- ): Promise<ResolvedRoute> {
1295
- const record = nearestBoundary(table.notFound, pathname);
1296
- const load = record?.page;
1297
- const [page, ...layouts] = await Promise.all([
1298
- load == null
1299
- ? Promise.resolve<PageModule>({ default: DefaultNotFound, metadata: { title: "Not found" } })
1300
- : loadOnce(load),
1301
- ...(record?.layouts ?? []).map((layout) => loadOnce(layout)),
1302
- ]);
1303
- const metadata = await resolveMetadata(page, layouts, {
1304
- params: {},
1305
- searchParams,
1306
- data: undefined,
1307
- });
1308
- return {
1309
- pathname,
1310
- search,
1311
- path: "*",
1312
- params: {},
1313
- searchParams,
1314
- page,
1315
- layouts,
1316
- data: undefined,
1317
- deferred: null,
1318
- metadata,
1319
- viewTransition: resolveViewTransition(page, layouts),
1320
- status: 404,
1321
- error: null,
1322
- // A not-found page is a page: one that throws is contained like any other.
1323
- errorBoundary: await resolveErrorBoundary(table, pathname, layouts.length),
1324
- // A not-found boundary is matched, not nested: `nearestBoundary` picked one
1325
- // record and the loading files are a property of the route that was walked
1326
- // to, which this URL never reached. Nothing to wait for, so no boundary.
1327
- loading: [],
1328
- // Templates are accumulated on that same walk, and for the same reason.
1329
- templates: [],
1330
- // Slots too: a URL that matched no route addressed no slot either.
1331
- slots: [],
1332
- };
1333
- }
1334
-
1335
- /**
1336
- * The route's metadata: each declaration merged over the ones outside it.
1337
- *
1338
- * Per key, so a page that declares only `canonical` keeps the title its layout
1339
- * set — with one exception, and it is deliberate. `jsonLd` is gathered along
1340
- * the way instead of merged, because a nearer declaration of it is an addition
1341
- * rather than a correction; [`Metadata`] has the argument.
1342
- */
1343
- async function resolveMetadata(
1344
- page: PageModule,
1345
- layouts: $ReadOnlyArray<LayoutModule>,
1346
- args: MetadataArgs,
1347
- ): Promise<Metadata> {
1348
- let merged: Metadata = {};
1349
- let structured: $ReadOnlyArray<JsonLd> = [];
1350
- const take = (declared: Metadata) => {
1351
- if (declared.jsonLd != null) {
1352
- structured = [...structured, ...declared.jsonLd];
1353
- }
1354
- merged = { ...merged, ...declared };
1355
- };
1356
-
1357
- for (const layout of layouts) {
1358
- if (layout.metadata != null) {
1359
- take(layout.metadata);
1360
- }
1361
- }
1362
- if (page.frontmatter != null) {
1363
- const { title, description } = page.frontmatter;
1364
- merged = {
1365
- ...merged,
1366
- ...(title != null ? { title } : {}),
1367
- ...(description != null ? { description } : {}),
1368
- };
1369
- }
1370
- if (page.metadata != null) {
1371
- take(page.metadata);
1372
- }
1373
- if (typeof page.generateMetadata === "function") {
1374
- take(await page.generateMetadata(args));
1375
- }
1376
- return structured.length === 0 ? merged : { ...merged, jsonLd: structured };
1377
- }
1378
-
1379
- /** A module that may name the transition its route arrives under. */
1380
- type Transitioning = { readonly viewTransition?: string, ... };
1381
-
1382
- /**
1383
- * What a stylesheet calls this route's arrival: the nearest declaration wins.
1384
- *
1385
- * The same walk `resolveMetadata` does one function above, and stated as its
1386
- * own function rather than folded into that one because the two answer
1387
- * different questions and only one of them is a document. Layouts are root
1388
- * first, so overwriting as it descends leaves the innermost, and the page has
1389
- * the last word.
1390
- *
1391
- * The parameters say what is read rather than naming `PageModule` and
1392
- * `LayoutModule`, which is the shape `nearestBoundary` already takes for the
1393
- * same reason: this reads one optional field, so requiring the whole of either
1394
- * type would be a claim it does not need and cannot use.
1395
- */
1396
- function resolveViewTransition(
1397
- page: Transitioning,
1398
- layouts: $ReadOnlyArray<Transitioning>,
1399
- ): ?string {
1400
- let name: ?string = null;
1401
- for (const layout of layouts) {
1402
- if (layout.viewTransition != null) {
1403
- name = layout.viewTransition;
1404
- }
1405
- }
1406
- return page.viewTransition ?? name;
1407
- }
1408
-
1409
- component DefaultNotFound() {
1410
- return (
1411
- <main>
1412
- <title>Not found</title>
1413
- <h1>404</h1>
1414
- <p>This page does not exist.</p>
1415
- </main>
1416
- );
1417
- }
1418
-
1419
- /** The document title an error page gets when nothing declared one. */
1420
- function errorTitle(error: RouteError): string {
1421
- return match (error) {
1422
- {kind: "unauthorized"} => "Sign in required",
1423
- {kind: "forbidden"} => "Not allowed",
1424
- {kind: "thrown"} => "Something went wrong",
1425
- };
1426
- }
1427
-
1428
- /**
1429
- * The framework's error page, for a project that declares no `$error.js`.
1430
- *
1431
- * It says which of the three happened and offers the reset, and it does *not*
1432
- * print the thrown error: on the server that message is written for whoever
1433
- * deployed the application — a query, a path, a token in a stack — and this
1434
- * markup is sent to whoever asked for the page. `uf dev` reports the throw in
1435
- * the terminal and `uf build` fails the route, which are the places the person
1436
- * who can act on it is looking.
1437
- */
1438
- component DefaultRouteError(error: RouteError, reset: () => void) {
1439
- const title = errorTitle(error);
1440
- const detail = match (error) {
1441
- {kind: "unauthorized"} => "This page needs you to be signed in.",
1442
- {kind: "forbidden"} => "You do not have access to this page.",
1443
- {kind: "thrown"} => "This page could not be rendered.",
1444
- };
1445
- return (
1446
- <main>
1447
- <title>{title}</title>
1448
- <h1>{title}</h1>
1449
- <p>{detail}</p>
1450
- <button type="button" onClick={reset}>
1451
- Try again
1452
- </button>
1453
- </main>
1454
- );
1455
- }
1456
-
1457
- /** The component an error module renders: `default`, or the named `Error`. */
1458
- function errorComponent(module: ErrorModule): React.ComponentType<ErrorRenderProps> {
1459
- const component = module.default ?? module.Error;
1460
- if (component == null) {
1461
- throw new Error(
1462
- "@uniflowed/router: an error module must export a component as `default` or `Error`",
1463
- );
1464
- }
1465
- return renderable(component);
1466
- }
1467
-
1468
- /** The props an error boundary's component receives. */
1469
- type ErrorRenderProps = {|
1470
- readonly error: RouteError,
1471
- readonly reset: () => void,
1472
- |};
1473
-
1474
- /**
1475
- * The error UI, from whichever module is in scope.
1476
- *
1477
- * One component for both ways in — the class boundary below, which catches a
1478
- * throw while the browser renders, and `ResolvedErrorPage`, which is what the
1479
- * server renders because React's boundaries do not run in `renderToString`.
1480
- * Two paths to the same screen is exactly the pair that drifts.
1481
- */
1482
- component RouteErrorView(module: ?ErrorModule, error: RouteError, reset: () => void) {
1483
- if (module == null) {
1484
- return <DefaultRouteError error={error} reset={reset} />;
1485
- }
1486
- const Boundary = errorComponent(module);
1487
- return <Boundary error={error} reset={reset} />;
1488
- }
1489
-
1490
- /**
1491
- * The page of a route that resolved to an error.
1492
- *
1493
- * A resolved error route carries the error and the module on the route itself,
1494
- * so this is a static component rather than a closure the resolver builds:
1495
- * `RouteView` composes it in its layouts exactly like a page, which is what
1496
- * makes "inside the layouts above the boundary" one code path and not two.
1497
- *
1498
- * `reset()` here is `router.refresh()` — this route resolved to an error
1499
- * because a loader or an import threw, so re-running the resolution is what
1500
- * trying again means. On the server `refresh` does nothing, which is correct:
1501
- * a static render has nothing to re-run.
1502
- */
1503
- component ResolvedErrorPage() {
1504
- const { resolved, router } = useRouterState();
1505
- const reset = () => {
1506
- router.refresh().catch(() => {});
1507
- };
1508
-
1509
- if (resolved.error == null) {
1510
- // Unreachable: this module is only ever the page of a resolved error route.
1511
- return null;
1512
- }
1513
- return (
1514
- <RouteErrorView module={resolved.errorBoundary.module} error={resolved.error} reset={reset} />
1515
- );
1516
- }
1517
-
1518
- type RouteErrorBoundaryProps = {|
1519
- readonly module: ?ErrorModule,
1520
- readonly resetKey: string,
1521
- readonly children: React.Node,
1522
- |};
1523
-
1524
- type RouteErrorBoundaryState = {| readonly error: ?RouteError |};
1525
-
1526
- /**
1527
- * The boundary that catches a throw while the browser renders the subtree.
1528
- *
1529
- * A class, because `getDerivedStateFromError` is React's contract for this and
1530
- * there is no hook that does it — this is the one place in the router where
1531
- * following React's public contract means not using a function component.
1532
- *
1533
- * Recovering on navigation is `componentDidUpdate` watching `resetKey`, not
1534
- * `key={pathname}` on the boundary. Keying it remounts the subtree on *every*
1535
- * navigation, error or not, and everything below the boundary goes with it —
1536
- * which is the layouts, whose whole purpose is to survive navigation with
1537
- * their scroll position and their open sections intact.
1538
- */
1539
- class RouteErrorBoundary extends React.Component<RouteErrorBoundaryProps, RouteErrorBoundaryState> {
1540
- constructor(props: RouteErrorBoundaryProps) {
1541
- super(props);
1542
- this.state = { error: null };
1543
- }
1544
-
1545
- static getDerivedStateFromError(error: mixed): RouteErrorBoundaryState {
1546
- return { error: routeErrorFor(error) };
1547
- }
102
+ notFound,
103
+ parseSearch,
104
+ permanentRedirect,
105
+ redirect,
106
+ routeErrorStatus,
107
+ splitUrl,
108
+ unauthorized,
109
+ } from "./routing.js";
1548
110
 
1549
- componentDidUpdate(previous: RouteErrorBoundaryProps) {
1550
- if (this.state.error != null && previous.resetKey !== this.props.resetKey) {
1551
- this.setState({ error: null });
1552
- }
1553
- }
111
+ export type {
112
+ ErrorBoundary,
113
+ ErrorModule,
114
+ Interception,
115
+ JsonLd,
116
+ LayoutModule,
117
+ LoaderArgs,
118
+ LoadingModule,
119
+ LoadingRecord,
120
+ Metadata,
121
+ MetadataArgs,
122
+ NotFoundBoundary,
123
+ PageModule,
124
+ ResolveOptions,
125
+ ResolvedRoute,
126
+ ResolvedSlot,
127
+ Robots,
128
+ RouteMatch,
129
+ RouteRecord,
130
+ RouteTable,
131
+ SlotRecord,
132
+ SlotRouteRecord,
133
+ TemplateModule,
134
+ TemplateRecord,
135
+ TwitterCard,
136
+ } from "./resolve.js";
1554
137
 
1555
- render(): React.Node {
1556
- const { error } = this.state;
1557
- if (error == null) {
1558
- return this.props.children;
1559
- }
1560
- return (
1561
- <RouteErrorView
1562
- module={this.props.module}
1563
- error={error}
1564
- reset={() => this.setState({ error: null })}
1565
- />
1566
- );
1567
- }
1568
- }
138
+ export { resolveFailure, resolveMatch } from "./resolve.js";
1569
139
 
1570
140
  // ---------------------------------------------------------------------------
1571
141
  // View transitions
@@ -1768,13 +338,28 @@ export type RouteInfo = {|
1768
338
  */
1769
339
  export type Navigation = "client" | "document";
1770
340
 
341
+ /**
342
+ * What the router holds, and what every hook and `RouteView` read.
343
+ *
344
+ * Two halves, because a route arrives two ways. `route` is what a hook reads —
345
+ * the path, the parameters, the loader's answer — and it is the same shape
346
+ * whichever way the route was rendered. `view` is what `RouteView` renders:
347
+ * the tree a server composed for React Server Components, or a route resolved
348
+ * from its modules, which the browser composes itself.
349
+ */
1771
350
  type RouterState = {|
1772
- readonly resolved: ResolvedRoute,
351
+ readonly route: RouteState,
352
+ readonly view: RouteViewState,
1773
353
  readonly router: Router,
1774
354
  readonly pending: boolean,
1775
355
  readonly navigation: Navigation,
1776
356
  |};
1777
357
 
358
+ /** What `RouteView` renders: a server's tree, or a route to compose. */
359
+ type RouteViewState =
360
+ | {| readonly kind: "flight", readonly tree: React.Node |}
361
+ | {| readonly kind: "modules", readonly resolved: ResolvedRoute |};
362
+
1778
363
  const RouterContext: React.Context<?RouterState> = createContext(null);
1779
364
 
1780
365
  /** The route table the application was started with. */
@@ -1827,61 +412,447 @@ export function routeTable(): RouteTable {
1827
412
  return installedTable;
1828
413
  }
1829
414
 
1830
- /** Props the app root receives from the client and server entries. */
415
+ /**
416
+ * Props the app root receives from the client and server entries.
417
+ *
418
+ * One of `flight` and `initial`. A document React Server Components rendered
419
+ * hands the root its payload, on the server and again in the browser, so both
420
+ * sides render the same tree from the same bytes. A single-page application —
421
+ * and a project that turned `app.rsc` off — hands it a route resolved from its
422
+ * modules instead. See ubugeeei-prod/uf#519.
423
+ */
1831
424
  export type AppProps = {|
1832
425
  readonly url: string,
1833
- readonly initial: ResolvedRoute,
426
+ readonly initial?: ResolvedRoute,
427
+ readonly flight?: Promise<FlightRoot>,
1834
428
  |};
1835
429
 
1836
- /**
1837
- * Whether there is a document to navigate.
1838
- *
1839
- * Asked every time rather than answered once at module scope, and the
1840
- * difference is not a style preference. The answer is a constant inside a
1841
- * browser bundle and inside a server process; it is *not* a constant inside a
1842
- * test runner, where a DOM is installed on the first render and one worker
1843
- * serves many files out of one module registry. Latched, the first file in a
1844
- * worker to import this module decided for every file after it whether a
1845
- * `Link` navigates or silently does nothing — and a server-rendering test
1846
- * imports it before any document exists. See ubugeeei-prod/uf#445.
1847
- *
1848
- * The cost is a `typeof` per navigation, which is a navigation.
1849
- */
1850
- function isBrowser(): boolean {
1851
- return typeof window !== "undefined" && typeof document !== "undefined";
430
+ /**
431
+ * Whether there is a document to navigate.
432
+ *
433
+ * Asked every time rather than answered once at module scope, and the
434
+ * difference is not a style preference. The answer is a constant inside a
435
+ * browser bundle and inside a server process; it is *not* a constant inside a
436
+ * test runner, where a DOM is installed on the first render and one worker
437
+ * serves many files out of one module registry. Latched, the first file in a
438
+ * worker to import this module decided for every file after it whether a
439
+ * `Link` navigates or silently does nothing — and a server-rendering test
440
+ * imports it before any document exists. See ubugeeei-prod/uf#445.
441
+ *
442
+ * The cost is a `typeof` per navigation, which is a navigation.
443
+ */
444
+ function isBrowser(): boolean {
445
+ return typeof window !== "undefined" && typeof document !== "undefined";
446
+ }
447
+
448
+ /**
449
+ * Provides the current route to the tree and performs navigation.
450
+ *
451
+ * On the server the route is fixed for the request. In the browser the
452
+ * provider listens to history and to `Link` clicks; a navigation fetches the
453
+ * next route's payload — or, for a route resolved from its modules, loads its
454
+ * chunks and runs its loader — *before* committing, inside a transition, so the
455
+ * previous page stays interactive meanwhile.
456
+ *
457
+ * Which of the two it does is decided by what it was started with: a Flight
458
+ * payload is [`FlightRouter`], and a resolved route is [`ModuleRouter`].
459
+ *
460
+ * # Unless the application asked the browser to do it
461
+ *
462
+ * Under `app.rendering.navigation: "document"` every one of those sentences
463
+ * stops being true, and the provider is still here: the tree below it still
464
+ * reads `useRoute`, still renders `<RouteView>`, and still hydrates whatever
465
+ * `"use client"` boundary made the document interactive. What it does not do is
466
+ * take the link over. `navigate` hands the URL to the browser, no `popstate`
467
+ * listener is installed, and `prefetch` — which exists to load the chunks of a
468
+ * route this page will render — has no page to load them for.
469
+ *
470
+ * That is one branch rather than a second provider because the two differ in
471
+ * what happens on a click and in nothing else. A second implementation would
472
+ * have had to keep `resolved`, `pending`, the context and every hook that
473
+ * reads it in step with this one, which is four things to keep in step for one
474
+ * that actually differs.
475
+ */
476
+ export component RouterProvider(
477
+ url: string,
478
+ initial?: ResolvedRoute,
479
+ flight?: Promise<FlightRoot>,
480
+ children: React.Node,
481
+ ) {
482
+ if (flight != null) {
483
+ return <FlightRouter flight={flight}>{children}</FlightRouter>;
484
+ }
485
+ if (initial == null) {
486
+ throw new Error(
487
+ "@uniflowed/router: RouterProvider was given neither a Flight payload nor a resolved route " +
488
+ "to start from. `virtual:uf/client` and `virtual:uf/server` hand it one of the two.",
489
+ );
490
+ }
491
+ return (
492
+ <ModuleRouter url={url} initial={initial}>
493
+ {children}
494
+ </ModuleRouter>
495
+ );
496
+ }
497
+
498
+ /**
499
+ * The key an intercepted navigation writes into its history entry.
500
+ *
501
+ * One string in `history.state` rather than the resolved route, because the
502
+ * browser structured-clones the state and keeps it across a reload: it can hold
503
+ * a URL and nothing with a module in it. A URL is also all the entry needs —
504
+ * where the navigation came from, resolved again when that page is not the one
505
+ * on screen, and the entry's own URL for what intercepted it.
506
+ */
507
+ const INTERCEPTED_FROM = "uf:intercepted-from";
508
+
509
+ /**
510
+ * The state a history entry for `resolved` is written with.
511
+ *
512
+ * `null` for a navigation nothing intercepted, which is what every entry this
513
+ * router wrote was before interception existed.
514
+ */
515
+ function historyStateFor(resolved: ResolvedRoute): mixed {
516
+ const interception = resolved.interception;
517
+ if (interception == null) {
518
+ return null;
519
+ }
520
+ return { [INTERCEPTED_FROM]: interception.base.pathname + interception.base.search };
521
+ }
522
+
523
+ /** Where the history entry holding `state` was intercepted from, if it was. */
524
+ function interceptedFrom(state: mixed): ?string {
525
+ if (state == null || typeof state !== "object" || Array.isArray(state)) {
526
+ return null;
527
+ }
528
+ const from = state[INTERCEPTED_FROM];
529
+ return typeof from === "string" ? from : null;
530
+ }
531
+
532
+ /**
533
+ * `state` without the interception in it.
534
+ *
535
+ * What is left is handed back rather than cleared, because an entry's state is
536
+ * not only this router's to write: another library may have put something
537
+ * beside it.
538
+ */
539
+ function withoutInterception(state: mixed): mixed {
540
+ if (state == null || typeof state !== "object" || Array.isArray(state)) {
541
+ return state;
542
+ }
543
+ const rest: { [string]: mixed } = {};
544
+ for (const key of Object.keys(state)) {
545
+ if (key !== INTERCEPTED_FROM) {
546
+ rest[key] = state[key];
547
+ }
548
+ }
549
+ return Object.keys(rest).length === 0 ? null : rest;
550
+ }
551
+
552
+ /**
553
+ * The provider for a route resolved from its modules: a single-page
554
+ * application, and a project that turned `app.rsc` off.
555
+ *
556
+ * It is also the provider that intercepts. Whether a navigation is intercepted
557
+ * is a question about the slots on screen, and only a router holding a route
558
+ * resolved from its modules has them to ask; a payload holds a rendered tree.
559
+ * See [`resolveInterception`].
560
+ */
561
+ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node) {
562
+ const [resolved, setResolved] = useState<ResolvedRoute>(initial);
563
+ const [pending, setPending] = useState<boolean>(false);
564
+ // Read once per render rather than per navigation: it is installed by the
565
+ // entry before the first render and never changes after it, and a `Link`
566
+ // that asked at click time would be asking a question whose answer decided
567
+ // what it rendered.
568
+ const navigation = navigationMode();
569
+ // The route on screen, for the code that runs after a render has finished.
570
+ //
571
+ // State is what renders, and a closure only sees the state of the render that
572
+ // made it: the `popstate` listener below is installed once and would go on
573
+ // reading the first route forever, and a navigation awaits between reading
574
+ // what is on screen and replacing it. Interception is what needs the answer —
575
+ // whether a navigation is intercepted is a question about the page it starts
576
+ // on — and `show` writes both in the same breath, so the two cannot disagree
577
+ // about what was last committed.
578
+ const shown = React.useRef<ResolvedRoute>(initial);
579
+ const show = (next: ResolvedRoute) => {
580
+ shown.current = next;
581
+ setResolved(next);
582
+ };
583
+
584
+ const navigate = async (to: string, options?: NavigateOptions): Promise<void> => {
585
+ if (!isBrowser()) {
586
+ return;
587
+ }
588
+ const target = new URL(to, window.location.href);
589
+ const next = target.pathname + target.search;
590
+ // The browser's job in this application. `assign` and `replace` rather
591
+ // than the history API, because the point is a document request: the
592
+ // history entry, the scroll position, the `Referer` and the unload
593
+ // handlers are then the browser's, done the way they are done for a link
594
+ // in a page with no JavaScript on it at all.
595
+ if (navigation === "document") {
596
+ if (options?.replace === true) {
597
+ window.location.replace(target.href);
598
+ } else {
599
+ window.location.assign(target.href);
600
+ }
601
+ return;
602
+ }
603
+ // Interception first, because it is a question about the page this
604
+ // navigation starts on rather than about the one it reaches. A slot on
605
+ // screen that intercepts the URL renders a page of its own, so whether the
606
+ // URL's ordinary page is in this bundle — the paragraph below — is not a
607
+ // question this navigation has to ask.
608
+ const origin = beneath(shown.current);
609
+ const intercepting = interceptingRoutes(origin.slots, target.pathname).length > 0;
610
+ // The half of the split that is not about bytes. A route whose page is not
611
+ // in this bundle is not a route this router can render, and pretending
612
+ // otherwise is the silent break: the navigation would resolve to nothing
613
+ // and the visitor would be left on the page they clicked from. The browser
614
+ // has the document, so the browser does the navigation — which is what a
615
+ // link does when there is no JavaScript at all, and what the anchor
616
+ // `Link` renders would have done on its own.
617
+ if (!intercepting) {
618
+ const matched = matchRoute(routeTable().routes, target.pathname);
619
+ if (matched != null && !hasClientPage(matched.route)) {
620
+ window.location.assign(target.href);
621
+ return;
622
+ }
623
+ }
624
+ setPending(true);
625
+ try {
626
+ const nextResolved =
627
+ (intercepting ? await resolveInterception(routeTable(), origin, next) : null) ??
628
+ (await resolveMatch(routeTable(), next));
629
+ // An intercepted entry remembers where it was intercepted from, so back
630
+ // and forward can put the page underneath under it again. Every other
631
+ // entry is written the way it always was.
632
+ const state = historyStateFor(nextResolved);
633
+ if (options?.replace === true) {
634
+ window.history.replaceState(state, "", next + target.hash);
635
+ } else {
636
+ window.history.pushState(state, "", next + target.hash);
637
+ }
638
+ const commit = () => {
639
+ show(nextResolved);
640
+ setPending(false);
641
+ };
642
+ if (options?.transition === false) {
643
+ startTransition(commit);
644
+ } else {
645
+ withViewTransition(nextResolved.viewTransition, commit);
646
+ }
647
+ // An intercepted navigation leaves the page underneath where the reader
648
+ // left it — the modal opens over the post they clicked, not over the top
649
+ // of the feed — so it moves the window only for a caller who asks with
650
+ // `scroll: true`. Every other navigation scrolls unless asked not to.
651
+ const scroll =
652
+ nextResolved.interception == null ? options?.scroll !== false : options?.scroll === true;
653
+ if (scroll) {
654
+ if (target.hash !== "") {
655
+ const element = document.getElementById(target.hash.slice(1));
656
+ if (element != null) {
657
+ element.scrollIntoView();
658
+ return;
659
+ }
660
+ }
661
+ window.scrollTo(0, 0);
662
+ }
663
+ } catch (error) {
664
+ setPending(false);
665
+ throw error;
666
+ }
667
+ };
668
+
669
+ useEffect(() => {
670
+ if (!isBrowser()) {
671
+ return undefined;
672
+ }
673
+ // An entry that says it was intercepted, under a provider that has only
674
+ // just mounted, is an entry the browser reloaded or restored — and the
675
+ // document on screen is what a request for its URL returned, which is the
676
+ // ordinary page. Clearing the mark makes the entry say what the reader is
677
+ // looking at, so coming back to it later renders this page again rather
678
+ // than a modal over a page they never saw one on.
679
+ const restored = window.history.state;
680
+ if (interceptedFrom(restored) != null) {
681
+ window.history.replaceState(withoutInterception(restored), "", window.location.href);
682
+ }
683
+ // Nothing pushed a history entry, so there is nothing to pop back into: a
684
+ // document-navigating application left this page when the link was
685
+ // followed, and the back button asks the browser for the previous document
686
+ // rather than asking this listener to rebuild it. Installing one anyway
687
+ // would put a `resolveMatch` on the back button of a page that is about to
688
+ // be replaced by the one the browser already has.
689
+ if (navigation === "document") {
690
+ return undefined;
691
+ }
692
+ const arrive = (nextResolved: ResolvedRoute) => {
693
+ // The back button is a navigation, and a navigation that animates in
694
+ // one direction and cuts in the other would read as a bug in the
695
+ // animation rather than as a decision.
696
+ withViewTransition(nextResolved.viewTransition, () => {
697
+ show(nextResolved);
698
+ });
699
+ };
700
+ const onPopState = () => {
701
+ const next = window.location.pathname + window.location.search;
702
+ // Back or forward into an entry an interception wrote: the page it was
703
+ // intercepted from, with the interception over it again. That page is
704
+ // resolved afresh only when it is not already the one underneath, so
705
+ // back from the second photo to the first leaves the feed exactly where
706
+ // it is.
707
+ const from = interceptedFrom(window.history.state);
708
+ if (from != null) {
709
+ const underneath = beneath(shown.current);
710
+ const origin =
711
+ underneath.pathname + underneath.search === from
712
+ ? Promise.resolve(underneath)
713
+ : resolveMatch(routeTable(), from);
714
+ origin
715
+ .then((page) => resolveInterception(routeTable(), page, next))
716
+ // Nothing on that page intercepts the entry's URL any more — a
717
+ // module that will not load, a table a development server rebuilt —
718
+ // so the entry is what its URL names.
719
+ .then((intercepted) => intercepted ?? resolveMatch(routeTable(), next))
720
+ .then(arrive);
721
+ return;
722
+ }
723
+ // Back into a route this bundle has no page for. The history entry is
724
+ // already the browser's — it moved before this listener ran — so the
725
+ // document that belongs to it is what has to be fetched.
726
+ const matched = matchRoute(routeTable().routes, window.location.pathname);
727
+ if (matched != null && !hasClientPage(matched.route)) {
728
+ window.location.reload();
729
+ return;
730
+ }
731
+ resolveMatch(routeTable(), next).then(arrive);
732
+ };
733
+ window.addEventListener("popstate", onPopState);
734
+ return () => {
735
+ window.removeEventListener("popstate", onPopState);
736
+ };
737
+ }, []);
738
+
739
+ const router: Router = {
740
+ push: (to, options) => navigate(to, options),
741
+ replace: (to) => navigate(to, { replace: true }),
742
+ prefetch: async (to) => {
743
+ // A prefetch loads the modules the *next render* will need, and under
744
+ // document navigation there is no next render in this page: the browser
745
+ // fetches a document and throws this one away. Loading the chunks would
746
+ // be bytes spent on a page that is leaving, so this declines rather than
747
+ // warming a cache nothing reads.
748
+ if (!isBrowser() || navigation === "document") {
749
+ return;
750
+ }
751
+ const target = new URL(to, window.location.href);
752
+ // What the next render will need is decided the way the navigation will
753
+ // decide it: a URL a slot on screen intercepts renders that slot's page,
754
+ // so that is the module worth having, and the page the URL names is not.
755
+ const intercepting = interceptingRoutes(beneath(shown.current).slots, target.pathname);
756
+ if (intercepting.length > 0) {
757
+ await Promise.all(
758
+ intercepting.flatMap((route) => [
759
+ loadOnce(route.page),
760
+ ...route.layouts.map((layout) => loadOnce(layout)),
761
+ ]),
762
+ );
763
+ return;
764
+ }
765
+ const matched = matchRoute(routeTable().routes, target.pathname);
766
+ const load = matched?.route.page;
767
+ if (matched == null || load == null) {
768
+ return;
769
+ }
770
+ await Promise.all([
771
+ loadOnce(load),
772
+ ...matched.route.layouts.map((layout) => loadOnce(layout)),
773
+ ]);
774
+ },
775
+ refresh: async () => {
776
+ if (!isBrowser()) {
777
+ return;
778
+ }
779
+ // The same URL, rendered again — which under document navigation is what
780
+ // the browser calls a reload. Resolving it in the page instead would
781
+ // re-run the loader and commit a tree whose links this application has
782
+ // already said it does not drive.
783
+ if (navigation === "document") {
784
+ window.location.reload();
785
+ return;
786
+ }
787
+ // A refresh of an intercepted page refreshes both of its halves: the
788
+ // page underneath, resolved again for its own URL, and the interception
789
+ // resolved again over it. Resolving only the address bar's URL would
790
+ // close the modal, which is a navigation nobody asked for.
791
+ const interception = shown.current.interception;
792
+ const nextResolved =
793
+ interception == null
794
+ ? await resolveMatch(routeTable(), window.location.pathname + window.location.search)
795
+ : ((await resolveInterception(
796
+ routeTable(),
797
+ await resolveMatch(
798
+ routeTable(),
799
+ interception.base.pathname + interception.base.search,
800
+ ),
801
+ interception.pathname + interception.search,
802
+ )) ?? (await resolveMatch(routeTable(), interception.pathname + interception.search)));
803
+ // No view transition, and it is the one place that is right: a refresh
804
+ // is the same URL resolved again, so a transition would animate a page
805
+ // into itself — a cross-fade between two frames of the same thing,
806
+ // which is a flicker with a name.
807
+ startTransition(() => {
808
+ show(nextResolved);
809
+ });
810
+ },
811
+ back: () => {
812
+ if (isBrowser()) {
813
+ window.history.back();
814
+ }
815
+ },
816
+ forward: () => {
817
+ if (isBrowser()) {
818
+ window.history.forward();
819
+ }
820
+ },
821
+ };
822
+
823
+ const value: RouterState = {
824
+ route: routeState(resolved),
825
+ view: { kind: "modules", resolved },
826
+ router,
827
+ pending,
828
+ navigation,
829
+ };
830
+ return <RouterContext.Provider value={value}>{children}</RouterContext.Provider>;
1852
831
  }
1853
832
 
1854
833
  /**
1855
- * Provides the current route to the tree and performs navigation.
1856
- *
1857
- * On the server the route is fixed for the request. In the browser the
1858
- * provider listens to history and to `Link` clicks; a navigation resolves the
1859
- * next route (loading its chunks and running its loader) *before* committing,
1860
- * inside a transition, so the previous page stays interactive meanwhile.
1861
- *
1862
- * # Unless the application asked the browser to do it
1863
- *
1864
- * Under `app.rendering.navigation: "document"` every one of those sentences
1865
- * stops being true, and the provider is still here: the tree below it still
1866
- * reads `useRoute`, still renders `<RouteView>`, and still hydrates whatever
1867
- * `"use client"` boundary made the document interactive. What it does not do is
1868
- * take the link over. `navigate` hands the URL to the browser, no `popstate`
1869
- * listener is installed, and `prefetch` — which exists to load the chunks of a
1870
- * route this page will render — has no page to load them for.
1871
- *
1872
- * That is one branch rather than a second provider because the two differ in
1873
- * what happens on a click and in nothing else. A second implementation would
1874
- * have had to keep `resolved`, `pending`, the context and every hook that
1875
- * reads it in step with this one, which is four things to keep in step for one
1876
- * that actually differs.
834
+ * The provider for a route React Server Components rendered.
835
+ *
836
+ * What it holds is the payload rather than a resolved route: `use` reads its
837
+ * root — the route a hook reads and the tree `RouteView` renders — and a
838
+ * navigation fetches the next route's payload and swaps the promise. The
839
+ * browser resolves nothing and imports no page, layout or loader; the server
840
+ * did all three, and a component that needs the browser arrived as a client
841
+ * reference inside the tree.
842
+ *
843
+ * A navigation reads the next payload's root before it commits, for the reason
844
+ * [`ModuleRouter`] resolves the next route before it commits: the page on
845
+ * screen stays interactive while the next one is on its way, and a commit
846
+ * inside a view transition is synchronous, so a root that had not arrived would
847
+ * show nothing rather than the page being left. What may still suspend after
848
+ * the commit is a `$loading.js` boundary inside the new tree, which is what that
849
+ * file is for.
1877
850
  */
1878
- export component RouterProvider(url: string, initial: ResolvedRoute, children: React.Node) {
1879
- const [resolved, setResolved] = useState<ResolvedRoute>(initial);
851
+ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
852
+ const [current, setCurrent] = useState<Promise<FlightRoot>>(flight);
1880
853
  const [pending, setPending] = useState<boolean>(false);
1881
- // Read once per render rather than per navigation: it is installed by the
1882
- // entry before the first render and never changes after it, and a `Link`
1883
- // that asked at click time would be asking a question whose answer decided
1884
- // what it rendered.
854
+ const root = use(current);
855
+ // Read once per render, for the reason `ModuleRouter` reads it once.
1885
856
  const navigation = navigationMode();
1886
857
 
1887
858
  const navigate = async (to: string, options?: NavigateOptions): Promise<void> => {
@@ -1890,11 +861,7 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
1890
861
  }
1891
862
  const target = new URL(to, window.location.href);
1892
863
  const next = target.pathname + target.search;
1893
- // The browser's job in this application. `assign` and `replace` rather
1894
- // than the history API, because the point is a document request: the
1895
- // history entry, the scroll position, the `Referer` and the unload
1896
- // handlers are then the browser's, done the way they are done for a link
1897
- // in a page with no JavaScript on it at all.
864
+ // The browser's job in this application; `ModuleRouter` has the argument.
1898
865
  if (navigation === "document") {
1899
866
  if (options?.replace === true) {
1900
867
  window.location.replace(target.href);
@@ -1903,34 +870,34 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
1903
870
  }
1904
871
  return;
1905
872
  }
1906
- // The half of the split that is not about bytes. A route whose page is not
1907
- // in this bundle is not a route this router can render, and pretending
1908
- // otherwise is the silent break: the navigation would resolve to nothing
1909
- // and the visitor would be left on the page they clicked from. The browser
1910
- // has the document, so the browser does the navigation — which is what a
1911
- // link does when there is no JavaScript at all, and what the anchor
1912
- // `Link` renders would have done on its own.
1913
- const matched = matchRoute(routeTable().routes, target.pathname);
1914
- if (matched != null && !hasClientPage(matched.route)) {
1915
- window.location.assign(target.href);
1916
- return;
1917
- }
1918
873
  setPending(true);
1919
874
  try {
1920
- const nextResolved = await resolveMatch(routeTable(), next);
875
+ const fetched = await (takePrefetched(next) ?? fetchFlight(next));
876
+ // Not a payload: a redirect off this origin, or a host that has no payload
877
+ // for this URL. The browser loads it as a document, which is what the
878
+ // anchor would have done.
879
+ if (fetched.kind === "document") {
880
+ window.location.assign(fetched.url);
881
+ return;
882
+ }
883
+ const payload = fetched.root;
884
+ const nextRoot = await payload;
885
+ // The URL the payload came from, which is a redirect's target when the
886
+ // route redirected: the history entry is where the visitor ended up.
887
+ const landed = fetched.url + target.hash;
1921
888
  if (options?.replace === true) {
1922
- window.history.replaceState(null, "", next + target.hash);
889
+ window.history.replaceState(null, "", landed);
1923
890
  } else {
1924
- window.history.pushState(null, "", next + target.hash);
891
+ window.history.pushState(null, "", landed);
1925
892
  }
1926
893
  const commit = () => {
1927
- setResolved(nextResolved);
894
+ setCurrent(payload);
1928
895
  setPending(false);
1929
896
  };
1930
897
  if (options?.transition === false) {
1931
898
  startTransition(commit);
1932
899
  } else {
1933
- withViewTransition(nextResolved.viewTransition, commit);
900
+ withViewTransition(nextRoot.route.viewTransition, commit);
1934
901
  }
1935
902
  if (options?.scroll !== false) {
1936
903
  if (target.hash !== "") {
@@ -1952,33 +919,37 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
1952
919
  if (!isBrowser()) {
1953
920
  return undefined;
1954
921
  }
1955
- // Nothing pushed a history entry, so there is nothing to pop back into: a
1956
- // document-navigating application left this page when the link was
1957
- // followed, and the back button asks the browser for the previous document
1958
- // rather than asking this listener to rebuild it. Installing one anyway
1959
- // would put a `resolveMatch` on the back button of a page that is about to
1960
- // be replaced by the one the browser already has.
922
+ // No history entry was pushed, so there is nothing to pop back into; see
923
+ // `ModuleRouter`.
1961
924
  if (navigation === "document") {
1962
925
  return undefined;
1963
926
  }
1964
927
  const onPopState = () => {
1965
928
  const next = window.location.pathname + window.location.search;
1966
- // Back into a route this bundle has no page for. The history entry is
1967
- // already the browser's — it moved before this listener ran — so the
1968
- // document that belongs to it is what has to be fetched.
1969
- const matched = matchRoute(routeTable().routes, window.location.pathname);
1970
- if (matched != null && !hasClientPage(matched.route)) {
1971
- window.location.reload();
1972
- return;
1973
- }
1974
- resolveMatch(routeTable(), next).then((nextResolved) => {
1975
- // The back button is a navigation, and a navigation that animates in
1976
- // one direction and cuts in the other would read as a bug in the
1977
- // animation rather than as a decision.
1978
- withViewTransition(nextResolved.viewTransition, () => {
1979
- setResolved(nextResolved);
1980
- });
1981
- });
929
+ // The history entry already moved; a payload that cannot be had for it is
930
+ // a document to load, and a reload is the browser's way to load it.
931
+ fetchFlight(next).then(
932
+ (fetched) => {
933
+ if (fetched.kind === "document") {
934
+ window.location.reload();
935
+ return;
936
+ }
937
+ const payload = fetched.root;
938
+ payload.then(
939
+ (nextRoot) => {
940
+ withViewTransition(nextRoot.route.viewTransition, () => {
941
+ setCurrent(payload);
942
+ });
943
+ },
944
+ () => {
945
+ window.location.reload();
946
+ },
947
+ );
948
+ },
949
+ () => {
950
+ window.location.reload();
951
+ },
952
+ );
1982
953
  };
1983
954
  window.addEventListener("popstate", onPopState);
1984
955
  return () => {
@@ -1990,47 +961,36 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
1990
961
  push: (to, options) => navigate(to, options),
1991
962
  replace: (to) => navigate(to, { replace: true }),
1992
963
  prefetch: async (to) => {
1993
- // A prefetch loads the modules the *next render* will need, and under
1994
- // document navigation there is no next render in this page: the browser
1995
- // fetches a document and throws this one away. Loading the chunks would
1996
- // be bytes spent on a page that is leaving, so this declines rather than
1997
- // warming a cache nothing reads.
964
+ // Under document navigation there is no next render in this page to
965
+ // fetch a payload for; see `ModuleRouter`'s prefetch.
1998
966
  if (!isBrowser() || navigation === "document") {
1999
967
  return;
2000
968
  }
2001
969
  const target = new URL(to, window.location.href);
2002
- const matched = matchRoute(routeTable().routes, target.pathname);
2003
- const load = matched?.route.page;
2004
- if (matched == null || load == null) {
970
+ if (target.origin !== window.location.origin) {
2005
971
  return;
2006
972
  }
2007
- await Promise.all([
2008
- loadOnce(load),
2009
- ...matched.route.layouts.map((layout) => loadOnce(layout)),
2010
- ]);
973
+ await prefetchFlight(target.pathname + target.search);
2011
974
  },
2012
975
  refresh: async () => {
2013
976
  if (!isBrowser()) {
2014
977
  return;
2015
978
  }
2016
- // The same URL, rendered again — which under document navigation is what
2017
- // the browser calls a reload. Resolving it in the page instead would
2018
- // re-run the loader and commit a tree whose links this application has
2019
- // already said it does not drive.
2020
979
  if (navigation === "document") {
2021
980
  window.location.reload();
2022
981
  return;
2023
982
  }
2024
- const nextResolved = await resolveMatch(
2025
- routeTable(),
2026
- window.location.pathname + window.location.search,
2027
- );
2028
- // No view transition, and it is the one place that is right: a refresh
2029
- // is the same URL resolved again, so a transition would animate a page
2030
- // into itself — a cross-fade between two frames of the same thing,
2031
- // which is a flicker with a name.
983
+ const fetched = await fetchFlight(window.location.pathname + window.location.search);
984
+ if (fetched.kind === "document") {
985
+ window.location.reload();
986
+ return;
987
+ }
988
+ const payload = fetched.root;
989
+ await payload;
990
+ // No view transition: a refresh is the same URL rendered again. See
991
+ // `ModuleRouter`'s refresh.
2032
992
  startTransition(() => {
2033
- setResolved(nextResolved);
993
+ setCurrent(payload);
2034
994
  });
2035
995
  },
2036
996
  back: () => {
@@ -2045,11 +1005,61 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
2045
1005
  },
2046
1006
  };
2047
1007
 
2048
- const value: RouterState = { resolved, router, pending, navigation };
1008
+ const value: RouterState = {
1009
+ route: root.route,
1010
+ view: { kind: "flight", tree: root.tree },
1011
+ router,
1012
+ pending,
1013
+ navigation,
1014
+ };
2049
1015
  return <RouterContext.Provider value={value}>{children}</RouterContext.Provider>;
2050
1016
  }
2051
1017
 
2052
- hook useRouterState(): RouterState {
1018
+ /**
1019
+ * Payloads a `Link` fetched on intent, kept for the navigation that follows it.
1020
+ *
1021
+ * Bounded and short-lived, because a payload is the rendering of a route at one
1022
+ * moment: one old enough to disagree with the server is one a navigation should
1023
+ * not show, and a page with a hundred links hovered over must not hold a hundred
1024
+ * renderings. Taken rather than read, so a prefetched payload serves exactly one
1025
+ * navigation and the next visit to the same URL asks again.
1026
+ */
1027
+ const PREFETCH_LIMIT = 32;
1028
+ const PREFETCH_LIFETIME_MS = 30000;
1029
+ const prefetchedFlights: Map<
1030
+ string,
1031
+ {| readonly fetched: Promise<FetchedFlight>, readonly at: number |},
1032
+ > = new Map();
1033
+
1034
+ function prefetchFlight(url: string): Promise<FetchedFlight> {
1035
+ const existing = prefetchedFlights.get(url);
1036
+ if (existing != null && Date.now() - existing.at < PREFETCH_LIFETIME_MS) {
1037
+ return existing.fetched;
1038
+ }
1039
+ if (existing == null && prefetchedFlights.size >= PREFETCH_LIMIT) {
1040
+ const oldest = prefetchedFlights.keys().next();
1041
+ if (oldest.done !== true) {
1042
+ prefetchedFlights.delete(oldest.value);
1043
+ }
1044
+ }
1045
+ const fetched = fetchFlight(url);
1046
+ prefetchedFlights.set(url, { fetched, at: Date.now() });
1047
+ fetched.catch(() => {
1048
+ prefetchedFlights.delete(url);
1049
+ });
1050
+ return fetched;
1051
+ }
1052
+
1053
+ function takePrefetched(url: string): Promise<FetchedFlight> | null {
1054
+ const entry = prefetchedFlights.get(url);
1055
+ prefetchedFlights.delete(url);
1056
+ if (entry == null || Date.now() - entry.at >= PREFETCH_LIFETIME_MS) {
1057
+ return null;
1058
+ }
1059
+ return entry.fetched;
1060
+ }
1061
+
1062
+ export hook useRouterState(): RouterState {
2053
1063
  const state = useContext(RouterContext);
2054
1064
  if (state == null) {
2055
1065
  throw new Error(
@@ -2061,13 +1071,13 @@ hook useRouterState(): RouterState {
2061
1071
 
2062
1072
  /** The current route. */
2063
1073
  export hook useRoute(): RouteInfo {
2064
- const { resolved, pending } = useRouterState();
1074
+ const { route, pending } = useRouterState();
2065
1075
  return {
2066
- path: resolved.path,
2067
- pathname: resolved.pathname,
2068
- params: resolved.params,
2069
- searchParams: resolved.searchParams,
2070
- data: useResolvedData(resolved),
1076
+ path: route.path,
1077
+ pathname: route.pathname,
1078
+ params: route.params,
1079
+ searchParams: route.searchParams,
1080
+ data: useResolvedData(route),
2071
1081
  pending,
2072
1082
  };
2073
1083
  }
@@ -2088,9 +1098,9 @@ export hook useRoute(): RouteInfo {
2088
1098
  * it is a benefit not taken rather than a regression, and it is visible: the
2089
1099
  * fallback does not appear.
2090
1100
  */
2091
- hook useResolvedData(resolved: ResolvedRoute): mixed {
2092
- const loader = resolved.deferred;
2093
- return loader == null ? resolved.data : use(loader);
1101
+ hook useResolvedData(route: RouteState): mixed {
1102
+ const loader = route.deferred;
1103
+ return loader == null ? route.data : use(loader);
2094
1104
  }
2095
1105
 
2096
1106
  /** Navigation. */
@@ -2119,7 +1129,7 @@ export hook useRouter(): Router {
2119
1129
  * same file, keyed by route. Until it is there, this says what is true.
2120
1130
  */
2121
1131
  export hook useLoaderData(): mixed {
2122
- return useResolvedData(useRouterState().resolved);
1132
+ return useResolvedData(useRouterState().route);
2123
1133
  }
2124
1134
 
2125
1135
  /**
@@ -2145,150 +1155,38 @@ const BOUNDARY_MARKS: boolean = import.meta.hot != null;
2145
1155
  * Renders the matched page inside its layouts, innermost last, with the
2146
1156
  * document metadata as hoistable head elements.
2147
1157
  *
2148
- * # One walk down the layouts, not three
2149
- *
2150
- * The layouts, the error boundary and the `<Suspense>` boundaries all have to
2151
- * be threaded into the same stack at the depth each was declared at, so this
2152
- * is one descending loop over that depth rather than a pass per kind. `depth`
2153
- * counts the layouts still *outside* the element built so far, which is what
2154
- * `above` means on both a route's `errorBoundary` and each of its `loading`
2155
- * entries — one number, one meaning, one place it is compared.
2156
- *
2157
- * # Where the error boundaries go
2158
- *
2159
- * Two, and they are not the same thing twice. The inner one is the project's
2160
- * `$error.js`, placed at the depth the file sits at, so the layouts above
2161
- * it stay mounted and interactive while the subtree below is replaced — that
2162
- * placement *is* the feature. The outer one has no module and so renders the
2163
- * framework's page; it is what stands between a throw in a root layout, or in
2164
- * the error component itself, and an unmounted document. A single boundary
2165
- * cannot be both: put it outside and a page's throw takes the navigation down
2166
- * with it; put it inside and nothing catches the layout above.
2167
- *
2168
- * # Where the loading boundaries go
2169
- *
2170
- * Inside the layout of the segment that declared the file and outside
2171
- * everything under it, which is what makes the shell arrive first: a renderer
2172
- * streaming this tree can send every layout down to the boundary, and the
2173
- * fallback, before whatever the page is waiting for has resolved. A segment
2174
- * with no `$loading.js` contributes no boundary at all — it is not wrapped
2175
- * in a `<Suspense fallback={null}>` on the way past — so a project that
2176
- * declares none renders the tree it rendered before this existed, and a page
2177
- * that suspends without a boundary above it still fails the way React says it
2178
- * should rather than silently rendering nothing.
2179
- *
2180
- * The error boundary goes *outside* the fallback at the same depth. A throw
2181
- * while the page is resolving has to reach a boundary that is still mounted,
2182
- * and the `<Suspense>` is part of what the throw came out of.
2183
- *
2184
- * # Where the templates go
2185
- *
2186
- * Inside their own segment's layout and outside everything else at that depth
2187
- * — the error boundary, the fallback and the page — which is what makes a
2188
- * template's remount mean "this segment and what is under it" and a layout's
2189
- * persistence mean "this segment's frame". The two files are the same wrapper
2190
- * with opposite answers to one question, so they are one line apart here, and
2191
- * the whole of the difference is the `key` — see [`insideTemplates`], which is
2192
- * that line's other half.
2193
- *
2194
- * # Where the boundary marks go
2195
- *
2196
- * Inside each boundary and around nothing else, under `uf dev` only. A
2197
- * `<Suspense>` and a class boundary each render no element of their own, so the
2198
- * run of nodes one owns is indistinguishable on the page from the layout's own
2199
- * nodes beside it — the marks are what distinguish it, and this loop is the
2200
- * only place that knows which boundary is which. `./boundaries.js` has the
2201
- * mechanism and the argument; every reference to it here is inside a
2202
- * [`BOUNDARY_MARKS`] branch, so a build has none of it. See
2203
- * ubugeeei-prod/uf#520.
1158
+ * The walk itself is `composeRoute` in `./compose.js`, which has the whole
1159
+ * argument for where each boundary goes. What is left here is the half that
1160
+ * reads the router: which route, the page element that carries the loader's
1161
+ * answer and the payload the browser hydrates it from, and — under `uf dev` —
1162
+ * the boundary marks and the report that watches them. See ubugeeei-prod/uf#520.
2204
1163
  */
2205
1164
  export component RouteView() {
2206
- const { resolved } = useRouterState();
2207
- const { module, above } = resolved.errorBoundary;
1165
+ const { view } = useRouterState();
1166
+ // A tree a server composed for React Server Components is the whole of it:
1167
+ // the boundaries, the fallbacks, the marks and the head were placed by
1168
+ // `composeRoute` on the server, before any of it was written into the payload.
1169
+ if (view.kind === "flight") {
1170
+ return view.tree;
1171
+ }
1172
+ const resolved = view.resolved;
2208
1173
  const loader = resolved.deferred;
2209
- // The route's boundaries, named once and read by both the marks below and the
2210
- // report that watches them. `installedTable` rather than [`routeTable`],
2211
- // which throws: a test may render this view without an entry having installed
2212
- // a table, and an error boundary named by its depth alone is worth less than
2213
- // one named by its file rather than wrong.
1174
+ // The route's boundaries, named once and read by both the marks the
1175
+ // composition places and the report that watches them. `installedTable`
1176
+ // rather than [`routeTable`], which throws: a test may render this view
1177
+ // without an entry having installed a table, and an error boundary named by
1178
+ // its depth alone is worth less than one named by its file rather than wrong.
2214
1179
  const marks = BOUNDARY_MARKS
2215
1180
  ? routeBoundaries(
2216
1181
  resolved,
2217
1182
  nearestBoundary(installedTable?.errors ?? [], resolved.pathname)?.file,
2218
1183
  )
2219
1184
  : null;
2220
- // The innermost element, so the `use` inside `AwaitedPage` suspends below
2221
- // every boundary the loop below adds — which is what makes the layouts and
2222
- // the fallback the shell rather than something waiting behind the loader.
2223
- let element: React.Node =
1185
+ const page =
2224
1186
  loader == null ? <RenderedPage data={resolved.data} /> : <AwaitedPage loader={loader} />;
2225
-
2226
- for (let depth = resolved.layouts.length; depth >= 0; depth -= 1) {
2227
- // Backwards over a root-first list, so the deepest segment's fallback ends
2228
- // up closest to the page. Two segments land on the same depth whenever the
2229
- // inner one declares no layout of its own, and then this order is the only
2230
- // thing that keeps them nested the way the directories are.
2231
- for (let index = resolved.loading.length - 1; index >= 0; index -= 1) {
2232
- const boundary = resolved.loading[index];
2233
- if (boundary.above !== depth) {
2234
- continue;
2235
- }
2236
- const Fallback = loadingComponent(boundary.module);
2237
- element = (
2238
- <Suspense fallback={<Fallback />}>
2239
- {BOUNDARY_MARKS ? insideBoundary(marks?.get(suspenseId(index)), element) : element}
2240
- </Suspense>
2241
- );
2242
- }
2243
- // Placed on `above` alone, and not on there being a module: a `null` one is
2244
- // the framework's own error page, and where it renders is exactly the
2245
- // question ubugeeei-prod/uf#351 asks. A project that declares no
2246
- // `$error.js` has the record the build synthesises for the router root,
2247
- // whose `above` is the root's layouts — so the framework's page appears
2248
- // inside the masthead rather than in place of the document. A table with no
2249
- // record at all answers 0, which puts this boundary outside every layout,
2250
- // where the outer one below already stood.
2251
- //
2252
- // Not around a route that already resolved to its error page: that page is
2253
- // the boundary's own component, and wrapping it in the same boundary would
2254
- // answer a throw inside it with itself.
2255
- if (depth === above && resolved.error == null) {
2256
- element = (
2257
- <RouteErrorBoundary module={module} resetKey={resolved.pathname}>
2258
- {BOUNDARY_MARKS ? insideBoundary(marks?.get(ROUTE_ERROR_ID), element) : element}
2259
- </RouteErrorBoundary>
2260
- );
2261
- }
2262
- element = insideTemplates(element, resolved, depth);
2263
- if (depth > 0) {
2264
- const Layout = layoutComponent(resolved.layouts[depth - 1]);
2265
- // The slots declared on this layout's own segment, beside `children`.
2266
- // Spread rather than passed as one `slots` object, because a slot is a
2267
- // prop a layout declares by name — `component Dashboard(children, team)`
2268
- // — and a bag would make every layout destructure a map to find out
2269
- // whether the router had anything for it.
2270
- // The spread first and `params` after it, so that a slot named after a
2271
- // prop the layout already has loses rather than wins. `@params` and
2272
- // `@children` are refused by the scan, and this is the second line of
2273
- // that defence for a table written by hand: losing a slot is a hole in
2274
- // the page, and overwriting `params` is every route in the segment
2275
- // rendering against the wrong parameters.
2276
- element = (
2277
- <Layout {...slotsAt(resolved.slots, depth)} params={resolved.params}>
2278
- {element}
2279
- </Layout>
2280
- );
2281
- }
2282
- }
2283
- if (needsRootStreamFrame(resolved)) {
2284
- element = <RootStreamFrame>{element}</RootStreamFrame>;
2285
- }
2286
1187
  return (
2287
1188
  <>
2288
- <Head metadata={resolved.metadata} />
2289
- <RouteErrorBoundary module={null} resetKey={resolved.pathname}>
2290
- {BOUNDARY_MARKS ? insideBoundary(marks?.get(ROOT_ERROR_ID), element) : element}
2291
- </RouteErrorBoundary>
1189
+ {composeRoute(resolved, { page, marks })}
2292
1190
  {/* After the tree rather than before it, so its effect runs once every
2293
1191
  mark below has had its own — which is the commit the marks are in. */}
2294
1192
  {BOUNDARY_MARKS && marks != null ? (
@@ -2298,28 +1196,6 @@ export component RouteView() {
2298
1196
  );
2299
1197
  }
2300
1198
 
2301
- /**
2302
- * Whether the outermost route fallback needs one host element above it.
2303
- *
2304
- * React can flush a shell whose suspended boundary is inside any host element,
2305
- * but not one whose boundary is a direct child of the render root. A route with
2306
- * no layout and a root `$loading.js` is exactly that second tree: every
2307
- * framework component above it renders no element, so the fallback waits for
2308
- * the page it was meant to stand in for. A root layout is already the element
2309
- * that can carry it, and deeper fallbacks sit inside a layout by construction.
2310
- */
2311
- function needsRootStreamFrame(resolved: ResolvedRoute): boolean {
2312
- return resolved.layouts.length === 0 && resolved.loading.some((boundary) => boundary.above === 0);
2313
- }
2314
-
2315
- component RootStreamFrame(children: React.Node) {
2316
- return (
2317
- <div data-uf-stream-root="" style={{ display: "contents" }}>
2318
- {children}
2319
- </div>
2320
- );
2321
- }
2322
-
2323
1199
  /**
2324
1200
  * The page, with the loader's answer and the copy of it the browser hydrates
2325
1201
  * from.
@@ -2339,7 +1215,12 @@ component RootStreamFrame(children: React.Node) {
2339
1215
  * read out of those very elements.
2340
1216
  */
2341
1217
  component RenderedPage(data: mixed) {
2342
- const { resolved } = useRouterState();
1218
+ const { view } = useRouterState();
1219
+ // Only ever rendered by `RouteView` for a route resolved from its modules.
1220
+ if (view.kind !== "modules") {
1221
+ return null;
1222
+ }
1223
+ const resolved = view.resolved;
2343
1224
  const Page = pageComponent(resolved.page);
2344
1225
  return (
2345
1226
  <>
@@ -2536,441 +1417,6 @@ function rowFailure(error: mixed): string {
2536
1417
  return error instanceof Error ? `${ROW_FAILURE} ${error.message}` : ROW_FAILURE;
2537
1418
  }
2538
1419
 
2539
- /**
2540
- * The component a page module renders: its default export, or the named
2541
- * `Page` that `uf create` scaffolds. An MDX page always has a default export.
2542
- */
2543
- function pageComponent(module: PageModule): React.ComponentType<PageRenderProps> {
2544
- const component = module.default ?? module.Page;
2545
- if (component == null) {
2546
- throw new Error(
2547
- "@uniflowed/router: a page module must export a component as `default` or `Page`",
2548
- );
2549
- }
2550
- return renderable(component);
2551
- }
2552
-
2553
- /**
2554
- * The component a loading module renders: `default`, or the named `Loading`.
2555
- *
2556
- * No props, unlike a page or a layout. A fallback is what the router shows
2557
- * when it does not have the route's answer yet, so there is nothing it could
2558
- * be handed that would be true — not `data`, which is the thing being waited
2559
- * for, and not `children`, because it renders instead of them.
2560
- */
2561
- function loadingComponent(module: LoadingModule): React.ComponentType<{||}> {
2562
- const component = module.default ?? module.Loading;
2563
- if (component == null) {
2564
- throw new Error(
2565
- "@uniflowed/router: a loading module must export a component as `default` or `Loading`",
2566
- );
2567
- }
2568
- return renderable(component);
2569
- }
2570
-
2571
- /**
2572
- * `element`, wrapped in every template declared at `depth`.
2573
- *
2574
- * Outside the boundaries at that depth and inside the layout below it, and
2575
- * backwards over a root-first list for the reason the fallbacks are: two
2576
- * segments share a depth whenever the inner one declares no layout, and this
2577
- * order is what keeps them nested the way the directories are.
2578
- *
2579
- * A function beside `RouteView` rather than a third loop inside it, and that
2580
- * is not only for reading: a third nested loop assigning to `element` is what
2581
- * the React Compiler's aliasing inference gave up on, and a component it
2582
- * cannot compile is a component it does not memoise.
2583
- */
2584
- function insideTemplates(
2585
- element: React.Node,
2586
- resolved: {
2587
- readonly pathname: string,
2588
- readonly params: RouteParams,
2589
- readonly templates: $ReadOnlyArray<ResolvedTemplate>,
2590
- ...
2591
- },
2592
- depth: number,
2593
- ): React.Node {
2594
- let out = element;
2595
- for (let index = resolved.templates.length - 1; index >= 0; index -= 1) {
2596
- const entry = resolved.templates[index];
2597
- if (entry.above !== depth) {
2598
- continue;
2599
- }
2600
- const Template = templateComponent(entry.module);
2601
- // Keyed on the pathname, which is the whole difference between this file
2602
- // and `$layout.js`: React throws the subtree away and builds it again
2603
- // whenever the key changes, and a navigation that changes only the query
2604
- // string leaves it alone.
2605
- out = (
2606
- <Template key={resolved.pathname} params={resolved.params}>
2607
- {out}
2608
- </Template>
2609
- );
2610
- }
2611
- return out;
2612
- }
2613
-
2614
- /**
2615
- * The slots declared at `depth`, as the props the layout there receives.
2616
- *
2617
- * One object per layout rather than one lookup per slot, so the common case —
2618
- * a project with no slots at all — allocates nothing and spreads nothing.
2619
- *
2620
- * A slot the URL addressed and that has no `$default.js` is `null` rather
2621
- * than absent: a layout that declares `team` receives `team` on every route,
2622
- * so `{team ?? <Empty />}` is a thing a project can write and rely on.
2623
- */
2624
- function slotsAt(
2625
- slots: $ReadOnlyArray<ResolvedSlot>,
2626
- depth: number,
2627
- ): { readonly [string]: React.Node } {
2628
- if (slots.length === 0) {
2629
- return EMPTY_SLOTS;
2630
- }
2631
- const props: { [string]: React.Node } = {};
2632
- for (const slot of slots) {
2633
- if (slot.above === depth) {
2634
- // `null` rather than an element that renders nothing, and the difference
2635
- // is the whole of what the prop is for: `{team ?? <Empty />}` has to be
2636
- // able to tell "this slot has nothing in it" from "this slot rendered
2637
- // something empty", and an element is never `null`.
2638
- props[slot.name] = slot.page == null ? null : <SlotView slot={slot} />;
2639
- }
2640
- }
2641
- return props;
2642
- }
2643
-
2644
- /** One object for every layout on a project that declares no slot. */
2645
- const EMPTY_SLOTS: { readonly [string]: React.Node } = Object.freeze({});
2646
-
2647
- /**
2648
- * One slot's tree: its page, inside the layouts declared under the slot, with
2649
- * the slots those layouts declare in turn.
2650
- *
2651
- * The same composition [`RouteView`] does and deliberately not the same
2652
- * function. A route's tree carries the things a slot does not have — the error
2653
- * boundary, the `<Suspense>` fallbacks, the templates, the head — and folding
2654
- * a second, simpler case into that loop would be four `if`s asking which of the
2655
- * two this is. What the two share is the *order*, page innermost and layouts
2656
- * backwards over a root-first list, and that is short enough to be right twice.
2657
- *
2658
- * A slot with no page is never rendered through this component at all —
2659
- * [`slotsAt`] hands the layout `null` instead, so the layout can tell an empty
2660
- * slot from one that rendered something empty. The guard below is what makes
2661
- * that a fact about one place rather than a convention two places share.
2662
- */
2663
- component SlotView(slot: ResolvedSlot) {
2664
- // Before the early return, because a hook after one is a hook that runs on
2665
- // some renders and not others. The search string is the route's — a slot
2666
- // matches the path and the query belongs to the URL, not to either match.
2667
- const { resolved } = useRouterState();
2668
- const page = slot.page;
2669
- if (page == null) {
2670
- return null;
2671
- }
2672
- const Page = pageComponent(page);
2673
- let element: React.Node = (
2674
- <Page params={slot.params} searchParams={resolved.searchParams} data={undefined} />
2675
- );
2676
- const templateContext = {
2677
- pathname: resolved.pathname,
2678
- params: slot.params,
2679
- templates: slot.templates,
2680
- };
2681
- for (let depth = slot.layouts.length; depth >= 0; depth -= 1) {
2682
- for (let index = slot.loading.length - 1; index >= 0; index -= 1) {
2683
- const boundary = slot.loading[index];
2684
- if (boundary.above !== depth) {
2685
- continue;
2686
- }
2687
- const Fallback = loadingComponent(boundary.module);
2688
- element = <Suspense fallback={<Fallback />}>{element}</Suspense>;
2689
- }
2690
- const errorBoundary = slot.errorBoundary;
2691
- if (errorBoundary != null && errorBoundary.above === depth) {
2692
- element = (
2693
- <RouteErrorBoundary
2694
- module={errorBoundary.module}
2695
- resetKey={`${resolved.pathname}:${slot.name}`}
2696
- >
2697
- {element}
2698
- </RouteErrorBoundary>
2699
- );
2700
- }
2701
- element = insideTemplates(element, templateContext, depth);
2702
- if (depth > 0) {
2703
- const Layout = layoutComponent(slot.layouts[depth - 1]);
2704
- element = (
2705
- <Layout {...slotsAt(slot.slots, depth)} params={slot.params}>
2706
- {element}
2707
- </Layout>
2708
- );
2709
- }
2710
- }
2711
- return element;
2712
- }
2713
-
2714
- /**
2715
- * The component a template module renders: `default`, or the named `Template`.
2716
- *
2717
- * The same props a layout receives, because it is a layout in every way but
2718
- * one: it wraps `children`, it may read the route's parameters, and the only
2719
- * difference is that `RouteView` gives the element a `key` so React builds it
2720
- * again on every navigation.
2721
- */
2722
- function templateComponent(module: TemplateModule): React.ComponentType<LayoutRenderProps> {
2723
- const component = module.default ?? module.Template;
2724
- if (component == null) {
2725
- throw new Error(
2726
- "@uniflowed/router: a template module must export a component as `default` or `Template`",
2727
- );
2728
- }
2729
- return renderable(component);
2730
- }
2731
-
2732
- /** The component a layout module renders: `default`, or the named `Layout`. */
2733
- function layoutComponent(module: LayoutModule): React.ComponentType<LayoutRenderProps> {
2734
- const component = module.default ?? module.Layout;
2735
- if (component == null) {
2736
- throw new Error(
2737
- "@uniflowed/router: a layout module must export a component as `default` or `Layout`",
2738
- );
2739
- }
2740
- return renderable(component);
2741
- }
2742
-
2743
- /**
2744
- * A route module's component, as the router is about to render it.
2745
- *
2746
- * # The one cast in this file, and why it is here rather than in six places
2747
- *
2748
- * A `RouteComponent` is a component about whose props nothing was claimed, and
2749
- * `RouteView` is about to pass it three. React allows that — a component
2750
- * receives the props its parent wrote and ignores the ones it did not declare
2751
- * — but Flow cannot be told it: a page's props are exact, so no props type but
2752
- * that page's own is assignable, and the router does not know which page it
2753
- * has. `React.ComponentType<any>` on the module types was this same
2754
- * unsoundness spread over six declarations, where it also stopped anyone from
2755
- * checking that `RouteView` passes the props a page is documented to receive.
2756
- * Here it is one line, and everything on either side of it is checked: what a
2757
- * module may export, and what a page is handed. Suppressed by name so that
2758
- * `check:lib` can gate CI without this file being the thing that stops it; the
2759
- * directive names the rule, and this is the argument for escaping it.
2760
- */
2761
- function renderable<TProps extends { ... }>(
2762
- component: RouteComponent,
2763
- ): React.ComponentType<TProps> {
2764
- // uf-lint-disable-next-line flow/unclear-type
2765
- return component as any;
2766
- }
2767
-
2768
- /**
2769
- * One URL from a route's metadata, made absolute if it can be.
2770
- *
2771
- * Open Graph, Twitter and `rel="canonical"` all want an absolute URL, and a
2772
- * route module cannot know the host it is served from — so `metadataBase` is
2773
- * how a site says it once, and this is where it is applied.
2774
- *
2775
- * Three things it deliberately does not do. It does not resolve against the
2776
- * *page's* URL: `Head` renders inside the route and does not know it, and a
2777
- * `metadataBase` is a site-wide fact rather than a per-page one. It does not
2778
- * invent a base: with none declared the value is emitted exactly as written,
2779
- * which is what every page that predates this field already gets. And it does
2780
- * not throw — a `metadataBase` that is not a URL is a mistake in one field,
2781
- * and turning it into a blank page would be a worse answer than an unresolved
2782
- * `og:image`.
2783
- */
2784
- function absoluteUrl(value: string, base: void | string): string {
2785
- if (base == null) return value;
2786
- try {
2787
- return new URL(value, base).href;
2788
- } catch {
2789
- return value;
2790
- }
2791
- }
2792
-
2793
- /**
2794
- * The `robots` directives, as one `content` string, or `null` for none.
2795
- *
2796
- * `null` rather than an empty string, so a page that declared nothing gets no
2797
- * tag at all: "index, follow" is what a document with no `robots` meta already
2798
- * means, and writing it out tells a crawler what it had already assumed.
2799
- *
2800
- * Each declared field contributes its directive and no field implies another.
2801
- * `index: true` therefore emits `index` rather than nothing — the value is
2802
- * there to overrule a section that said otherwise, and a directive that
2803
- * disappeared because it agreed with the default would be a page saying
2804
- * something and no evidence of it in the markup.
2805
- */
2806
- function robotsContent(robots: void | Robots): ?string {
2807
- if (robots == null) {
2808
- return null;
2809
- }
2810
- const directives: Array<string> = [];
2811
- if (robots.index != null) {
2812
- directives.push(robots.index ? "index" : "noindex");
2813
- }
2814
- if (robots.follow != null) {
2815
- directives.push(robots.follow ? "follow" : "nofollow");
2816
- }
2817
- if (robots.maxSnippet != null) {
2818
- directives.push(`max-snippet:${robots.maxSnippet}`);
2819
- }
2820
- if (robots.maxImagePreview != null) {
2821
- directives.push(`max-image-preview:${robots.maxImagePreview}`);
2822
- }
2823
- return directives.length === 0 ? null : directives.join(", ");
2824
- }
2825
-
2826
- /**
2827
- * One JSON-LD object as the text of a `<script>`.
2828
- *
2829
- * `<` is escaped so a string inside the data holding `</script>` cannot end
2830
- * the element early — the same escape `server.js` applies to the embedded
2831
- * loader data, and for the same reason: the text is the application's and the
2832
- * element it lands in is terminated by a character sequence rather than by a
2833
- * length. `dataScript` also escapes U+2028 and U+2029; those are about a
2834
- * string being parsed as JavaScript source, and this one never is.
2835
- */
2836
- function jsonLdText(entry: JsonLd): string {
2837
- return JSON.stringify(entry).replace(/</g, "\\u003c");
2838
- }
2839
-
2840
- /**
2841
- * One JSON-LD object, as the element that carries it.
2842
- *
2843
- * A function rather than an element written inline, because the suppression
2844
- * needs a line of its own; `docs/app/$layout.js` has the same shape for the
2845
- * same reason. `security/no-dangerously-set-inner-html` is about markup that
2846
- * came from somewhere and has to be sanitized before a browser parses it as
2847
- * HTML, and its escape hatch is a `@uniflowed/markdown` sanitizer — the right
2848
- * answer for markup and no answer at all for JSON. This string is
2849
- * `JSON.stringify`'s output with `<` escaped, so nothing in it can close the
2850
- * element, and it is never parsed as HTML. There is also no other spelling:
2851
- * React escapes a text child, so `{"@type":"Article"}` would reach the page as
2852
- * `&quot;@type&quot;`, which is not JSON-LD any more.
2853
- */
2854
- function jsonLdScript(entry: JsonLd): React.Node {
2855
- const text = jsonLdText(entry);
2856
- const html = { __html: text };
2857
- // uf-lint-disable-next-line security/no-dangerously-set-inner-html
2858
- return <script key={text} type="application/ld+json" dangerouslySetInnerHTML={html} />;
2859
- }
2860
-
2861
- component Head(metadata: Metadata) {
2862
- const { title, description, metadataBase, canonical, robots } = metadata;
2863
- const { alternates, pagination, jsonLd, openGraph, twitter } = metadata;
2864
- const href = canonical != null ? absoluteUrl(canonical, metadataBase) : null;
2865
- const crawler = robotsContent(robots);
2866
- // Read out of `alternates` once rather than through it at every use: the map
2867
- // is read inside a callback, and a refinement of `alternates.languages` does
2868
- // not survive being carried into one.
2869
- const languages = alternates?.languages;
2870
- // A page that said what it is called has said what its card is called. Every
2871
- // site that had to write both wrote the same string twice, and the second
2872
- // one is the one that goes stale — the docs site shipped thirty pages whose
2873
- // share cards carried an image and no title at all.
2874
- //
2875
- // `??`, not `||`: an empty string is a decision, and a page that deliberately
2876
- // has no card title should get none rather than the document's.
2877
- const cardTitle = openGraph?.title ?? title;
2878
- const cardDescription = openGraph?.description ?? description;
2879
- // `og:type` is one of the four properties Open Graph requires. A default is
2880
- // the difference between a document with a card and a document without one,
2881
- // and `website` is right for everything that is not an article or a video.
2882
- const cardType = openGraph?.type ?? "website";
2883
- // Only when the card was asked for. A page with no `twitter.card` gets no
2884
- // Twitter tags at all, which is what a site that never wanted one meant.
2885
- const twitterTitle = twitter != null ? (twitter.title ?? cardTitle) : null;
2886
- const twitterDescription = twitter != null ? (twitter.description ?? cardDescription) : null;
2887
- const twitterImageAlt = twitter != null ? (twitter.imageAlt ?? openGraph?.imageAlt) : null;
2888
- return (
2889
- <>
2890
- {title != null ? <title>{title}</title> : null}
2891
- {description != null ? <meta name="description" content={description} /> : null}
2892
- {crawler != null ? <meta name="robots" content={crawler} /> : null}
2893
- {href != null ? <link rel="canonical" href={href} /> : null}
2894
- {/* The set is reciprocal and includes this page, so a `hreflang` list is
2895
- usually the same list on every page of it — which is why it belongs
2896
- on the layout they share rather than on each of them.
2897
-
2898
- `hrefLang` is React's spelling and it reaches the markup unchanged,
2899
- which is worth knowing before grepping a document for `hreflang` and
2900
- concluding it is missing. HTML attribute names are case-insensitive,
2901
- so the parser every crawler runs reads it as the same attribute; the
2902
- lowercase spelling is the one React warns about. */}
2903
- {languages != null
2904
- ? Object.keys(languages).map((language) => (
2905
- <link
2906
- key={language}
2907
- rel="alternate"
2908
- hrefLang={language}
2909
- href={absoluteUrl(languages[language], metadataBase)}
2910
- />
2911
- ))
2912
- : null}
2913
- {pagination?.prev != null ? (
2914
- <link rel="prev" href={absoluteUrl(pagination.prev, metadataBase)} />
2915
- ) : null}
2916
- {pagination?.next != null ? (
2917
- <link rel="next" href={absoluteUrl(pagination.next, metadataBase)} />
2918
- ) : null}
2919
- {/* `og:url` *is* the canonical URL of the page, in Open Graph's own
2920
- words, so one declaration answers both rather than asking a project
2921
- to write the same URL twice and keep them in step. */}
2922
- {href != null ? <meta property="og:url" content={href} /> : null}
2923
- {cardTitle != null ? <meta property="og:title" content={cardTitle} /> : null}
2924
- {cardDescription != null ? (
2925
- <meta property="og:description" content={cardDescription} />
2926
- ) : null}
2927
- {/* Only alongside something else. A document with `og:type` and nothing
2928
- more is not a card; it is one meta tag saying the page is a page. */}
2929
- {cardTitle != null || cardDescription != null || openGraph?.images != null ? (
2930
- <meta property="og:type" content={cardType} />
2931
- ) : null}
2932
- {openGraph?.siteName != null ? (
2933
- <meta property="og:site_name" content={openGraph.siteName} />
2934
- ) : null}
2935
- {openGraph?.images != null
2936
- ? openGraph.images.map((image) => (
2937
- <meta key={image} property="og:image" content={absoluteUrl(image, metadataBase)} />
2938
- ))
2939
- : null}
2940
- {openGraph?.imageAlt != null && openGraph?.images != null ? (
2941
- <meta property="og:image:alt" content={openGraph.imageAlt} />
2942
- ) : null}
2943
- {/* `name`, not `property`: Open Graph is RDFa and Twitter's cards are
2944
- not, and a `property="twitter:card"` is ignored by the crawler that
2945
- reads it. */}
2946
- {twitter?.card != null ? <meta name="twitter:card" content={twitter.card} /> : null}
2947
- {twitter?.site != null ? <meta name="twitter:site" content={twitter.site} /> : null}
2948
- {twitter?.creator != null ? <meta name="twitter:creator" content={twitter.creator} /> : null}
2949
- {/* X reads the `og:` tags when these are absent, so these are not
2950
- required — and every validator asks for them anyway, which is a good
2951
- enough reason when the value is one the page has already given. They
2952
- fall back through the card's title to the document's. */}
2953
- {twitterTitle != null ? <meta name="twitter:title" content={twitterTitle} /> : null}
2954
- {twitterDescription != null ? (
2955
- <meta name="twitter:description" content={twitterDescription} />
2956
- ) : null}
2957
- {twitter?.images != null
2958
- ? twitter.images.map((image) => (
2959
- <meta key={image} name="twitter:image" content={absoluteUrl(image, metadataBase)} />
2960
- ))
2961
- : null}
2962
- {twitterImageAlt != null && twitter?.images != null ? (
2963
- <meta name="twitter:image:alt" content={twitterImageAlt} />
2964
- ) : null}
2965
- {/* Last, and not hoisted into `<head>` with the rest: React hoists a
2966
- `<title>`, a `<meta>` and a `<link>`, and not a script whose body it
2967
- would have to carry. JSON-LD is read from anywhere in the document,
2968
- so these render where the route does. */}
2969
- {jsonLd != null ? jsonLd.map(jsonLdScript) : null}
2970
- </>
2971
- );
2972
- }
2973
-
2974
1420
  /**
2975
1421
  * Head elements a component contributes while it is rendering.
2976
1422
  *
@@ -3006,8 +1452,8 @@ component Head(metadata: Metadata) {
3006
1452
  * relative URLs written here are resolved.
3007
1453
  */
3008
1454
  export hook useSeo(seo: Metadata): React.Node {
3009
- const { resolved } = useRouterState();
3010
- const base = seo.metadataBase ?? resolved.metadata.metadataBase;
1455
+ const { route } = useRouterState();
1456
+ const base = seo.metadataBase ?? route.metadata.metadataBase;
3011
1457
  return <Head metadata={base == null ? seo : { ...seo, metadataBase: base }} />;
3012
1458
  }
3013
1459
 
@@ -3142,10 +1588,10 @@ function isExternal(to: string): boolean {
3142
1588
  */
3143
1589
  export function routerView(root: string): React.ComponentType<AppProps> {
3144
1590
  void root;
3145
- component App(url: string, initial: ResolvedRoute) {
1591
+ component App(url: string, initial?: ResolvedRoute, flight?: Promise<FlightRoot>) {
3146
1592
  return (
3147
1593
  <RenderProvider>
3148
- <RouterProvider url={url} initial={initial}>
1594
+ <RouterProvider url={url} initial={initial} flight={flight}>
3149
1595
  <RouteView />
3150
1596
  </RouterProvider>
3151
1597
  </RenderProvider>