@uniflowed/router 0.0.0-alpha.9 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/action.js +324 -0
  2. package/client.js +261 -7
  3. package/handler.js +113 -99
  4. package/http-client.js +104 -0
  5. package/index.js +49 -6
  6. package/instrumentation.js +92 -0
  7. package/internal/action-endpoint.js +438 -0
  8. package/internal/action-wire.js +608 -0
  9. package/internal/base-path.js +175 -0
  10. package/internal/boundaries.js +481 -0
  11. package/internal/boundary-data.js +88 -0
  12. package/internal/compose.js +490 -0
  13. package/internal/deployment.js +160 -0
  14. package/internal/devtools.js +131 -0
  15. package/internal/diagnostics.js +169 -0
  16. package/internal/error-view.js +193 -0
  17. package/internal/flight-browser.js +242 -0
  18. package/internal/flight-chunks.js +205 -0
  19. package/internal/flight-rows.js +135 -0
  20. package/internal/flight-ssr.js +91 -0
  21. package/internal/flight.js +192 -0
  22. package/internal/head.js +219 -0
  23. package/internal/hydration.js +1085 -0
  24. package/internal/inspector.js +626 -0
  25. package/internal/native-links.js +67 -0
  26. package/internal/native-tree.js +89 -0
  27. package/internal/navigation-cache.js +181 -0
  28. package/internal/payload-rows.js +270 -0
  29. package/internal/payload.js +685 -0
  30. package/internal/prepare-document.js +54 -0
  31. package/internal/react-version.js +77 -0
  32. package/internal/resolve.js +1617 -0
  33. package/internal/resolved-summary.js +199 -0
  34. package/internal/routing.js +478 -0
  35. package/internal/runtime.js +1593 -1341
  36. package/internal/server-instrumentation.js +12 -0
  37. package/internal/server-route.js +58 -0
  38. package/internal/shell.js +125 -0
  39. package/internal/stream.js +754 -21
  40. package/middleware.js +161 -22
  41. package/native-navigation.js +217 -0
  42. package/native.js +416 -0
  43. package/package.json +48 -7
  44. package/routing.js +51 -0
  45. package/rsc-client.js +120 -0
  46. package/rsc-ssr.js +637 -0
  47. package/rsc.js +402 -0
  48. package/server-components.js +159 -0
  49. package/server.js +254 -106
@@ -0,0 +1,1617 @@
1
+ // @flow
2
+ //
3
+ // Internal to `@uniflowed/router`: what a URL resolves to, with no rendering in it.
4
+ //
5
+ // The route table is data and a resolved route is data — the modules a match
6
+ // loaded, the loader's answer, the merged metadata and the boundaries around the
7
+ // page — and nothing in this file puts either on a screen. That is what makes it
8
+ // the half of the router a React Server Component graph can import: that graph
9
+ // resolves `react` to the build with no `useState`, no `createContext` and no
10
+ // `Component` in it (ubugeeei-prod/uf#519), so the module that resolves a route
11
+ // may not reach any of them, even at import time.
12
+ //
13
+ // It was the top half of `./runtime.js`, which a comment on #519 measured as
14
+ // "everything above the error boundary class". Composition is `./compose.js`,
15
+ // the head is `./head.js`, the error views are `./error-view.js`, and the
16
+ // browser's binding — the provider, the hooks, navigation and `Link` — is
17
+ // what is left in `./runtime.js`.
18
+ //
19
+ // The two things here that are components are the framework's own not-found
20
+ // page, which is markup, and the placeholder an error route carries as its
21
+ // page, which `./error-view.js` owns because it has to reach the router to
22
+ // offer a retry.
23
+
24
+ import * as React from "react";
25
+
26
+ import { ResolvedErrorPage } from "./error-view.js";
27
+ import {
28
+ ForbiddenError,
29
+ NotFoundError,
30
+ RedirectError,
31
+ UnauthorizedError,
32
+ matchIn,
33
+ matchRoute,
34
+ nearestBoundary,
35
+ parseSearch,
36
+ routeErrorStatus,
37
+ splitUrl,
38
+ } from "./routing.js";
39
+ import type {
40
+ ErrorBoundary as RoutingErrorBoundary,
41
+ LoadingRecord as RoutingLoadingRecord,
42
+ NotFoundBoundary as RoutingNotFoundBoundary,
43
+ RouteError,
44
+ RouteMatch as RoutingRouteMatch,
45
+ RouteParams,
46
+ RouteRecord as RoutingRouteRecord,
47
+ RouteTable as RoutingRouteTable,
48
+ SearchParams,
49
+ SlotRecord as RoutingSlotRecord,
50
+ SlotRouteRecord as RoutingSlotRouteRecord,
51
+ TemplateRecord as RoutingTemplateRecord,
52
+ } from "./routing.js";
53
+
54
+ /**
55
+ * A component found in a route module.
56
+ *
57
+ * `React.ComponentType<empty>` is "some React component", and it is a claim
58
+ * rather than a shrug. `ComponentType` is contravariant in its props — Flow's
59
+ * library definition writes it `component(...P)` with `in P` — so `empty` is
60
+ * the *top* of the component types: every component is one, and nothing may be
61
+ * passed to one until a caller has said which props it is passing. That is
62
+ * exactly what is known here. The router finds these by dynamic import, and
63
+ * nobody has told it what a page's props are.
64
+ *
65
+ * It cannot be `React.ComponentType<PageRenderProps>`, the props the router
66
+ * actually passes, because Flow's `component` syntax gives a component *exact*
67
+ * props and a page is free to want none of them. This repository's own pages
68
+ * and layouts are `component NotFound()` and
69
+ * `component Layout(children: React.Node)`, and against the props the router
70
+ * hands them that reads:
71
+ *
72
+ * error[incompatible-type]: property `data`, property `params`, and
73
+ * property `searchParams` are extra in `PageRenderProps` but missing in
74
+ * `props of component NotFound`. Exact objects do not accept extra props.
75
+ *
76
+ * React passing a component a prop it did not declare is allowed and always
77
+ * has been. `renderable` is the one line that says so.
78
+ */
79
+ export type RouteComponent = React.ComponentType<empty>;
80
+
81
+ /**
82
+ * The props `RouteView` gives the page it renders.
83
+ *
84
+ * The same three as the public `PageProps` in `../index.js`, at the arguments
85
+ * the runtime instantiates it with: the runtime knows the parameters as
86
+ * strings and the loader's data as `mixed`, and a page narrows both by
87
+ * annotating its own props.
88
+ */
89
+ export type PageRenderProps = {|
90
+ readonly params: RouteParams,
91
+ readonly searchParams: SearchParams,
92
+ readonly data: mixed,
93
+ |};
94
+
95
+ /**
96
+ * The props `RouteView` gives each layout, outermost first.
97
+ *
98
+ * Inexact, and that is the parallel routes reaching the type: a layout on a
99
+ * segment that declares `@team` is handed a `team` prop beside `children`, and
100
+ * the names are the project's rather than this file's. Every extra prop is a
101
+ * `React.Node` — a rendered slot, or `null` when the URL addressed neither the
102
+ * slot's routes nor a `$default.js`.
103
+ *
104
+ * The exactness is not lost so much as moved: what a layout may be *given* is
105
+ * open, and what it *declares* is still its own exact props type, which is
106
+ * where a typo in a slot name shows up.
107
+ */
108
+ export type LayoutRenderProps = {
109
+ readonly params: RouteParams,
110
+ readonly children: React.Node,
111
+ ...
112
+ };
113
+
114
+ /** What a page module may export. The component is `default` or `Page`. */
115
+ export type PageModule = {
116
+ readonly default?: RouteComponent,
117
+ readonly Page?: RouteComponent,
118
+ readonly loader?: (args: LoaderArgs) => mixed | Promise<mixed>,
119
+ readonly metadata?: Metadata,
120
+ readonly generateMetadata?: (args: MetadataArgs) => Metadata | Promise<Metadata>,
121
+ readonly generateStaticParams?: () =>
122
+ | $ReadOnlyArray<RouteParams>
123
+ | Promise<$ReadOnlyArray<RouteParams>>,
124
+ readonly frontmatter?: { readonly title?: string, readonly description?: string, ... },
125
+ /**
126
+ * What a stylesheet calls the transition this route arrives under.
127
+ *
128
+ * Not a switch. Every client navigation opts into a view transition where
129
+ * the browser has one, and this is how one arrival is told from another —
130
+ * the name reaches CSS as an attribute on the document element for as long
131
+ * as the transition runs:
132
+ *
133
+ * html[data-uf-view-transition="manual"]::view-transition-old(root) { … }
134
+ *
135
+ * A layout may declare one too, and then it covers every route under it; the
136
+ * page's own wins. That is the rule `metadata` already follows, and there is
137
+ * no reason for a second one — "the nearest declaration" is how everything
138
+ * else in a route module is resolved.
139
+ */
140
+ readonly viewTransition?: string,
141
+ ...
142
+ };
143
+
144
+ /** What a layout module may export. The component is `default` or `Layout`. */
145
+ export type LayoutModule = {
146
+ readonly default?: RouteComponent,
147
+ readonly Layout?: RouteComponent,
148
+ readonly metadata?: Metadata,
149
+ /** A transition name for every route under this layout; see [`PageModule`]. */
150
+ readonly viewTransition?: string,
151
+ ...
152
+ };
153
+
154
+ /**
155
+ * What a template module may export. The component is `default` or `Template`.
156
+ *
157
+ * A layout's shape without its `metadata`, and the omission is the type saying
158
+ * what a template is for. A layout persists across navigation, so a title it
159
+ * declares is a claim about a section of the site; a template is thrown away
160
+ * and built again on every navigation, so a title on one would be a claim
161
+ * about nothing. Titles come from the page and the layouts above it.
162
+ */
163
+ export type TemplateModule = {
164
+ readonly default?: RouteComponent,
165
+ readonly Template?: RouteComponent,
166
+ ...
167
+ };
168
+
169
+ /**
170
+ * What an error module may export. The component is `default` or `Error`.
171
+ *
172
+ * `Error` shadows the global inside the file that writes it, which is the
173
+ * cost of naming the export after what it is; a file that needs the
174
+ * constructor still has `globalThis.Error`. The alternative was a name the
175
+ * convention would have to explain — `ErrorPage`, `Boundary` — for a file
176
+ * whose whole job is already in its name.
177
+ */
178
+ export type ErrorModule = {
179
+ readonly default?: RouteComponent,
180
+ readonly Error?: RouteComponent,
181
+ readonly metadata?: Metadata,
182
+ ...
183
+ };
184
+
185
+ /**
186
+ * What a loading module may export. The component is `default` or `Loading`.
187
+ *
188
+ * No `metadata`, and that is the type saying something true rather than an
189
+ * omission. A fallback renders while the route is still resolving, and the
190
+ * route's metadata was decided before the first byte — a title on a file that
191
+ * renders after the head has gone could never be used. `packages/web/head.js`
192
+ * documents the same constraint from the other side.
193
+ */
194
+ export type LoadingModule = {
195
+ readonly default?: RouteComponent,
196
+ readonly Loading?: RouteComponent,
197
+ ...
198
+ };
199
+
200
+ /**
201
+ * How a Twitter card is laid out, which is the whole of what `card` may be.
202
+ *
203
+ * A union rather than a string: every one of the four is spelled exactly this
204
+ * way and a fifth value is silently ignored by the crawler, so a typo in it
205
+ * costs a card and produces no error anywhere.
206
+ */
207
+ export type TwitterCard = "summary" | "summary_large_image" | "app" | "player";
208
+
209
+ /**
210
+ * What a crawler may do with a page.
211
+ *
212
+ * Four fields rather than the whole `robots` vocabulary, and the omissions are
213
+ * the argument. `index` and `follow` are the two directives a page has an
214
+ * opinion about; `maxSnippet` and `maxImagePreview` are the two that change
215
+ * what a result *looks* like and have no other spelling. `nosnippet` is not
216
+ * here because `maxSnippet: 0` is the same instruction, and a type with two
217
+ * ways to say one thing is a type somebody will eventually ask which of them
218
+ * wins.
219
+ *
220
+ * Every field is optional and every one is only emitted when it is declared,
221
+ * because "index, follow" is what a document with no `robots` meta already
222
+ * says — the tag exists to say something else.
223
+ */
224
+ export type Robots = {
225
+ readonly index?: boolean,
226
+ readonly follow?: boolean,
227
+ /** The longest snippet a result may quote; `0` is none, `-1` is no limit. */
228
+ readonly maxSnippet?: number,
229
+ readonly maxImagePreview?: "none" | "standard" | "large",
230
+ };
231
+
232
+ /**
233
+ * One JSON-LD object, as a page hands it over.
234
+ *
235
+ * `mixed` values rather than a schema.org type, because there is no useful
236
+ * middle: the vocabulary is hundreds of types deep, it grows without asking
237
+ * anyone, and a partial transcription of it would reject correct documents far
238
+ * more often than it caught wrong ones. What this type does claim is the part
239
+ * uf is answerable for — that the thing is an object, and therefore that it
240
+ * serialises into one `<script>`.
241
+ */
242
+ export type JsonLd = { readonly [string]: mixed };
243
+
244
+ /** Document metadata a page or layout declares. */
245
+ export type Metadata = {
246
+ readonly title?: string,
247
+ readonly description?: string,
248
+ /**
249
+ * The absolute URL every other URL here is resolved against.
250
+ *
251
+ * Open Graph and Twitter both require absolute image URLs, and a route
252
+ * module has no way to know the host it will be served from — so without
253
+ * this, `openGraph.images: ["/og.png"]` ships exactly as written and is not
254
+ * a valid `og:image`. Declare it once on the root layout and every
255
+ * descendant inherits it through the same merge as everything else.
256
+ *
257
+ * Resolution is the URL standard's, so `"/og.png"` is resolved against the
258
+ * *origin* and `"og.png"` against the base's own path — not against the
259
+ * page's URL, which `Head` does not know.
260
+ */
261
+ readonly metadataBase?: string,
262
+ /**
263
+ * This page's canonical URL, for `<link rel="canonical">` and `og:url`.
264
+ *
265
+ * Relative to `metadataBase` when it is not absolute. A page
266
+ * reachable at more than one path — a query a filter added, a duplicate
267
+ * under a second section — is one page, and this is how it says so.
268
+ */
269
+ readonly canonical?: string,
270
+ /**
271
+ * What a crawler may do with this page. See [`Robots`].
272
+ *
273
+ * The one field here that is usually declared on a *layout*: a staging
274
+ * section, a preview tree or an account area is `index: false` for
275
+ * everything under it, and saying so once is the only version of that which
276
+ * stays true when a page is added.
277
+ */
278
+ readonly robots?: Robots,
279
+ /**
280
+ * The other addresses this same page is published at.
281
+ *
282
+ * `languages` maps a BCP 47 tag to that translation's URL and becomes one
283
+ * `<link rel="alternate" hreflang>` each. The set has to be reciprocal —
284
+ * every page in it lists every other one *and itself*, which is what makes a
285
+ * search engine read them as translations rather than as duplicates — so it
286
+ * is usually the same map on every page of the set, declared on the layout
287
+ * they share. `"x-default"` is a tag like any other here, and names what a
288
+ * reader whose language is not in the set should be given.
289
+ *
290
+ * Nested under `alternates` rather than sitting at the top level as
291
+ * `languages`, because `alternate` is the link relation and a language is
292
+ * only one kind of alternate; the outer name is a fact about the wire rather
293
+ * than a shape invented here.
294
+ */
295
+ readonly alternates?: {
296
+ readonly languages?: { readonly [string]: string },
297
+ },
298
+ /**
299
+ * The pages either side of this one in a sequence.
300
+ *
301
+ * `<link rel="prev">` and `<link rel="next">`, resolved against
302
+ * `metadataBase` like every other URL here. A page four of a list, and a
303
+ * chapter in the middle of a manual, are the same statement: this document
304
+ * is one of a series and here is where the series continues.
305
+ *
306
+ * `canonical` still belongs to the page itself. Pointing every page of a
307
+ * paginated list at page one is the mistake this pair exists to make
308
+ * unnecessary — it tells a search engine that pages two onwards are
309
+ * duplicates of page one, and everything only reachable from them stops
310
+ * being reachable at all.
311
+ */
312
+ readonly pagination?: {
313
+ readonly prev?: string,
314
+ readonly next?: string,
315
+ },
316
+ /**
317
+ * Structured data, as JSON-LD.
318
+ *
319
+ * One `<script type="application/ld+json">` per entry. Unlike everything
320
+ * else here it *accumulates* down the tree rather than being replaced by the
321
+ * nearest declaration: an `Organization` on the root layout and an `Article`
322
+ * on the page are two statements about one page, not two answers to one
323
+ * question, and replacing would mean a page that describes itself silently
324
+ * deletes the site's description of itself.
325
+ *
326
+ * The scripts are rendered with the rest of the route rather than hoisted
327
+ * into `<head>`, because React hoists `<title>`, `<meta>` and `<link>` and
328
+ * not a script it has to keep the body of. JSON-LD is read from anywhere in
329
+ * the document, so this costs nothing; it is worth knowing when reading the
330
+ * markup.
331
+ */
332
+ readonly jsonLd?: $ReadOnlyArray<JsonLd>,
333
+ readonly openGraph?: {
334
+ /**
335
+ * The title a share card shows.
336
+ *
337
+ * Falls back to `title`, because a page that has said what it is called
338
+ * has said what its card is called — and a site made to write it twice
339
+ * writes it twice once and then lets them drift.
340
+ */
341
+ readonly title?: string,
342
+ /** The description a share card shows. Falls back to `description`. */
343
+ readonly description?: string,
344
+ /**
345
+ * The Open Graph object type. `website` unless a page says otherwise.
346
+ *
347
+ * Defaulted rather than omitted because `og:type` is one of the four
348
+ * properties Open Graph requires, and a document without it is not an
349
+ * Open Graph document at all — so leaving it to every project to remember
350
+ * is leaving most of them without one.
351
+ */
352
+ readonly type?: string,
353
+ /**
354
+ * The name of the site the page belongs to, which a card prints above the
355
+ * title. Declared once on the root layout.
356
+ */
357
+ readonly siteName?: string,
358
+ readonly images?: $ReadOnlyArray<string>,
359
+ /**
360
+ * What the card's image shows, for a reader who cannot see it.
361
+ *
362
+ * One description rather than one per image: a card shows one image, and
363
+ * the array exists so a site can offer a crawler a choice of sizes rather
364
+ * than so it can show several.
365
+ */
366
+ readonly imageAlt?: string,
367
+ },
368
+ readonly twitter?: {
369
+ readonly card?: TwitterCard,
370
+ readonly site?: string,
371
+ readonly creator?: string,
372
+ /** Falls back to `openGraph.title`, and then to `title`. */
373
+ readonly title?: string,
374
+ /** Falls back to `openGraph.description`, and then to `description`. */
375
+ readonly description?: string,
376
+ readonly images?: $ReadOnlyArray<string>,
377
+ /** Falls back to `openGraph.imageAlt`. */
378
+ readonly imageAlt?: string,
379
+ },
380
+ };
381
+
382
+ /** Arguments a loader receives. */
383
+ export type LoaderArgs = {|
384
+ readonly params: RouteParams,
385
+ readonly searchParams: SearchParams,
386
+ readonly pathname: string,
387
+ |};
388
+
389
+ /** Arguments `generateMetadata` receives. */
390
+ export type MetadataArgs = {|
391
+ readonly params: RouteParams,
392
+ readonly searchParams: SearchParams,
393
+ readonly data: mixed,
394
+ |};
395
+
396
+ export type RouteRecord = RoutingRouteRecord<
397
+ PageModule,
398
+ LayoutModule,
399
+ TemplateModule,
400
+ LoadingModule,
401
+ ErrorModule,
402
+ >;
403
+
404
+ export type SlotRecord = RoutingSlotRecord<
405
+ PageModule,
406
+ LayoutModule,
407
+ TemplateModule,
408
+ LoadingModule,
409
+ ErrorModule,
410
+ >;
411
+
412
+ export type SlotRouteRecord = RoutingSlotRouteRecord<
413
+ PageModule,
414
+ LayoutModule,
415
+ TemplateModule,
416
+ LoadingModule,
417
+ ErrorModule,
418
+ >;
419
+
420
+ export type TemplateRecord = RoutingTemplateRecord<TemplateModule>;
421
+
422
+ export type LoadingRecord = RoutingLoadingRecord<LoadingModule>;
423
+
424
+ export type NotFoundBoundary = RoutingNotFoundBoundary<PageModule, LayoutModule>;
425
+
426
+ export type ErrorBoundary = RoutingErrorBoundary<ErrorModule, LayoutModule>;
427
+
428
+ export type RouteTable = RoutingRouteTable<
429
+ PageModule,
430
+ LayoutModule,
431
+ TemplateModule,
432
+ LoadingModule,
433
+ ErrorModule,
434
+ >;
435
+
436
+ export type RouteMatch = RoutingRouteMatch<RouteRecord>;
437
+
438
+ export type ResolvedTemplate = {|
439
+ readonly above: number,
440
+ readonly module: TemplateModule,
441
+ |};
442
+
443
+ type ResolvedSlotErrorBoundary = {|
444
+ readonly above: number,
445
+ readonly module: ?ErrorModule,
446
+ |};
447
+
448
+ type SlotErrorBoundaryLoader = {|
449
+ readonly above: number,
450
+ readonly module: () => Promise<ErrorModule>,
451
+ |};
452
+
453
+ /**
454
+ * A match whose modules are loaded and whose loader has run or is running — or,
455
+ * when `error` is set, the error page that stands in for it.
456
+ */
457
+ export type ResolvedRoute = {|
458
+ readonly pathname: string,
459
+ readonly search: string,
460
+ readonly path: string,
461
+ readonly params: RouteParams,
462
+ readonly searchParams: SearchParams,
463
+ readonly page: PageModule,
464
+ readonly layouts: $ReadOnlyArray<LayoutModule>,
465
+ /** What the loader returned, once it has. `undefined` while `deferred` is set. */
466
+ readonly data: mixed,
467
+ /**
468
+ * The loader still running, when the router handed the page its promise
469
+ * rather than its value. `null` on every other path, which is most of them.
470
+ *
471
+ * Two fields rather than a `data` that is sometimes a promise, because a
472
+ * loader is free to return something with a `then` on it and no duck test
473
+ * could tell that apart from a deferral. This one is the router's own answer
474
+ * to a question the router asked, so it says so.
475
+ *
476
+ * Set only by a streaming render of a route that declares a
477
+ * `$loading.js` and generates no metadata from its data — the two
478
+ * conditions under which deferring buys anything and costs nothing that was
479
+ * not already spent. [`resolveRoute`] is where that is decided and argued.
480
+ */
481
+ readonly deferred: ?Promise<mixed>,
482
+ readonly metadata: Metadata,
483
+ /**
484
+ * What a stylesheet calls the transition this route arrives under, or `null`
485
+ * when neither the page nor a layout above it named one.
486
+ *
487
+ * Resolved with the route rather than looked up at the moment of the
488
+ * navigation, because by then the answer is a property of the destination's
489
+ * modules and those are exactly what has just been loaded. A server render
490
+ * carries it and never reads it; see "View transitions".
491
+ */
492
+ readonly viewTransition: ?string,
493
+ readonly status: 200 | 401 | 403 | 404 | 500,
494
+ /**
495
+ * Set when this resolution *is* the error page: the loader threw, or the
496
+ * server render did and the renderer resolved again. `null` on the ordinary
497
+ * path.
498
+ */
499
+ readonly error: ?RouteError,
500
+ /**
501
+ * The boundary that would catch a throw while rendering this route.
502
+ *
503
+ * Always present, because every route has an answer for a throw: `module`
504
+ * is `null` when the project declares no `$error.js` above the path, and
505
+ * the framework's own error page renders instead. `above` is how many of
506
+ * `layouts` are outside the boundary — the ones that stay mounted, which is
507
+ * what "the rest of the document is still interactive" means.
508
+ */
509
+ readonly errorBoundary: {|
510
+ readonly module: ?ErrorModule,
511
+ readonly above: number,
512
+ |},
513
+ /**
514
+ * The loading boundaries around this route, root first, already imported.
515
+ *
516
+ * Imported rather than lazy: React decides to render a fallback
517
+ * synchronously, during the render that suspended, so a module that is still
518
+ * being fetched is a module that is not there at the only moment it is
519
+ * wanted. Empty for a route with no `$loading.js` above it, which is the
520
+ * ordinary case and renders exactly the tree it did before.
521
+ */
522
+ readonly loading: $ReadOnlyArray<{| readonly above: number, readonly module: LoadingModule |}>,
523
+ /**
524
+ * The templates around this route, root first, already imported.
525
+ *
526
+ * Empty for a route with no `$template.js` above it, which is the
527
+ * ordinary case and renders exactly the tree it did before templates
528
+ * existed. Empty too on a resolution that *is* a boundary — a not-found or
529
+ * an error page — for the reason its `loading` is: those are matched rather
530
+ * than walked to, and templates are accumulated on the walk down to a route
531
+ * the URL never reached.
532
+ */
533
+ readonly templates: $ReadOnlyArray<ResolvedTemplate>,
534
+ /**
535
+ * The slots this route renders, outermost first, already imported.
536
+ *
537
+ * Empty for a route with no slot above it, and empty on a resolution that
538
+ * *is* a boundary — a not-found or an error page — for the reason its
539
+ * `templates` is: a boundary is matched rather than walked to, and a slot
540
+ * belongs to the segment the walk went through.
541
+ */
542
+ readonly slots: $ReadOnlyArray<ResolvedSlot>,
543
+ /**
544
+ * Set when a client navigation was intercepted: this is then the route the
545
+ * navigation came from, still on screen, with the intercepting route in one
546
+ * of its slots.
547
+ *
548
+ * `null` or absent on every other resolution — which includes every one a
549
+ * server makes, because a document request is never intercepted. See
550
+ * [`resolveInterception`].
551
+ */
552
+ readonly interception?: ?Interception,
553
+ |};
554
+
555
+ /**
556
+ * What an intercepted navigation went to, and what it went there from.
557
+ *
558
+ * A route is resolved *for* a URL, and an intercepted navigation is the one
559
+ * case where the URL in the address bar is not the URL the page on screen was
560
+ * resolved for. The `ResolvedRoute` that carries this is the page the
561
+ * navigation started on — its `pathname`, `params` and `data` are that page's,
562
+ * because that page is what `children` still renders and what `useRoute()`
563
+ * still describes — and this is the other half: where the address bar went.
564
+ *
565
+ * `base` is the same page as it was before anything intercepted it. Kept rather
566
+ * than re-derived, because a second interception from inside the first — the
567
+ * next photo, from a photo already open in the modal — has to start from the
568
+ * page underneath rather than from a page that already has a modal in it, and
569
+ * going back from the second to the first has to find that page where it left
570
+ * it.
571
+ */
572
+ export type Interception = {|
573
+ /** The URL the navigation went to: the one in the address bar. */
574
+ readonly pathname: string,
575
+ readonly search: string,
576
+ /** The route the navigation came from, as it was before it was intercepted. */
577
+ readonly base: ResolvedRoute,
578
+ |};
579
+
580
+ /**
581
+ * One slot, matched against the URL and imported.
582
+ *
583
+ * `page` is `null` for a slot the URL addressed and that declares no
584
+ * `$default.js`, and the layout receives `null` rather than nothing at all:
585
+ * a layout that declares a slot always gets that prop, so a project can write
586
+ * `{team ?? <Empty />}` and mean it.
587
+ *
588
+ * `params` are the slot's own. A slot matches the same URL by its own patterns,
589
+ * so `@team/[member]` captures `member` while the page beside it captures
590
+ * nothing — which is the point of matching twice rather than sharing one match.
591
+ */
592
+ export type ResolvedSlot = {|
593
+ readonly name: string,
594
+ readonly above: number,
595
+ readonly page: ?PageModule,
596
+ readonly params: RouteParams,
597
+ readonly layouts: $ReadOnlyArray<LayoutModule>,
598
+ readonly loading: $ReadOnlyArray<{| readonly above: number, readonly module: LoadingModule |}>,
599
+ readonly templates: $ReadOnlyArray<ResolvedTemplate>,
600
+ readonly errorBoundary: ?ResolvedSlotErrorBoundary,
601
+ readonly slots: $ReadOnlyArray<ResolvedSlot>,
602
+ /**
603
+ * The table record this slot was resolved from.
604
+ *
605
+ * Carried so a client navigation can ask the slots *on screen* whether they
606
+ * intercept where it is going. Interception is a question about the page a
607
+ * navigation starts on, and this is that page's own answer rather than a
608
+ * second match of the table that could arrive at different slots. Absent on
609
+ * a slot written by hand, which then intercepts nothing.
610
+ */
611
+ readonly record?: SlotRecord,
612
+ /**
613
+ * Set when this slot renders an intercepting route, or sits inside one: the
614
+ * URL that was intercepted.
615
+ *
616
+ * The slot's keys and its page's `searchParams` come from here rather than
617
+ * from the route on screen, because the route on screen is the page the
618
+ * navigation started on. Keying the modal's templates on that page's
619
+ * pathname would leave the second photo mounted as the first one.
620
+ */
621
+ readonly intercepted?: ?InterceptedUrl,
622
+ |};
623
+
624
+ /** The URL an intercepting route was matched against. */
625
+ type InterceptedUrl = {|
626
+ readonly pathname: string,
627
+ readonly searchParams: SearchParams,
628
+ |};
629
+
630
+ // ---------------------------------------------------------------------------
631
+ // Loading
632
+ // ---------------------------------------------------------------------------
633
+
634
+ const moduleCache: Map<() => Promise<mixed>, Promise<mixed>> = new Map();
635
+
636
+ export function loadOnce<T>(load: () => Promise<T>): Promise<T> {
637
+ let pending = moduleCache.get(load);
638
+ if (pending == null) {
639
+ pending = load();
640
+ moduleCache.set(load, pending);
641
+ }
642
+ // $FlowFixMe[incompatible-return] the cache is keyed by the loader, whose result type it stores.
643
+ return pending;
644
+ }
645
+
646
+ /**
647
+ * Load a match's modules and run its loader.
648
+ *
649
+ * `data` is what the loader returned; on the client after hydration it is the
650
+ * value the server embedded, so the loader does not run twice for the first
651
+ * page.
652
+ *
653
+ * # This resolves or redirects; it does not reject
654
+ *
655
+ * Everything a route can go wrong with is a route to render: no match and
656
+ * `notFound()` are the not-found boundary, a loader that threw and
657
+ * `forbidden()`/`unauthorized()` are the error boundary. Only `redirect()`
658
+ * comes back out, because a redirect is a response rather than a page and the
659
+ * caller is what has one to send.
660
+ *
661
+ * That guarantee is the point rather than a convenience. `hydrate` awaits this
662
+ * before `hydrateRoot`, so a rejection there is not an error page — it is no
663
+ * `hydrateRoot` call at all, and the document the server sent stays on screen
664
+ * with nothing attached to it.
665
+ *
666
+ * # `onMatch`
667
+ *
668
+ * Called with the route pattern the moment the URL matches one, before any
669
+ * module is imported and before the loader runs. It exists because the server
670
+ * has something to do with that fact and does it too late otherwise: the route
671
+ * a request turned out to be is what its log line carries, and a loader is
672
+ * inside this call, so a server that recorded the route after this resolved
673
+ * would have every line a loader wrote saying it belonged to no route.
674
+ *
675
+ * A callback rather than a return value because both callers already have one
676
+ * — the pattern is on the `ResolvedRoute` this hands back — and only one of
677
+ * them needs it *early*. The browser passes nothing and pays nothing.
678
+ */
679
+ export async function resolveMatch(
680
+ table: RouteTable,
681
+ url: string,
682
+ options?: ResolveOptions,
683
+ ): Promise<ResolvedRoute> {
684
+ try {
685
+ return await resolveRoute(table, url, options);
686
+ } catch (error) {
687
+ if (error instanceof RedirectError) {
688
+ throw error;
689
+ }
690
+ return resolveFailure(table, url, error);
691
+ }
692
+ }
693
+
694
+ /** What a caller may tell [`resolveMatch`] about the resolution it wants. */
695
+ export type ResolveOptions = {|
696
+ /** The loader's answer, already in hand — the value the server embedded. */
697
+ readonly data?: mixed,
698
+ /** Do not run the loader at all; `data` is the answer. */
699
+ readonly skipLoader?: boolean,
700
+ /**
701
+ * Whether the caller can render a route whose loader has not answered yet.
702
+ *
703
+ * Only a streaming server render can, and that is the whole of why this is
704
+ * a caller's choice rather than the router's. `createRenderer`'s `render`
705
+ * sends a `<Suspense>` fallback now and the content when it arrives, so
706
+ * deferring is what turns a slow loader from a delay before the first byte
707
+ * into a fallback the reader is already looking at.
708
+ *
709
+ * Nothing else is in that position, and each for its own reason. `prerender`
710
+ * writes a file, which has no first paint to improve and no reader to show a
711
+ * fallback to. `hydrate` has the server's answer already. A client
712
+ * navigation has a page on screen that stays interactive while the next one
713
+ * resolves, which is the browser's version of the same idea and does not
714
+ * need this one.
715
+ *
716
+ * It costs the loader its say in the response: a status is decided when the
717
+ * shell goes out, so a deferred `notFound()` reaches the error boundary
718
+ * rather than the 404 page, and the document is a 200. That is inherent to
719
+ * streaming rather than a shortcut — the bytes have gone — and it is the
720
+ * reason this is off unless a caller asks.
721
+ */
722
+ readonly defer?: boolean,
723
+ /** The route pattern, the moment the URL matches one; see [`resolveMatch`]. */
724
+ readonly onMatch?: (pattern: string) => void,
725
+ /** The server supplies tracing without importing server code into the browser. */
726
+ readonly runLoader?: (body: () => mixed | Promise<mixed>) => Promise<mixed>,
727
+ |};
728
+
729
+ async function resolveRoute(
730
+ table: RouteTable,
731
+ url: string,
732
+ options?: ResolveOptions,
733
+ ): Promise<ResolvedRoute> {
734
+ const { pathname, search } = splitUrl(url);
735
+ const searchParams = parseSearch(search);
736
+ const matched = matchRoute(table.routes, pathname);
737
+
738
+ if (matched != null) {
739
+ options?.onMatch?.(matched.route.path);
740
+ }
741
+
742
+ if (matched == null) {
743
+ return resolveNotFound(table, pathname, search, searchParams);
744
+ }
745
+
746
+ const load = matched.route.page;
747
+ if (load == null) {
748
+ // Reachable only by asking this table to render a route it was built
749
+ // without. `hydrate` and every navigation check `hasClientPage` first and
750
+ // hand the URL to the browser instead, so arriving here means a caller
751
+ // went around them — and the honest answer is to say so rather than to
752
+ // render an empty page.
753
+ throw new Error(
754
+ `@uniflowed/router: ${matched.route.path} has no page in this route table; it ships no ` +
755
+ "client JavaScript, so the browser navigates to it rather than rendering it",
756
+ );
757
+ }
758
+ const [page, ...layouts] = await Promise.all([
759
+ loadOnce(load),
760
+ ...matched.route.layouts.map((layout) => loadOnce(layout)),
761
+ ]);
762
+ // Started here and awaited at the end: the boundary's module does not depend
763
+ // on the loader, so importing it alongside costs a navigation nothing. It
764
+ // never rejects, so an early throw below leaves no unhandled rejection.
765
+ const boundary = resolveErrorBoundary(table, pathname, matched.route.layouts.length);
766
+ // Started alongside for the same reason, and awaited at the end: a fallback
767
+ // depends on nothing the loader produces.
768
+ const loading = resolveLoading(matched.route, matched.route.layouts.length);
769
+ const templates = resolveTemplates(matched.route, matched.route.layouts.length);
770
+ // Started alongside and awaited at the end, for the reason the boundaries
771
+ // are: a slot is matched against the URL and depends on nothing the loader
772
+ // produces, so the second match and its imports overlap the first page's
773
+ // loader rather than following it.
774
+ const slots = resolveSlots(
775
+ matched.route.slots ?? [],
776
+ pathname,
777
+ matched.route.layouts.length,
778
+ matched.params,
779
+ );
780
+
781
+ // The loader, run here and awaited below — or not awaited at all.
782
+ //
783
+ // A page that suspends while *rendering* has always streamed; a page waiting
784
+ // on its loader could not, because this function awaited the loader before it
785
+ // returned and by the time React saw the tree the data was already in hand.
786
+ // The fallback beside such a page showed for zero milliseconds, which made
787
+ // `$loading.js` useful for the one case a page usually is not slow for.
788
+ //
789
+ // Two things stand in the way of simply not awaiting, and both are about the
790
+ // document rather than about the route. Metadata goes in the head and the
791
+ // head is written before the body, so a title computed from the data
792
+ // genuinely cannot be deferred — that is a rule worth stating rather than a
793
+ // limitation to hide, and it is the `generateMetadata` half of the condition
794
+ // below. The other is that a route with no `<Suspense>` above it has nothing
795
+ // to defer *into*: React holds the whole shell for a page that suspends with
796
+ // no boundary, which is the same wait by another name, with an unresolved
797
+ // promise flowing through the tree for nothing. So the loader is deferred
798
+ // exactly when there is a boundary to defer it into.
799
+ //
800
+ // See ubugeeei-prod/uf#373, and `ResolveOptions.defer` for who asks.
801
+ let data: mixed = options?.data;
802
+ let deferred: ?Promise<mixed> = null;
803
+ if (options?.skipLoader !== true && typeof page.loader === "function") {
804
+ const loader = page.loader;
805
+ const loadData = () => loader({ params: matched.params, searchParams, pathname });
806
+ const running = options?.runLoader == null ? loadData() : options.runLoader(loadData);
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
+ intercepted?: ?InterceptedUrl,
867
+ ): Promise<$ReadOnlyArray<ResolvedSlot>> {
868
+ if (records.length === 0) {
869
+ return [];
870
+ }
871
+ return Promise.all(
872
+ records.map((record) =>
873
+ resolveSlot(record, pathname, layoutCount, fallbackParams, intercepted),
874
+ ),
875
+ );
876
+ }
877
+
878
+ async function resolveSlot(
879
+ record: SlotRecord,
880
+ pathname: string,
881
+ layoutCount: number,
882
+ fallbackParams: RouteParams,
883
+ intercepted?: ?InterceptedUrl,
884
+ ): Promise<ResolvedSlot> {
885
+ // Clamped exactly as a template's `above` is, and for the same reason: a
886
+ // hand-written table, or a `(group)` between the layout and the route, can
887
+ // leave a route with fewer layouts than the slot was declared above.
888
+ const above = Math.min(record.above, layoutCount);
889
+ const empty: ResolvedSlot = {
890
+ name: record.name,
891
+ above,
892
+ page: null,
893
+ params: fallbackParams,
894
+ layouts: [],
895
+ loading: [],
896
+ templates: [],
897
+ errorBoundary: null,
898
+ slots: [],
899
+ record,
900
+ intercepted,
901
+ };
902
+
903
+ const matched = matchIn(record.routes, pathname);
904
+ if (matched == null) {
905
+ // The URL says nothing about this slot. `$default.js` is what it says
906
+ // instead, and a slot that declares none renders nothing at all.
907
+ const load = record.defaultPage;
908
+ if (load == null) {
909
+ return empty;
910
+ }
911
+ const module = await loadOrNull(load);
912
+ if (module == null) {
913
+ return empty;
914
+ }
915
+ return {
916
+ ...empty,
917
+ page: withoutLoader(module, record.defaultFile ?? record.name),
918
+ errorBoundary: await resolveSlotErrorBoundary(record.defaultErrorBoundary ?? null, 0),
919
+ };
920
+ }
921
+
922
+ return (await resolveSlotRoute(record, matched, above, pathname, intercepted)) ?? empty;
923
+ }
924
+
925
+ /**
926
+ * One route inside a slot, imported: what the slot renders for a match.
927
+ *
928
+ * Shared by the two ways a slot comes to render a route — one of its own
929
+ * `routes`, matched against the URL, and one of its `intercepts`, matched
930
+ * against where a client navigation is going — so an intercepting page is
931
+ * composed exactly the way every other slot page is: inside its own layouts,
932
+ * fallbacks, templates and error boundary, with the slots those layouts
933
+ * declare.
934
+ *
935
+ * `null` when the page or a layout will not import, and what that means is the
936
+ * caller's to say. For a match it is an empty slot, for the reason
937
+ * [`resolveSlots`] gives; for an interception it is no interception, and the
938
+ * navigation goes where the URL says instead.
939
+ */
940
+ async function resolveSlotRoute(
941
+ record: SlotRecord,
942
+ matched: RoutingRouteMatch<SlotRouteRecord>,
943
+ above: number,
944
+ pathname: string,
945
+ intercepted: ?InterceptedUrl,
946
+ ): Promise<?ResolvedSlot> {
947
+ const route = matched.route;
948
+ // Started together and awaited apart, so the two `await`s are not a
949
+ // waterfall and each keeps the type its loader had.
950
+ const pending = loadOrNull(route.page);
951
+ const pendingLayouts = Promise.all(route.layouts.map((layout) => loadOrNull(layout)));
952
+ const page = await pending;
953
+ const layouts = await pendingLayouts;
954
+ if (page == null) {
955
+ return null;
956
+ }
957
+ const loaded = layouts.filter(Boolean);
958
+ if (loaded.length !== layouts.length) {
959
+ return null;
960
+ }
961
+ const loading = await resolveLoadingRecords(route.loading ?? [], loaded.length);
962
+ const templates = await resolveTemplateRecords(route.templates ?? [], loaded.length);
963
+ const errorBoundary = await resolveSlotErrorBoundary(route.errorBoundary ?? null, loaded.length);
964
+ return {
965
+ name: record.name,
966
+ above,
967
+ page: withoutLoader(page, route.file),
968
+ params: matched.params,
969
+ layouts: loaded,
970
+ loading,
971
+ templates,
972
+ errorBoundary,
973
+ // The slot's own layouts are what a nested slot is measured against, so
974
+ // the count handed down is this slot's rather than the route's. A slot
975
+ // nested inside an interception is matched against the intercepted URL,
976
+ // and keyed on it, for the same reason the interception is.
977
+ slots: await resolveSlots(route.slots, pathname, loaded.length, matched.params, intercepted),
978
+ record,
979
+ intercepted,
980
+ };
981
+ }
982
+
983
+ // ---------------------------------------------------------------------------
984
+ // Interception
985
+ // ---------------------------------------------------------------------------
986
+
987
+ /**
988
+ * The page underneath: `resolved` itself, or what it was before an interception
989
+ * put something in one of its slots.
990
+ *
991
+ * Every question about where a navigation *starts* is asked of this rather than
992
+ * of the route on screen, because an interception is not a place a navigation
993
+ * can start from. The next photo, opened from inside the modal, is intercepted
994
+ * from the feed.
995
+ */
996
+ export function beneath(resolved: ResolvedRoute): ResolvedRoute {
997
+ return resolved.interception?.base ?? resolved;
998
+ }
999
+
1000
+ /**
1001
+ * The intercepting routes the slots on screen have for `pathname`.
1002
+ *
1003
+ * Only the slots on screen, and that is the whole of what "a navigation from
1004
+ * inside `/feed`" means. A page under `app/feed/$layout.js` renders the layout
1005
+ * that declares `@modal`, so the slot is in its tree and so are the slot's
1006
+ * `intercepts`. A page outside that segment has no such slot in its tree —
1007
+ * which is why a link to `/feed/photo/1` from `/about` is an ordinary
1008
+ * navigation to the photo page, not an interception with nowhere to render.
1009
+ *
1010
+ * A slot that intercepts `pathname` is not looked inside: what it holds is
1011
+ * about to be replaced, nested slots and all.
1012
+ */
1013
+ export function interceptingRoutes(
1014
+ slots: $ReadOnlyArray<ResolvedSlot>,
1015
+ pathname: string,
1016
+ ): $ReadOnlyArray<SlotRouteRecord> {
1017
+ const found: Array<SlotRouteRecord> = [];
1018
+ for (const slot of slots) {
1019
+ const matched = matchIn(slot.record?.intercepts ?? [], pathname);
1020
+ if (matched != null) {
1021
+ found.push(matched.route);
1022
+ } else {
1023
+ found.push(...interceptingRoutes(slot.slots, pathname));
1024
+ }
1025
+ }
1026
+ return found;
1027
+ }
1028
+
1029
+ /**
1030
+ * `base`, with every slot on it that intercepts `url` rendering what it
1031
+ * intercepts — or `null` when none of them does.
1032
+ *
1033
+ * # What stays, and what does not
1034
+ *
1035
+ * Everything that is not an intercepting slot stays exactly as it was, and that
1036
+ * is the feature rather than a shortcut. `children` goes on rendering the page
1037
+ * the reader navigated *from* — its data, its scroll position, whatever state
1038
+ * its components are holding — and every other slot keeps what it was showing.
1039
+ * An intercepted navigation changes the address bar and the slots that
1040
+ * intercept it, and nothing else. Matching the rest against the new URL would
1041
+ * be an ordinary navigation with a modal on top of it: the page underneath
1042
+ * swapped for the page the URL names, which is precisely what interception
1043
+ * exists not to do.
1044
+ *
1045
+ * Every slot that intercepts the URL renders it, not only the first, because
1046
+ * slots are independent of each other: two named places may each have
1047
+ * something to show for one URL, the way two slots each match one URL by their
1048
+ * own routes.
1049
+ *
1050
+ * # Never for a document
1051
+ *
1052
+ * A document request for an intercepted URL resolves the ordinary page,
1053
+ * because a request carries where it is going and not what was on screen when
1054
+ * it was made — which is what a reload, a shared link and a crawler all are.
1055
+ * A Flight payload request may carry the page the browser is navigating from,
1056
+ * and the React Server Components renderer calls this for that request only.
1057
+ *
1058
+ * # When the interception cannot render
1059
+ *
1060
+ * A slot whose intercepting page will not import keeps what it had, and when no
1061
+ * slot could render the interception this answers `null`: the navigation goes
1062
+ * ahead as an ordinary one, and the reader gets the page the URL names — what a
1063
+ * reload would have given them — rather than a click that did nothing. An
1064
+ * intercepting page that exports a `loader`, which no slot page may, is the
1065
+ * error boundary for the URL, the way any other slot page's is.
1066
+ */
1067
+ export async function resolveInterception(
1068
+ table: RouteTable,
1069
+ base: ResolvedRoute,
1070
+ url: string,
1071
+ ): Promise<?ResolvedRoute> {
1072
+ const { pathname, search } = splitUrl(url);
1073
+ const intercepted: InterceptedUrl = { pathname, searchParams: parseSearch(search) };
1074
+ // The first slot that renders the interception, for the transition's name.
1075
+ let first: ?ResolvedSlot = null;
1076
+ const visit = (slots: $ReadOnlyArray<ResolvedSlot>): Promise<$ReadOnlyArray<ResolvedSlot>> =>
1077
+ Promise.all(
1078
+ slots.map(async (slot): Promise<ResolvedSlot> => {
1079
+ const record = slot.record;
1080
+ const matched = record == null ? null : matchIn(record.intercepts ?? [], pathname);
1081
+ if (record == null || matched == null) {
1082
+ return slot.slots.length === 0 ? slot : { ...slot, slots: await visit(slot.slots) };
1083
+ }
1084
+ const rendered = await resolveSlotRoute(record, matched, slot.above, pathname, intercepted);
1085
+ if (rendered == null) {
1086
+ return slot;
1087
+ }
1088
+ first = first ?? rendered;
1089
+ return rendered;
1090
+ }),
1091
+ );
1092
+
1093
+ let slots: $ReadOnlyArray<ResolvedSlot>;
1094
+ try {
1095
+ slots = await visit(base.slots);
1096
+ } catch (error) {
1097
+ return resolveFailure(table, url, error);
1098
+ }
1099
+ if (first == null) {
1100
+ return null;
1101
+ }
1102
+ return {
1103
+ ...base,
1104
+ slots,
1105
+ // The intercepting page's own name, where it or a layout inside the slot
1106
+ // declares one, so a stylesheet can tell a modal opening from a page
1107
+ // arriving. The page underneath has not moved, so its name would say
1108
+ // nothing about this arrival.
1109
+ viewTransition: resolveViewTransition(first.page ?? {}, first.layouts),
1110
+ interception: { pathname, search, base },
1111
+ };
1112
+ }
1113
+
1114
+ async function resolveSlotErrorBoundary(
1115
+ boundary: ?SlotErrorBoundaryLoader,
1116
+ layoutCount: number,
1117
+ ): Promise<?ResolvedSlotErrorBoundary> {
1118
+ if (boundary == null) {
1119
+ return null;
1120
+ }
1121
+ const above = Math.min(boundary.above, layoutCount);
1122
+ try {
1123
+ return { module: await loadOnce(boundary.module), above };
1124
+ } catch {
1125
+ // Keep the declared depth even when the custom file fails to import. The
1126
+ // framework fallback still contains the slot instead of escalating the
1127
+ // page beside it.
1128
+ return { module: null, above };
1129
+ }
1130
+ }
1131
+
1132
+ /**
1133
+ * A module, or `null` when it would not import.
1134
+ *
1135
+ * The judgement [`resolveTemplates`] and [`resolveLoading`] already make, at
1136
+ * the granularity a slot needs it: a slot is a second thing beside the page, so
1137
+ * a slot whose module is missing renders nothing rather than taking the route
1138
+ * down with it — and the import error surfaces where it belongs, the next time
1139
+ * the module is asked for.
1140
+ */
1141
+ async function loadOrNull<TModule>(load: () => Promise<TModule>): Promise<?TModule> {
1142
+ try {
1143
+ return await loadOnce(load);
1144
+ } catch {
1145
+ return null;
1146
+ }
1147
+ }
1148
+
1149
+ /**
1150
+ * The same page module, having said out loud that a slot's loader does not run.
1151
+ *
1152
+ * A slot page is a component. It is *not* handed data, and this throws rather
1153
+ * than passing `undefined` to a page that asked for some, because a slot whose
1154
+ * loader is quietly skipped is exactly the failure ubugeeei-prod/uf#267 is
1155
+ * about — a file written to a convention, and nothing that reads it.
1156
+ *
1157
+ * Why not run it. A page's loader answer is embedded in the document for the
1158
+ * browser to hydrate from, once, under one id; a slot's would have nowhere to
1159
+ * go, so it would run on the server and again in the browser on the way in.
1160
+ * That is not merely two fetches: a loader that reads `cookies()` succeeds on
1161
+ * the server and throws in the browser, and the slot would render on one side
1162
+ * and not the other — a hydration mismatch produced by the router. So the rule
1163
+ * is the narrow one, and lifting it means embedding per-slot data, which is
1164
+ * named in the issue as what is left.
1165
+ */
1166
+ function withoutLoader(module: PageModule, file: string): PageModule {
1167
+ if (typeof module.loader === "function") {
1168
+ throw new Error(
1169
+ `@uniflowed/router: ${file} is inside a \`@slot\` and exports a \`loader\`, which the ` +
1170
+ "router does not run — a slot's data has nowhere to be embedded for hydration, so it " +
1171
+ "would be fetched again in the browser and a server-only loader would render one tree " +
1172
+ "on the server and another in the page. Fetch inside the component, or move the data to " +
1173
+ "the page the URL names. https://github.com/ubugeeei-prod/uf/issues/267",
1174
+ );
1175
+ }
1176
+ return module;
1177
+ }
1178
+
1179
+ /**
1180
+ * The route's templates, imported.
1181
+ *
1182
+ * A template that will not load is dropped, the way a fallback is: it is a
1183
+ * wrapper around the page, not the page, so a broken wrapper must not become a
1184
+ * broken route. The tree renders without it — the page keeps the layout it was
1185
+ * inside, and loses only the remount — and the import error surfaces where it
1186
+ * belongs, when the module is next asked for.
1187
+ */
1188
+ async function resolveTemplates(
1189
+ route: RouteRecord,
1190
+ layoutCount: number,
1191
+ ): Promise<$ReadOnlyArray<ResolvedTemplate>> {
1192
+ return resolveTemplateRecords(route.templates ?? [], layoutCount);
1193
+ }
1194
+
1195
+ async function resolveTemplateRecords(
1196
+ records: $ReadOnlyArray<TemplateRecord>,
1197
+ layoutCount: number,
1198
+ ): Promise<$ReadOnlyArray<ResolvedTemplate>> {
1199
+ if (records.length === 0) {
1200
+ return [];
1201
+ }
1202
+ const loaded = await Promise.all(
1203
+ records.map(async (record) => {
1204
+ try {
1205
+ return {
1206
+ // Clamped exactly as the error and loading boundaries' are: a
1207
+ // `(group)` directory can leave a route with fewer layouts than the
1208
+ // template declared above it.
1209
+ above: Math.min(record.above, layoutCount),
1210
+ module: await loadOnce(record.module),
1211
+ };
1212
+ } catch {
1213
+ return null;
1214
+ }
1215
+ }),
1216
+ );
1217
+ return loaded.filter(Boolean);
1218
+ }
1219
+
1220
+ /**
1221
+ * The route's loading boundaries, imported.
1222
+ *
1223
+ * A boundary whose module will not load is dropped rather than thrown for, and
1224
+ * this is the same judgement `resolveErrorBoundary` makes one function above: a
1225
+ * fallback is what the router shows while it does not yet have the page, so a
1226
+ * broken fallback must not become a broken page. The route renders without that
1227
+ * boundary — the next one out, or the shell, waits for it instead — and the
1228
+ * import error surfaces where it belongs, when the module is next asked for.
1229
+ */
1230
+ async function resolveLoading(
1231
+ route: RouteRecord,
1232
+ layoutCount: number,
1233
+ ): Promise<$ReadOnlyArray<{| readonly above: number, readonly module: LoadingModule |}>> {
1234
+ return resolveLoadingRecords(route.loading ?? [], layoutCount);
1235
+ }
1236
+
1237
+ async function resolveLoadingRecords(
1238
+ records: $ReadOnlyArray<LoadingRecord>,
1239
+ layoutCount: number,
1240
+ ): Promise<$ReadOnlyArray<{| readonly above: number, readonly module: LoadingModule |}>> {
1241
+ if (records.length === 0) {
1242
+ return [];
1243
+ }
1244
+ const loaded = await Promise.all(
1245
+ records.map(async (record) => {
1246
+ try {
1247
+ return {
1248
+ // Clamped exactly as the error boundary's is, and for the same
1249
+ // reason: a `(group)` directory can leave a route with fewer layouts
1250
+ // than the boundary that covers it.
1251
+ above: Math.min(record.above, layoutCount),
1252
+ module: await loadOnce(record.module),
1253
+ };
1254
+ } catch {
1255
+ return null;
1256
+ }
1257
+ }),
1258
+ );
1259
+ return loaded.filter(Boolean);
1260
+ }
1261
+
1262
+ /**
1263
+ * The route to render after something threw.
1264
+ *
1265
+ * Two callers, one behaviour: [`resolveMatch`] when a loader or a module
1266
+ * import threw, and `createRenderer` when the *render* did — React's error
1267
+ * boundaries do not run in `renderToString`, so the server has to catch it
1268
+ * itself and resolve again.
1269
+ */
1270
+ export async function resolveFailure(
1271
+ table: RouteTable,
1272
+ url: string,
1273
+ error: mixed,
1274
+ ): Promise<ResolvedRoute> {
1275
+ const { pathname, search } = splitUrl(url);
1276
+ const searchParams = parseSearch(search);
1277
+ if (error instanceof NotFoundError) {
1278
+ try {
1279
+ return await resolveNotFound(table, pathname, search, searchParams);
1280
+ } catch (failure) {
1281
+ // The not-found page itself would not load. Falling through to the error
1282
+ // boundary rather than rethrowing is what keeps the promise above: the
1283
+ // page a project wrote to explain a 404 is not more load-bearing than
1284
+ // the document staying on screen.
1285
+ return resolveError(table, pathname, search, searchParams, routeErrorFor(failure));
1286
+ }
1287
+ }
1288
+ return resolveError(table, pathname, search, searchParams, routeErrorFor(error));
1289
+ }
1290
+
1291
+ /** What a thrown value means to the router. */
1292
+ export function routeErrorFor(error: mixed): RouteError {
1293
+ if (error instanceof UnauthorizedError) {
1294
+ return { kind: "unauthorized" };
1295
+ }
1296
+ if (error instanceof ForbiddenError) {
1297
+ return { kind: "forbidden" };
1298
+ }
1299
+ return { kind: "thrown", error };
1300
+ }
1301
+
1302
+ /**
1303
+ * The error boundary a route renders inside, loaded with the route rather than
1304
+ * when it is needed.
1305
+ *
1306
+ * React decides to show a boundary's fallback synchronously, during the render
1307
+ * that threw. A module that still has to be imported is a module that is not
1308
+ * there at the only moment it can be used, so this is one more dynamic import
1309
+ * per navigation and not a lazy one.
1310
+ *
1311
+ * `above` is the boundary's own layout count, clamped to the route's. The
1312
+ * first attempt compared the two layout arrays for a shared prefix, which is
1313
+ * more precise when a `(group)` directory puts a boundary beside a route
1314
+ * rather than above it — and it worked by *reference identity* of the loader
1315
+ * functions, which holds only because `routesModuleSource` deduplicates them
1316
+ * by file. A rule that depends on an invisible property of the generated
1317
+ * module is a rule that reads as zero the moment a table is built any other
1318
+ * way, and it did: it put the boundary outside the layouts it was written
1319
+ * inside. Nesting a boundary per group needs parallel-route trees (#267);
1320
+ * until then this is the honest approximation, and it is stated rather than
1321
+ * inferred.
1322
+ */
1323
+ async function resolveErrorBoundary(
1324
+ table: RouteTable,
1325
+ pathname: string,
1326
+ layoutCount: number,
1327
+ ): Promise<{| readonly module: ?ErrorModule, readonly above: number |}> {
1328
+ const boundary = nearestBoundary(table.errors, pathname);
1329
+ if (boundary == null) {
1330
+ return { module: null, above: 0 };
1331
+ }
1332
+ // Clamped, because a route group can leave a route with fewer layouts than
1333
+ // the boundary covering it, and an `above` past the end would compose the
1334
+ // layouts out of nothing.
1335
+ const above = Math.min(boundary.layouts.length, layoutCount);
1336
+ const load = boundary.module;
1337
+ // The synthesised root record, which names layouts and no module: the
1338
+ // framework's page renders, and `above` still says where — inside the site's
1339
+ // own layouts rather than outside everything. See [`NotFoundBoundary`]`.page`.
1340
+ if (load == null) {
1341
+ return { module: null, above };
1342
+ }
1343
+ try {
1344
+ return { module: await loadOnce(load), above };
1345
+ } catch {
1346
+ // A boundary whose module will not load cannot be the answer to a throw,
1347
+ // and this is why the field is nullable: containment must not itself
1348
+ // depend on an import working. The depth is kept, because the layouts the
1349
+ // boundary named are still there and the framework's page is better inside
1350
+ // them than outside them.
1351
+ return { module: null, above };
1352
+ }
1353
+ }
1354
+
1355
+ /**
1356
+ * The error page for `pathname`, inside the layouts above the boundary that
1357
+ * answers it.
1358
+ *
1359
+ * The layouts are the boundary's, for the same reason [`resolveNotFound`]
1360
+ * gives: they are what stays mounted around the error, and the layouts below
1361
+ * the boundary belong to the subtree that just stopped.
1362
+ */
1363
+ async function resolveError(
1364
+ table: RouteTable,
1365
+ pathname: string,
1366
+ search: string,
1367
+ searchParams: SearchParams,
1368
+ routeError: RouteError,
1369
+ ): Promise<ResolvedRoute> {
1370
+ const boundary = nearestBoundary(table.errors, pathname);
1371
+ let module: ?ErrorModule = null;
1372
+ let layouts: $ReadOnlyArray<LayoutModule> = [];
1373
+ if (boundary != null) {
1374
+ const load = boundary.module;
1375
+ try {
1376
+ // The layouts whether or not there is a module, because the synthesised
1377
+ // root record has layouts and no module and its whole purpose is that
1378
+ // the framework's error page renders inside them: a site whose root
1379
+ // layout owns the masthead and the stylesheet answered a 500 with
1380
+ // neither. See ubugeeei-prod/uf#351.
1381
+ layouts = await Promise.all(boundary.layouts.map((layout) => loadOnce(layout)));
1382
+ module = load == null ? null : await loadOnce(load);
1383
+ } catch {
1384
+ // See `resolveErrorBoundary`: the framework's own page answers instead.
1385
+ module = null;
1386
+ layouts = [];
1387
+ }
1388
+ }
1389
+
1390
+ const declared = await resolveMetadata(
1391
+ module?.metadata != null ? { metadata: module.metadata } : {},
1392
+ layouts,
1393
+ { params: {}, searchParams, data: undefined },
1394
+ );
1395
+ return {
1396
+ pathname,
1397
+ search,
1398
+ path: "*",
1399
+ params: {},
1400
+ searchParams,
1401
+ page: { default: ResolvedErrorPage },
1402
+ layouts,
1403
+ data: undefined,
1404
+ deferred: null,
1405
+ metadata: declared.title != null ? declared : { ...declared, title: errorTitle(routeError) },
1406
+ // The boundary's own layouts may name one; the page cannot, because the
1407
+ // page here is this module's. An error arriving under the section's
1408
+ // transition is the same answer as a page arriving under it.
1409
+ viewTransition: resolveViewTransition({}, layouts),
1410
+ status: routeErrorStatus(routeError),
1411
+ error: routeError,
1412
+ // All of the boundary's layouts are above it, and no inner boundary is
1413
+ // inserted around a page that already is one; see `RouteView`.
1414
+ errorBoundary: { module, above: layouts.length },
1415
+ // An error page has nothing left to wait for: it renders the value it was
1416
+ // resolved with. A fallback around it would be a boundary that can never
1417
+ // show, which is worse than none.
1418
+ loading: [],
1419
+ templates: [],
1420
+ // And slots for the third time: a slot belongs to the segment the walk went
1421
+ // through, and an error page is matched rather than walked to. A layout
1422
+ // that declares one is still mounted above the boundary, holding the slot
1423
+ // it was rendered with — the boundary replaces what is under it.
1424
+ slots: [],
1425
+ };
1426
+ }
1427
+
1428
+ /**
1429
+ * The not-found page for `pathname`, inside the layouts above the boundary
1430
+ * that answers it.
1431
+ *
1432
+ * The layouts are the *boundary's*, not the ones the URL had already matched.
1433
+ * Taking the matched route's layouts was the other candidate and it is wrong
1434
+ * in both directions: for an unmatched URL there is no matched route to take
1435
+ * them from, and for `notFound()` thrown from a page they would keep the
1436
+ * layouts *below* the boundary — so `app/guide/[slug]/$layout.js` would
1437
+ * wrap a 404 that `app/guide/$not-found.js` answered, which is the layout
1438
+ * of the page that just said it does not exist.
1439
+ *
1440
+ * # The record with no page
1441
+ *
1442
+ * A project that declares no `$not-found.js` anywhere still has a record —
1443
+ * the one the build synthesises for the router root — and it names the root's
1444
+ * layouts and no module. Before that record existed this function answered
1445
+ * with `layouts: []`, so a site whose root layout owns the masthead, the
1446
+ * stylesheet and often `<html>` itself answered an unmatched URL with a white
1447
+ * page carrying `404` and no way to leave it. That was not the nearest-ancestor
1448
+ * rule failing; it was the fallback having no record to take layouts from, and
1449
+ * giving it one is the whole of ubugeeei-prod/uf#351.
1450
+ *
1451
+ * The framework's page then merges its title over the layouts' metadata like
1452
+ * any page would, so a `metadataBase` or an `og:site_name` declared on the root
1453
+ * layout still applies to the 404.
1454
+ */
1455
+ async function resolveNotFound(
1456
+ table: RouteTable,
1457
+ pathname: string,
1458
+ search: string,
1459
+ searchParams: SearchParams,
1460
+ ): Promise<ResolvedRoute> {
1461
+ const record = nearestBoundary(table.notFound, pathname);
1462
+ const load = record?.page;
1463
+ const [page, ...layouts] = await Promise.all([
1464
+ load == null
1465
+ ? Promise.resolve<PageModule>({ default: DefaultNotFound, metadata: { title: "Not found" } })
1466
+ : loadOnce(load),
1467
+ ...(record?.layouts ?? []).map((layout) => loadOnce(layout)),
1468
+ ]);
1469
+ const metadata = await resolveMetadata(page, layouts, {
1470
+ params: {},
1471
+ searchParams,
1472
+ data: undefined,
1473
+ });
1474
+ return {
1475
+ pathname,
1476
+ search,
1477
+ path: "*",
1478
+ params: {},
1479
+ searchParams,
1480
+ page,
1481
+ layouts,
1482
+ data: undefined,
1483
+ deferred: null,
1484
+ metadata,
1485
+ viewTransition: resolveViewTransition(page, layouts),
1486
+ status: 404,
1487
+ error: null,
1488
+ // A not-found page is a page: one that throws is contained like any other.
1489
+ errorBoundary: await resolveErrorBoundary(table, pathname, layouts.length),
1490
+ // A not-found boundary is matched, not nested: `nearestBoundary` picked one
1491
+ // record and the loading files are a property of the route that was walked
1492
+ // to, which this URL never reached. Nothing to wait for, so no boundary.
1493
+ loading: [],
1494
+ // Templates are accumulated on that same walk, and for the same reason.
1495
+ templates: [],
1496
+ // Slots too: a URL that matched no route addressed no slot either.
1497
+ slots: [],
1498
+ };
1499
+ }
1500
+
1501
+ /**
1502
+ * The route's metadata: each declaration merged over the ones outside it.
1503
+ *
1504
+ * Per key, so a page that declares only `canonical` keeps the title its layout
1505
+ * set — with one exception, and it is deliberate. `jsonLd` is gathered along
1506
+ * the way instead of merged, because a nearer declaration of it is an addition
1507
+ * rather than a correction; [`Metadata`] has the argument.
1508
+ */
1509
+ async function resolveMetadata(
1510
+ page: PageModule,
1511
+ layouts: $ReadOnlyArray<LayoutModule>,
1512
+ args: MetadataArgs,
1513
+ ): Promise<Metadata> {
1514
+ let merged: Metadata = {};
1515
+ let structured: $ReadOnlyArray<JsonLd> = [];
1516
+ const take = (declared: Metadata) => {
1517
+ if (declared.jsonLd != null) {
1518
+ structured = [...structured, ...declared.jsonLd];
1519
+ }
1520
+ merged = { ...merged, ...declared };
1521
+ };
1522
+
1523
+ for (const layout of layouts) {
1524
+ if (layout.metadata != null) {
1525
+ take(layout.metadata);
1526
+ }
1527
+ }
1528
+ if (page.frontmatter != null) {
1529
+ const { title, description } = page.frontmatter;
1530
+ merged = {
1531
+ ...merged,
1532
+ ...(title != null ? { title } : {}),
1533
+ ...(description != null ? { description } : {}),
1534
+ };
1535
+ }
1536
+ if (page.metadata != null) {
1537
+ take(page.metadata);
1538
+ }
1539
+ if (typeof page.generateMetadata === "function") {
1540
+ take(await page.generateMetadata(args));
1541
+ }
1542
+ return structured.length === 0 ? merged : { ...merged, jsonLd: structured };
1543
+ }
1544
+
1545
+ /** A module that may name the transition its route arrives under. */
1546
+ type Transitioning = { readonly viewTransition?: string, ... };
1547
+
1548
+ /**
1549
+ * What a stylesheet calls this route's arrival: the nearest declaration wins.
1550
+ *
1551
+ * The same walk `resolveMetadata` does one function above, and stated as its
1552
+ * own function rather than folded into that one because the two answer
1553
+ * different questions and only one of them is a document. Layouts are root
1554
+ * first, so overwriting as it descends leaves the innermost, and the page has
1555
+ * the last word.
1556
+ *
1557
+ * The parameters say what is read rather than naming `PageModule` and
1558
+ * `LayoutModule`, which is the shape `nearestBoundary` already takes for the
1559
+ * same reason: this reads one optional field, so requiring the whole of either
1560
+ * type would be a claim it does not need and cannot use.
1561
+ */
1562
+ function resolveViewTransition(
1563
+ page: Transitioning,
1564
+ layouts: $ReadOnlyArray<Transitioning>,
1565
+ ): ?string {
1566
+ let name: ?string = null;
1567
+ for (const layout of layouts) {
1568
+ if (layout.viewTransition != null) {
1569
+ name = layout.viewTransition;
1570
+ }
1571
+ }
1572
+ return page.viewTransition ?? name;
1573
+ }
1574
+
1575
+ component DefaultNotFound() {
1576
+ return (
1577
+ <main>
1578
+ <title>Not found</title>
1579
+ <h1>404</h1>
1580
+ <p>This page does not exist.</p>
1581
+ </main>
1582
+ );
1583
+ }
1584
+
1585
+ /** The document title an error page gets when nothing declared one. */
1586
+ export function errorTitle(error: RouteError): string {
1587
+ return match (error) {
1588
+ {kind: "unauthorized"} => "Sign in required",
1589
+ {kind: "forbidden"} => "Not allowed",
1590
+ {kind: "thrown"} => "Something went wrong",
1591
+ };
1592
+ }
1593
+
1594
+ /**
1595
+ * A route module's component, as the router is about to render it.
1596
+ *
1597
+ * # The one cast in this file, and why it is here rather than in six places
1598
+ *
1599
+ * A `RouteComponent` is a component about whose props nothing was claimed, and
1600
+ * `RouteView` is about to pass it three. React allows that — a component
1601
+ * receives the props its parent wrote and ignores the ones it did not declare
1602
+ * — but Flow cannot be told it: a page's props are exact, so no props type but
1603
+ * that page's own is assignable, and the router does not know which page it
1604
+ * has. `React.ComponentType<any>` on the module types was this same
1605
+ * unsoundness spread over six declarations, where it also stopped anyone from
1606
+ * checking that `RouteView` passes the props a page is documented to receive.
1607
+ * Here it is one line, and everything on either side of it is checked: what a
1608
+ * module may export, and what a page is handed. Suppressed by name so that
1609
+ * `check:lib` can gate CI without this file being the thing that stops it; the
1610
+ * directive names the rule, and this is the argument for escaping it.
1611
+ */
1612
+ export function renderable<TProps extends { ... }>(
1613
+ component: RouteComponent,
1614
+ ): React.ComponentType<TProps> {
1615
+ // uf-lint-disable-next-line flow/unclear-type
1616
+ return component as any;
1617
+ }