@uniflowed/router 0.0.0-alpha.11 → 0.0.0-alpha.13

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.
@@ -14,6 +14,7 @@ import {
14
14
  Suspense,
15
15
  createContext,
16
16
  startTransition,
17
+ use,
17
18
  useCallback,
18
19
  useContext,
19
20
  useEffect,
@@ -21,6 +22,19 @@ import {
21
22
  useState,
22
23
  useSyncExternalStore,
23
24
  } from "react";
25
+ // The one thing in this module that only a browser can do, and the reason it
26
+ // is imported here rather than from `../client.js`: a view transition needs
27
+ // the DOM updated inside the callback it was handed, and `startTransition`
28
+ // schedules. "View transitions", below, is the argument. Importing `react-dom`
29
+ // costs the server bundle nothing it did not already have — `internal/stream.js`
30
+ // imports `react-dom/server` — and this entry touches no document while it is
31
+ // being evaluated.
32
+ import { flushSync } from "react-dom";
33
+
34
+ // The id of the script the loader data is embedded in. It moved out of the
35
+ // head and into the tree with ubugeeei-prod/uf#373 — see [`loaderDataScript`]
36
+ // — so the module that renders it is this one rather than `../server.js`.
37
+ import { DATA_ID } from "./document.js";
24
38
 
25
39
  /** One parameter a route path captures. */
26
40
  export type RouteParamSpec = {| readonly name: string, readonly catchAll: boolean |};
@@ -89,6 +103,22 @@ export type PageModule = {
89
103
  | $ReadOnlyArray<RouteParams>
90
104
  | Promise<$ReadOnlyArray<RouteParams>>,
91
105
  readonly frontmatter?: { readonly title?: string, readonly description?: string, ... },
106
+ /**
107
+ * What a stylesheet calls the transition this route arrives under.
108
+ *
109
+ * Not a switch. Every client navigation opts into a view transition where
110
+ * the browser has one, and this is how one arrival is told from another —
111
+ * the name reaches CSS as an attribute on the document element for as long
112
+ * as the transition runs:
113
+ *
114
+ * html[data-uf-view-transition="manual"]::view-transition-old(root) { … }
115
+ *
116
+ * A layout may declare one too, and then it covers every route under it; the
117
+ * page's own wins. That is the rule `metadata` already follows, and there is
118
+ * no reason for a second one — "the nearest declaration" is how everything
119
+ * else in a route module is resolved.
120
+ */
121
+ readonly viewTransition?: string,
92
122
  ...
93
123
  };
94
124
 
@@ -97,6 +127,23 @@ export type LayoutModule = {
97
127
  readonly default?: RouteComponent,
98
128
  readonly Layout?: RouteComponent,
99
129
  readonly metadata?: Metadata,
130
+ /** A transition name for every route under this layout; see [`PageModule`]. */
131
+ readonly viewTransition?: string,
132
+ ...
133
+ };
134
+
135
+ /**
136
+ * What a template module may export. The component is `default` or `Template`.
137
+ *
138
+ * A layout's shape without its `metadata`, and the omission is the type saying
139
+ * what a template is for. A layout persists across navigation, so a title it
140
+ * declares is a claim about a section of the site; a template is thrown away
141
+ * and built again on every navigation, so a title on one would be a claim
142
+ * about nothing. Titles come from the page and the layouts above it.
143
+ */
144
+ export type TemplateModule = {
145
+ readonly default?: RouteComponent,
146
+ readonly Template?: RouteComponent,
100
147
  ...
101
148
  };
102
149
 
@@ -140,6 +187,41 @@ export type LoadingModule = {
140
187
  */
141
188
  export type TwitterCard = "summary" | "summary_large_image" | "app" | "player";
142
189
 
190
+ /**
191
+ * What a crawler may do with a page.
192
+ *
193
+ * Four fields rather than the whole `robots` vocabulary, and the omissions are
194
+ * the argument. `index` and `follow` are the two directives a page has an
195
+ * opinion about; `maxSnippet` and `maxImagePreview` are the two that change
196
+ * what a result *looks* like and have no other spelling. `nosnippet` is not
197
+ * here because `maxSnippet: 0` is the same instruction, and a type with two
198
+ * ways to say one thing is a type somebody will eventually ask which of them
199
+ * wins.
200
+ *
201
+ * Every field is optional and every one is only emitted when it is declared,
202
+ * because "index, follow" is what a document with no `robots` meta already
203
+ * says — the tag exists to say something else.
204
+ */
205
+ export type Robots = {
206
+ readonly index?: boolean,
207
+ readonly follow?: boolean,
208
+ /** The longest snippet a result may quote; `0` is none, `-1` is no limit. */
209
+ readonly maxSnippet?: number,
210
+ readonly maxImagePreview?: "none" | "standard" | "large",
211
+ };
212
+
213
+ /**
214
+ * One JSON-LD object, as a page hands it over.
215
+ *
216
+ * `mixed` values rather than a schema.org type, because there is no useful
217
+ * middle: the vocabulary is hundreds of types deep, it grows without asking
218
+ * anyone, and a partial transcription of it would reject correct documents far
219
+ * more often than it caught wrong ones. What this type does claim is the part
220
+ * uf is answerable for — that the thing is an object, and therefore that it
221
+ * serialises into one `<script>`.
222
+ */
223
+ export type JsonLd = { readonly [string]: mixed };
224
+
143
225
  /** Document metadata a page or layout declares. */
144
226
  export type Metadata = {
145
227
  readonly title?: string,
@@ -166,18 +248,115 @@ export type Metadata = {
166
248
  * under a second section — is one page, and this is how it says so.
167
249
  */
168
250
  readonly canonical?: string,
251
+ /**
252
+ * What a crawler may do with this page. See [`Robots`].
253
+ *
254
+ * The one field here that is usually declared on a *layout*: a staging
255
+ * section, a preview tree or an account area is `index: false` for
256
+ * everything under it, and saying so once is the only version of that which
257
+ * stays true when a page is added.
258
+ */
259
+ readonly robots?: Robots,
260
+ /**
261
+ * The other addresses this same page is published at.
262
+ *
263
+ * `languages` maps a BCP 47 tag to that translation's URL and becomes one
264
+ * `<link rel="alternate" hreflang>` each. The set has to be reciprocal —
265
+ * every page in it lists every other one *and itself*, which is what makes a
266
+ * search engine read them as translations rather than as duplicates — so it
267
+ * is usually the same map on every page of the set, declared on the layout
268
+ * they share. `"x-default"` is a tag like any other here, and names what a
269
+ * reader whose language is not in the set should be given.
270
+ *
271
+ * Nested under `alternates` rather than sitting at the top level as
272
+ * `languages`, because `alternate` is the link relation and a language is
273
+ * only one kind of alternate; the outer name is a fact about the wire rather
274
+ * than a shape invented here.
275
+ */
276
+ readonly alternates?: {
277
+ readonly languages?: { readonly [string]: string },
278
+ },
279
+ /**
280
+ * The pages either side of this one in a sequence.
281
+ *
282
+ * `<link rel="prev">` and `<link rel="next">`, resolved against
283
+ * `metadataBase` like every other URL here. A page four of a list, and a
284
+ * chapter in the middle of a manual, are the same statement: this document
285
+ * is one of a series and here is where the series continues.
286
+ *
287
+ * `canonical` still belongs to the page itself. Pointing every page of a
288
+ * paginated list at page one is the mistake this pair exists to make
289
+ * unnecessary — it tells a search engine that pages two onwards are
290
+ * duplicates of page one, and everything only reachable from them stops
291
+ * being reachable at all.
292
+ */
293
+ readonly pagination?: {
294
+ readonly prev?: string,
295
+ readonly next?: string,
296
+ },
297
+ /**
298
+ * Structured data, as JSON-LD.
299
+ *
300
+ * One `<script type="application/ld+json">` per entry. Unlike everything
301
+ * else here it *accumulates* down the tree rather than being replaced by the
302
+ * nearest declaration: an `Organization` on the root layout and an `Article`
303
+ * on the page are two statements about one page, not two answers to one
304
+ * question, and replacing would mean a page that describes itself silently
305
+ * deletes the site's description of itself.
306
+ *
307
+ * The scripts are rendered with the rest of the route rather than hoisted
308
+ * into `<head>`, because React hoists `<title>`, `<meta>` and `<link>` and
309
+ * not a script it has to keep the body of. JSON-LD is read from anywhere in
310
+ * the document, so this costs nothing; it is worth knowing when reading the
311
+ * markup.
312
+ */
313
+ readonly jsonLd?: $ReadOnlyArray<JsonLd>,
169
314
  readonly openGraph?: {
315
+ /**
316
+ * The title a share card shows.
317
+ *
318
+ * Falls back to `title`, because a page that has said what it is called
319
+ * has said what its card is called — and a site made to write it twice
320
+ * writes it twice once and then lets them drift.
321
+ */
170
322
  readonly title?: string,
323
+ /** The description a share card shows. Falls back to `description`. */
171
324
  readonly description?: string,
325
+ /**
326
+ * The Open Graph object type. `website` unless a page says otherwise.
327
+ *
328
+ * Defaulted rather than omitted because `og:type` is one of the four
329
+ * properties Open Graph requires, and a document without it is not an
330
+ * Open Graph document at all — so leaving it to every project to remember
331
+ * is leaving most of them without one.
332
+ */
333
+ readonly type?: string,
334
+ /**
335
+ * The name of the site the page belongs to, which a card prints above the
336
+ * title. Declared once on the root layout.
337
+ */
338
+ readonly siteName?: string,
172
339
  readonly images?: $ReadOnlyArray<string>,
340
+ /**
341
+ * What the card's image shows, for a reader who cannot see it.
342
+ *
343
+ * One description rather than one per image: a card shows one image, and
344
+ * the array exists so a site can offer a crawler a choice of sizes rather
345
+ * than so it can show several.
346
+ */
347
+ readonly imageAlt?: string,
173
348
  },
174
349
  readonly twitter?: {
175
350
  readonly card?: TwitterCard,
176
351
  readonly site?: string,
177
352
  readonly creator?: string,
353
+ /** Falls back to `openGraph.title`, and then to `title`. */
178
354
  readonly title?: string,
355
+ /** Falls back to `openGraph.description`, and then to `description`. */
179
356
  readonly description?: string,
180
357
  readonly images?: $ReadOnlyArray<string>,
358
+ /** Falls back to `openGraph.imageAlt`. */
359
+ readonly imageAlt?: string,
181
360
  },
182
361
  };
183
362
 
@@ -228,6 +407,26 @@ export type RouteRecord = {|
228
407
  * exactly what it had before.
229
408
  */
230
409
  readonly loading?: $ReadOnlyArray<LoadingRecord>,
410
+ /**
411
+ * The `_uf.template.js` wrappers this route renders inside, root first.
412
+ *
413
+ * Optional for the reason `loading` is: a table written before templates
414
+ * existed is still a table this router can render, and a route with no
415
+ * template renders exactly the tree it did before.
416
+ */
417
+ readonly templates?: $ReadOnlyArray<TemplateRecord>,
418
+ |};
419
+
420
+ /**
421
+ * One `_uf.template.js`, as the route table carries it.
422
+ *
423
+ * The same shape as [`LoadingRecord`] and the same `above`, because it answers
424
+ * the same question — where in the stack of layouts this thing sits — and
425
+ * there is no second vocabulary for it.
426
+ */
427
+ export type TemplateRecord = {|
428
+ readonly above: number,
429
+ readonly module: () => Promise<TemplateModule>,
231
430
  |};
232
431
 
233
432
  /**
@@ -256,7 +455,19 @@ export type NotFoundBoundary = {|
256
455
  readonly path: string,
257
456
  readonly mdx: boolean,
258
457
  readonly file: string,
259
- readonly page: () => Promise<PageModule>,
458
+ /**
459
+ * The page this boundary renders — `null` for the one the build synthesises
460
+ * at the router root when a project declares no `_uf.not-found.js` there.
461
+ *
462
+ * A project that had declared none used to get `layouts: []` along with the
463
+ * framework's page: not the nearest-ancestor rule failing, but the fallback
464
+ * having no record to take layouts from. So the root's layouts are a record
465
+ * like any other, with the framework's component in place of a module to
466
+ * import — which is a nullable field rather than a second kind of answer, and
467
+ * is why an unmatched URL still arrives inside the site's own masthead. See
468
+ * ubugeeei-prod/uf#351.
469
+ */
470
+ readonly page: ?() => Promise<PageModule>,
260
471
  readonly layouts: $ReadOnlyArray<() => Promise<LayoutModule>>,
261
472
  |};
262
473
 
@@ -271,7 +482,8 @@ export type NotFoundBoundary = {|
271
482
  export type ErrorBoundary = {|
272
483
  readonly path: string,
273
484
  readonly file: string,
274
- readonly module: () => Promise<ErrorModule>,
485
+ /** `null` for the synthesised root record; see [`NotFoundBoundary`]`.page`. */
486
+ readonly module: ?() => Promise<ErrorModule>,
275
487
  readonly layouts: $ReadOnlyArray<() => Promise<LayoutModule>>,
276
488
  |};
277
489
 
@@ -324,8 +536,8 @@ export function routeErrorStatus(error: RouteError): 401 | 403 | 500 {
324
536
  }
325
537
 
326
538
  /**
327
- * A match whose modules are loaded and whose loader has run — or, when `error`
328
- * is set, the error page that stands in for it.
539
+ * A match whose modules are loaded and whose loader has run or is running — or,
540
+ * when `error` is set, the error page that stands in for it.
329
541
  */
330
542
  export type ResolvedRoute = {|
331
543
  readonly pathname: string,
@@ -335,8 +547,34 @@ export type ResolvedRoute = {|
335
547
  readonly searchParams: SearchParams,
336
548
  readonly page: PageModule,
337
549
  readonly layouts: $ReadOnlyArray<LayoutModule>,
550
+ /** What the loader returned, once it has. `undefined` while `deferred` is set. */
338
551
  readonly data: mixed,
552
+ /**
553
+ * The loader still running, when the router handed the page its promise
554
+ * rather than its value. `null` on every other path, which is most of them.
555
+ *
556
+ * Two fields rather than a `data` that is sometimes a promise, because a
557
+ * loader is free to return something with a `then` on it and no duck test
558
+ * could tell that apart from a deferral. This one is the router's own answer
559
+ * to a question the router asked, so it says so.
560
+ *
561
+ * Set only by a streaming render of a route that declares a
562
+ * `_uf.loading.js` and generates no metadata from its data — the two
563
+ * conditions under which deferring buys anything and costs nothing that was
564
+ * not already spent. [`resolveRoute`] is where that is decided and argued.
565
+ */
566
+ readonly deferred: ?Promise<mixed>,
339
567
  readonly metadata: Metadata,
568
+ /**
569
+ * What a stylesheet calls the transition this route arrives under, or `null`
570
+ * when neither the page nor a layout above it named one.
571
+ *
572
+ * Resolved with the route rather than looked up at the moment of the
573
+ * navigation, because by then the answer is a property of the destination's
574
+ * modules and those are exactly what has just been loaded. A server render
575
+ * carries it and never reads it; see "View transitions".
576
+ */
577
+ readonly viewTransition: ?string,
340
578
  readonly status: 200 | 401 | 403 | 404 | 500,
341
579
  /**
342
580
  * Set when this resolution *is* the error page: the loader threw, or the
@@ -367,6 +605,20 @@ export type ResolvedRoute = {|
367
605
  * ordinary case and renders exactly the tree it did before.
368
606
  */
369
607
  readonly loading: $ReadOnlyArray<{| readonly above: number, readonly module: LoadingModule |}>,
608
+ /**
609
+ * The templates around this route, root first, already imported.
610
+ *
611
+ * Empty for a route with no `_uf.template.js` above it, which is the
612
+ * ordinary case and renders exactly the tree it did before templates
613
+ * existed. Empty too on a resolution that *is* a boundary — a not-found or
614
+ * an error page — for the reason its `loading` is: those are matched rather
615
+ * than walked to, and templates are accumulated on the walk down to a route
616
+ * the URL never reached.
617
+ */
618
+ readonly templates: $ReadOnlyArray<{|
619
+ readonly above: number,
620
+ readonly module: TemplateModule,
621
+ |}>,
370
622
  |};
371
623
 
372
624
  /** Thrown by `notFound()`; the renderer answers with the not-found page. */
@@ -638,11 +890,24 @@ function loadOnce<T>(load: () => Promise<T>): Promise<T> {
638
890
  * before `hydrateRoot`, so a rejection there is not an error page — it is no
639
891
  * `hydrateRoot` call at all, and the document the server sent stays on screen
640
892
  * with nothing attached to it.
893
+ *
894
+ * # `onMatch`
895
+ *
896
+ * Called with the route pattern the moment the URL matches one, before any
897
+ * module is imported and before the loader runs. It exists because the server
898
+ * has something to do with that fact and does it too late otherwise: the route
899
+ * a request turned out to be is what its log line carries, and a loader is
900
+ * inside this call, so a server that recorded the route after this resolved
901
+ * would have every line a loader wrote saying it belonged to no route.
902
+ *
903
+ * A callback rather than a return value because both callers already have one
904
+ * — the pattern is on the `ResolvedRoute` this hands back — and only one of
905
+ * them needs it *early*. The browser passes nothing and pays nothing.
641
906
  */
642
907
  export async function resolveMatch(
643
908
  table: RouteTable,
644
909
  url: string,
645
- options?: {| readonly data?: mixed, readonly skipLoader?: boolean |},
910
+ options?: ResolveOptions,
646
911
  ): Promise<ResolvedRoute> {
647
912
  try {
648
913
  return await resolveRoute(table, url, options);
@@ -654,15 +919,52 @@ export async function resolveMatch(
654
919
  }
655
920
  }
656
921
 
922
+ /** What a caller may tell [`resolveMatch`] about the resolution it wants. */
923
+ export type ResolveOptions = {|
924
+ /** The loader's answer, already in hand — the value the server embedded. */
925
+ readonly data?: mixed,
926
+ /** Do not run the loader at all; `data` is the answer. */
927
+ readonly skipLoader?: boolean,
928
+ /**
929
+ * Whether the caller can render a route whose loader has not answered yet.
930
+ *
931
+ * Only a streaming server render can, and that is the whole of why this is
932
+ * a caller's choice rather than the router's. `createRenderer`'s `render`
933
+ * sends a `<Suspense>` fallback now and the content when it arrives, so
934
+ * deferring is what turns a slow loader from a delay before the first byte
935
+ * into a fallback the reader is already looking at.
936
+ *
937
+ * Nothing else is in that position, and each for its own reason. `prerender`
938
+ * writes a file, which has no first paint to improve and no reader to show a
939
+ * fallback to. `hydrate` has the server's answer already. A client
940
+ * navigation has a page on screen that stays interactive while the next one
941
+ * resolves, which is the browser's version of the same idea and does not
942
+ * need this one.
943
+ *
944
+ * It costs the loader its say in the response: a status is decided when the
945
+ * shell goes out, so a deferred `notFound()` reaches the error boundary
946
+ * rather than the 404 page, and the document is a 200. That is inherent to
947
+ * streaming rather than a shortcut — the bytes have gone — and it is the
948
+ * reason this is off unless a caller asks.
949
+ */
950
+ readonly defer?: boolean,
951
+ /** The route pattern, the moment the URL matches one; see [`resolveMatch`]. */
952
+ readonly onMatch?: (pattern: string) => void,
953
+ |};
954
+
657
955
  async function resolveRoute(
658
956
  table: RouteTable,
659
957
  url: string,
660
- options?: {| readonly data?: mixed, readonly skipLoader?: boolean |},
958
+ options?: ResolveOptions,
661
959
  ): Promise<ResolvedRoute> {
662
960
  const { pathname, search } = splitUrl(url);
663
961
  const searchParams = parseSearch(search);
664
962
  const matched = matchRoute(table.routes, pathname);
665
963
 
964
+ if (matched != null) {
965
+ options?.onMatch?.(matched.route.path);
966
+ }
967
+
666
968
  if (matched == null) {
667
969
  return resolveNotFound(table, pathname, search, searchParams);
668
970
  }
@@ -690,22 +992,44 @@ async function resolveRoute(
690
992
  // Started alongside for the same reason, and awaited at the end: a fallback
691
993
  // depends on nothing the loader produces.
692
994
  const loading = resolveLoading(matched.route, matched.route.layouts.length);
693
-
995
+ const templates = resolveTemplates(matched.route, matched.route.layouts.length);
996
+
997
+ // The loader, run here and awaited below — or not awaited at all.
998
+ //
999
+ // A page that suspends while *rendering* has always streamed; a page waiting
1000
+ // on its loader could not, because this function awaited the loader before it
1001
+ // returned and by the time React saw the tree the data was already in hand.
1002
+ // The fallback beside such a page showed for zero milliseconds, which made
1003
+ // `_uf.loading.js` useful for the one case a page usually is not slow for.
1004
+ //
1005
+ // Two things stand in the way of simply not awaiting, and both are about the
1006
+ // document rather than about the route. Metadata goes in the head and the
1007
+ // head is written before the body, so a title computed from the data
1008
+ // genuinely cannot be deferred — that is a rule worth stating rather than a
1009
+ // limitation to hide, and it is the `generateMetadata` half of the condition
1010
+ // below. The other is that a route with no `<Suspense>` above it has nothing
1011
+ // to defer *into*: React holds the whole shell for a page that suspends with
1012
+ // no boundary, which is the same wait by another name, with an unresolved
1013
+ // promise flowing through the tree for nothing. So the loader is deferred
1014
+ // exactly when there is a boundary to defer it into.
1015
+ //
1016
+ // See ubugeeei-prod/uf#373, and `ResolveOptions.defer` for who asks.
694
1017
  let data: mixed = options?.data;
1018
+ let deferred: ?Promise<mixed> = null;
695
1019
  if (options?.skipLoader !== true && typeof page.loader === "function") {
696
- // Awaited here, so a route's time to first byte is still its slowest
697
- // loader. A page that suspends while *rendering* streams — that is what the
698
- // `<Suspense>` boundaries below are for — but a page waiting on its loader
699
- // has already waited by the time React sees the tree, so its fallback shows
700
- // for no time at all.
701
- //
702
- // Deferring it means handing the page a promise and unwrapping it inside
703
- // the boundary, and the obstacle is not the awaiting: it is that
704
- // `generateMetadata` reads `data` and metadata goes in the head, and that
705
- // the loader data is embedded in the head too, for hydration. Both are
706
- // decisions about the document rather than about the route.
707
- // ubugeeei-prod/uf#373 has the design.
708
- data = await page.loader({ params: matched.params, searchParams, pathname });
1020
+ const running = page.loader({ params: matched.params, searchParams, pathname });
1021
+ const canDefer =
1022
+ options?.defer === true &&
1023
+ (matched.route.loading ?? []).length > 0 &&
1024
+ typeof page.generateMetadata !== "function";
1025
+ if (canDefer) {
1026
+ // `Promise.resolve`, because a loader may return a plain value and `use`
1027
+ // wants a promise either way. A loader that answered without waiting
1028
+ // costs one microtask and renders in the same pass.
1029
+ deferred = Promise.resolve(running);
1030
+ } else {
1031
+ data = await running;
1032
+ }
709
1033
  }
710
1034
 
711
1035
  const metadata = await resolveMetadata(page, layouts, {
@@ -722,14 +1046,52 @@ async function resolveRoute(
722
1046
  page,
723
1047
  layouts,
724
1048
  data,
1049
+ deferred,
725
1050
  metadata,
1051
+ viewTransition: resolveViewTransition(page, layouts),
726
1052
  status: 200,
727
1053
  error: null,
728
1054
  errorBoundary: await boundary,
729
1055
  loading: await loading,
1056
+ templates: await templates,
730
1057
  };
731
1058
  }
732
1059
 
1060
+ /**
1061
+ * The route's templates, imported.
1062
+ *
1063
+ * A template that will not load is dropped, the way a fallback is: it is a
1064
+ * wrapper around the page, not the page, so a broken wrapper must not become a
1065
+ * broken route. The tree renders without it — the page keeps the layout it was
1066
+ * inside, and loses only the remount — and the import error surfaces where it
1067
+ * belongs, when the module is next asked for.
1068
+ */
1069
+ async function resolveTemplates(
1070
+ route: RouteRecord,
1071
+ layoutCount: number,
1072
+ ): Promise<$ReadOnlyArray<{| readonly above: number, readonly module: TemplateModule |}>> {
1073
+ const records = route.templates ?? [];
1074
+ if (records.length === 0) {
1075
+ return [];
1076
+ }
1077
+ const loaded = await Promise.all(
1078
+ records.map(async (record) => {
1079
+ try {
1080
+ return {
1081
+ // Clamped exactly as the error and loading boundaries' are: a
1082
+ // `(group)` directory can leave a route with fewer layouts than the
1083
+ // template declared above it.
1084
+ above: Math.min(record.above, layoutCount),
1085
+ module: await loadOnce(record.module),
1086
+ };
1087
+ } catch {
1088
+ return null;
1089
+ }
1090
+ }),
1091
+ );
1092
+ return loaded.filter(Boolean);
1093
+ }
1094
+
733
1095
  /**
734
1096
  * The route's loading boundaries, imported.
735
1097
  *
@@ -840,13 +1202,22 @@ async function resolveErrorBoundary(
840
1202
  // the boundary covering it, and an `above` past the end would compose the
841
1203
  // layouts out of nothing.
842
1204
  const above = Math.min(boundary.layouts.length, layoutCount);
1205
+ const load = boundary.module;
1206
+ // The synthesised root record, which names layouts and no module: the
1207
+ // framework's page renders, and `above` still says where — inside the site's
1208
+ // own layouts rather than outside everything. See [`NotFoundBoundary`]`.page`.
1209
+ if (load == null) {
1210
+ return { module: null, above };
1211
+ }
843
1212
  try {
844
- return { module: await loadOnce(boundary.module), above };
1213
+ return { module: await loadOnce(load), above };
845
1214
  } catch {
846
1215
  // A boundary whose module will not load cannot be the answer to a throw,
847
1216
  // and this is why the field is nullable: containment must not itself
848
- // depend on an import working.
849
- return { module: null, above: 0 };
1217
+ // depend on an import working. The depth is kept, because the layouts the
1218
+ // boundary named are still there and the framework's page is better inside
1219
+ // them than outside them.
1220
+ return { module: null, above };
850
1221
  }
851
1222
  }
852
1223
 
@@ -869,11 +1240,15 @@ async function resolveError(
869
1240
  let module: ?ErrorModule = null;
870
1241
  let layouts: $ReadOnlyArray<LayoutModule> = [];
871
1242
  if (boundary != null) {
1243
+ const load = boundary.module;
872
1244
  try {
873
- [module, layouts] = await Promise.all([
874
- loadOnce(boundary.module),
875
- Promise.all(boundary.layouts.map((layout) => loadOnce(layout))),
876
- ]);
1245
+ // The layouts whether or not there is a module, because the synthesised
1246
+ // root record has layouts and no module and its whole purpose is that
1247
+ // the framework's error page renders inside them: a site whose root
1248
+ // layout owns the masthead and the stylesheet answered a 500 with
1249
+ // neither. See ubugeeei-prod/uf#351.
1250
+ layouts = await Promise.all(boundary.layouts.map((layout) => loadOnce(layout)));
1251
+ module = load == null ? null : await loadOnce(load);
877
1252
  } catch {
878
1253
  // See `resolveErrorBoundary`: the framework's own page answers instead.
879
1254
  module = null;
@@ -895,7 +1270,12 @@ async function resolveError(
895
1270
  page: { default: ResolvedErrorPage },
896
1271
  layouts,
897
1272
  data: undefined,
1273
+ deferred: null,
898
1274
  metadata: declared.title != null ? declared : { ...declared, title: errorTitle(routeError) },
1275
+ // The boundary's own layouts may name one; the page cannot, because the
1276
+ // page here is this module's. An error arriving under the section's
1277
+ // transition is the same answer as a page arriving under it.
1278
+ viewTransition: resolveViewTransition({}, layouts),
899
1279
  status: routeErrorStatus(routeError),
900
1280
  error: routeError,
901
1281
  // All of the boundary's layouts are above it, and no inner boundary is
@@ -905,6 +1285,7 @@ async function resolveError(
905
1285
  // resolved with. A fallback around it would be a boundary that can never
906
1286
  // show, which is worse than none.
907
1287
  loading: [],
1288
+ templates: [],
908
1289
  };
909
1290
  }
910
1291
 
@@ -919,6 +1300,21 @@ async function resolveError(
919
1300
  * layouts *below* the boundary — so `app/guide/[slug]/_uf.layout.js` would
920
1301
  * wrap a 404 that `app/guide/_uf.not-found.js` answered, which is the layout
921
1302
  * of the page that just said it does not exist.
1303
+ *
1304
+ * # The record with no page
1305
+ *
1306
+ * A project that declares no `_uf.not-found.js` anywhere still has a record —
1307
+ * the one the build synthesises for the router root — and it names the root's
1308
+ * layouts and no module. Before that record existed this function answered
1309
+ * with `layouts: []`, so a site whose root layout owns the masthead, the
1310
+ * stylesheet and often `<html>` itself answered an unmatched URL with a white
1311
+ * page carrying `404` and no way to leave it. That was not the nearest-ancestor
1312
+ * rule failing; it was the fallback having no record to take layouts from, and
1313
+ * giving it one is the whole of ubugeeei-prod/uf#351.
1314
+ *
1315
+ * The framework's page then merges its title over the layouts' metadata like
1316
+ * any page would, so a `metadataBase` or an `og:site_name` declared on the root
1317
+ * layout still applies to the 404.
922
1318
  */
923
1319
  async function resolveNotFound(
924
1320
  table: RouteTable,
@@ -927,26 +1323,12 @@ async function resolveNotFound(
927
1323
  searchParams: SearchParams,
928
1324
  ): Promise<ResolvedRoute> {
929
1325
  const record = nearestBoundary(table.notFound, pathname);
930
- if (record == null) {
931
- return {
932
- pathname,
933
- search,
934
- path: "*",
935
- params: {},
936
- searchParams,
937
- page: { default: DefaultNotFound },
938
- layouts: [],
939
- data: undefined,
940
- metadata: { title: "Not found" },
941
- status: 404,
942
- error: null,
943
- errorBoundary: await resolveErrorBoundary(table, pathname, 0),
944
- loading: [],
945
- };
946
- }
1326
+ const load = record?.page;
947
1327
  const [page, ...layouts] = await Promise.all([
948
- loadOnce(record.page),
949
- ...record.layouts.map((layout) => loadOnce(layout)),
1328
+ load == null
1329
+ ? Promise.resolve<PageModule>({ default: DefaultNotFound, metadata: { title: "Not found" } })
1330
+ : loadOnce(load),
1331
+ ...(record?.layouts ?? []).map((layout) => loadOnce(layout)),
950
1332
  ]);
951
1333
  const metadata = await resolveMetadata(page, layouts, {
952
1334
  params: {},
@@ -962,7 +1344,9 @@ async function resolveNotFound(
962
1344
  page,
963
1345
  layouts,
964
1346
  data: undefined,
1347
+ deferred: null,
965
1348
  metadata,
1349
+ viewTransition: resolveViewTransition(page, layouts),
966
1350
  status: 404,
967
1351
  error: null,
968
1352
  // A not-found page is a page: one that throws is contained like any other.
@@ -971,18 +1355,36 @@ async function resolveNotFound(
971
1355
  // record and the loading files are a property of the route that was walked
972
1356
  // to, which this URL never reached. Nothing to wait for, so no boundary.
973
1357
  loading: [],
1358
+ // Templates are accumulated on that same walk, and for the same reason.
1359
+ templates: [],
974
1360
  };
975
1361
  }
976
1362
 
1363
+ /**
1364
+ * The route's metadata: each declaration merged over the ones outside it.
1365
+ *
1366
+ * Per key, so a page that declares only `canonical` keeps the title its layout
1367
+ * set — with one exception, and it is deliberate. `jsonLd` is gathered along
1368
+ * the way instead of merged, because a nearer declaration of it is an addition
1369
+ * rather than a correction; [`Metadata`] has the argument.
1370
+ */
977
1371
  async function resolveMetadata(
978
1372
  page: PageModule,
979
1373
  layouts: $ReadOnlyArray<LayoutModule>,
980
1374
  args: MetadataArgs,
981
1375
  ): Promise<Metadata> {
982
1376
  let merged: Metadata = {};
1377
+ let structured: $ReadOnlyArray<JsonLd> = [];
1378
+ const take = (declared: Metadata) => {
1379
+ if (declared.jsonLd != null) {
1380
+ structured = [...structured, ...declared.jsonLd];
1381
+ }
1382
+ merged = { ...merged, ...declared };
1383
+ };
1384
+
983
1385
  for (const layout of layouts) {
984
1386
  if (layout.metadata != null) {
985
- merged = { ...merged, ...layout.metadata };
1387
+ take(layout.metadata);
986
1388
  }
987
1389
  }
988
1390
  if (page.frontmatter != null) {
@@ -994,12 +1396,42 @@ async function resolveMetadata(
994
1396
  };
995
1397
  }
996
1398
  if (page.metadata != null) {
997
- merged = { ...merged, ...page.metadata };
1399
+ take(page.metadata);
998
1400
  }
999
1401
  if (typeof page.generateMetadata === "function") {
1000
- merged = { ...merged, ...(await page.generateMetadata(args)) };
1402
+ take(await page.generateMetadata(args));
1001
1403
  }
1002
- return merged;
1404
+ return structured.length === 0 ? merged : { ...merged, jsonLd: structured };
1405
+ }
1406
+
1407
+ /** A module that may name the transition its route arrives under. */
1408
+ type Transitioning = { readonly viewTransition?: string, ... };
1409
+
1410
+ /**
1411
+ * What a stylesheet calls this route's arrival: the nearest declaration wins.
1412
+ *
1413
+ * The same walk `resolveMetadata` does one function above, and stated as its
1414
+ * own function rather than folded into that one because the two answer
1415
+ * different questions and only one of them is a document. Layouts are root
1416
+ * first, so overwriting as it descends leaves the innermost, and the page has
1417
+ * the last word.
1418
+ *
1419
+ * The parameters say what is read rather than naming `PageModule` and
1420
+ * `LayoutModule`, which is the shape `nearestBoundary` already takes for the
1421
+ * same reason: this reads one optional field, so requiring the whole of either
1422
+ * type would be a claim it does not need and cannot use.
1423
+ */
1424
+ function resolveViewTransition(
1425
+ page: Transitioning,
1426
+ layouts: $ReadOnlyArray<Transitioning>,
1427
+ ): ?string {
1428
+ let name: ?string = null;
1429
+ for (const layout of layouts) {
1430
+ if (layout.viewTransition != null) {
1431
+ name = layout.viewTransition;
1432
+ }
1433
+ }
1434
+ return page.viewTransition ?? name;
1003
1435
  }
1004
1436
 
1005
1437
  component DefaultNotFound() {
@@ -1163,12 +1595,178 @@ class RouteErrorBoundary extends React.Component<RouteErrorBoundaryProps, RouteE
1163
1595
  }
1164
1596
  }
1165
1597
 
1598
+ // ---------------------------------------------------------------------------
1599
+ // View transitions
1600
+ // ---------------------------------------------------------------------------
1601
+ //
1602
+ // A client navigation replaces the tree and the browser paints the new one,
1603
+ // which is a cut. `document.startViewTransition` is the platform's answer, and
1604
+ // it is opt-in per navigation rather than per site — so somebody has to call
1605
+ // it, and the somebody is whatever replaced the tree. That is this module.
1606
+ // Leaving it to the application would mean every application reimplementing
1607
+ // the same four decisions below, and getting the last one wrong.
1608
+ //
1609
+ // # Why `flushSync` rather than `startTransition`
1610
+ //
1611
+ // The browser captures the old frame, calls the callback, and waits on the
1612
+ // promise the callback returns before capturing the new one. So the callback
1613
+ // has to leave the DOM updated, and `startTransition` deliberately does not:
1614
+ // it schedules, and returns having changed nothing.
1615
+ //
1616
+ // The alternative was to hand the browser a promise resolved from a layout
1617
+ // effect after the commit, which keeps the render concurrent and can hang: a
1618
+ // running view transition blocks input until its callback settles, so a commit
1619
+ // React decides not to make — an interrupted transition, an unmounted provider
1620
+ // — is a frozen page with no way back. `flushSync` cannot hang.
1621
+ //
1622
+ // The cost is real and worth stating rather than discovering. Inside a
1623
+ // transition the commit is synchronous, so a route that suspends *while
1624
+ // rendering* shows its `_uf.loading.js` fallback instead of leaving the
1625
+ // previous page up until it resolves. Its modules and its loader are already
1626
+ // finished by this point — `resolveMatch` awaited both — so what is left is a
1627
+ // component suspending on something else, and it degrades to the fallback the
1628
+ // project wrote for exactly that.
1629
+ //
1630
+ // # Why not React's `<ViewTransition>`
1631
+ //
1632
+ // It is not in a stable React. This package's peer range is `react >= 19`, and
1633
+ // reaching for a component that exists only in an experimental build would
1634
+ // turn an animation into a reason a project cannot use the router at all.
1635
+ // `startViewTransition` is the same feature one layer down, and it is in the
1636
+ // browser rather than in a dependency.
1637
+ //
1638
+ // # What must not change
1639
+ //
1640
+ // A browser without `startViewTransition` navigates exactly as it did before
1641
+ // any of this. A reader who asked for less motion gets the cut they asked for,
1642
+ // without the application having to remember to ask on their behalf. And the
1643
+ // server renders nothing about it: a transition is a client-only concern, and
1644
+ // the moment one reaches the markup it is a hydration difference instead.
1645
+
1646
+ /**
1647
+ * The attribute a running transition's name reaches CSS through.
1648
+ *
1649
+ * On the document element, because that is where the `::view-transition`
1650
+ * pseudo-elements hang and therefore the only element a selector can reach
1651
+ * them from.
1652
+ */
1653
+ const VIEW_TRANSITION_ATTRIBUTE = "data-uf-view-transition";
1654
+
1655
+ /**
1656
+ * The part of a running transition this module reads.
1657
+ *
1658
+ * One property, because one is what a navigation needs: `finished` settles
1659
+ * when the animation is over, which is when the document may stop saying which
1660
+ * transition is running. `ready` and `updateCallbackDone` are for an
1661
+ * application animating something itself, and a router holding them would be
1662
+ * claiming to know what they were for.
1663
+ */
1664
+ type ViewTransition = { readonly finished: Promise<mixed>, ... };
1665
+
1666
+ /**
1667
+ * The document, under the one description this module has of it.
1668
+ *
1669
+ * Flow's library definitions have no `startViewTransition` — the API is newer
1670
+ * than they are — and reading it off `any` would leave the one call that
1671
+ * performs a navigation unchecked, where a wrong type is a broken navigation
1672
+ * rather than a broken animation. Optional, because "this browser may not have
1673
+ * it" is the entire point.
1674
+ *
1675
+ * An `interface` rather than an object type, because a `Document` is a class
1676
+ * instance and class instances are not subtypes of object types. `documentElement`
1677
+ * is nullable for the same reason it is in Flow's own libdef: a document parsed
1678
+ * from nothing has no root element.
1679
+ */
1680
+ interface ViewTransitionDocument {
1681
+ readonly startViewTransition?: (update: () => mixed) => ViewTransition;
1682
+ readonly documentElement: HTMLElement | null;
1683
+ }
1684
+
1685
+ /**
1686
+ * Whether the reader has asked for less motion.
1687
+ *
1688
+ * Asked at the moment of the navigation rather than subscribed to, because it
1689
+ * is not a rendered value: nothing re-renders when the preference changes, and
1690
+ * the only question is what to do with the click that just happened.
1691
+ * `usePrefersReducedMotion` in `@uniflowed/hooks` is the rendered form of the
1692
+ * same query and answers a different question.
1693
+ *
1694
+ * `matchMedia` is optional here because a document installed by a test runner
1695
+ * may not have one, and a media query that cannot be asked is not a reason to
1696
+ * fail a navigation.
1697
+ */
1698
+ function prefersReducedMotion(): boolean {
1699
+ const query = window.matchMedia?.("(prefers-reduced-motion: reduce)");
1700
+ return query != null && query.matches === true;
1701
+ }
1702
+
1703
+ /**
1704
+ * Apply `update`, inside a view transition where there is one to be had.
1705
+ *
1706
+ * Two ways out and they are one decision: with no `startViewTransition`, or
1707
+ * with a reader who asked for less motion, this is the `startTransition` the
1708
+ * router did before any of this existed — same commit, same concurrency, no
1709
+ * animation.
1710
+ *
1711
+ * `name` is the route's, and it reaches CSS as an attribute for as long as the
1712
+ * transition runs. The other spelling is the `types` option, which is the
1713
+ * platform's own vocabulary for the same idea and is *newer than
1714
+ * `startViewTransition` itself* — so passing the options object to a browser
1715
+ * that has only the callback form is a `TypeError` thrown out of the call that
1716
+ * performs the navigation. Naming a transition would then need a second and
1717
+ * finer feature detection than the one for having transitions at all, and the
1718
+ * cost of getting that one wrong is the navigation rather than the animation.
1719
+ * One attribute needs no detection and is removed again when the transition
1720
+ * ends.
1721
+ */
1722
+ function withViewTransition(name: ?string, update: () => void): void {
1723
+ const owner: ViewTransitionDocument = document;
1724
+ const start = owner.startViewTransition?.bind(owner);
1725
+ if (start == null || prefersReducedMotion()) {
1726
+ startTransition(update);
1727
+ return;
1728
+ }
1729
+
1730
+ const root = owner.documentElement;
1731
+ if (name != null && root != null) {
1732
+ root.setAttribute(VIEW_TRANSITION_ATTRIBUTE, name);
1733
+ }
1734
+ const ended = () => {
1735
+ if (name != null && root != null) {
1736
+ root.removeAttribute(VIEW_TRANSITION_ATTRIBUTE);
1737
+ }
1738
+ };
1739
+ // Both settlements do the same thing, and the rejection is not a failure:
1740
+ // `finished` rejects when the transition is skipped — a second navigation
1741
+ // before this one finished, a tab that went to the background — and a
1742
+ // skipped transition has still ended. Handling it is also what keeps a
1743
+ // routine interruption from being reported as an unhandled rejection.
1744
+ start(() => {
1745
+ flushSync(update);
1746
+ }).finished.then(ended, ended);
1747
+ }
1748
+
1166
1749
  // ---------------------------------------------------------------------------
1167
1750
  // The React binding
1168
1751
  // ---------------------------------------------------------------------------
1169
1752
 
1170
1753
  /** How a navigation is performed. */
1171
- export type NavigateOptions = {| readonly replace?: boolean, readonly scroll?: boolean |};
1754
+ export type NavigateOptions = {|
1755
+ readonly replace?: boolean,
1756
+ readonly scroll?: boolean,
1757
+ /**
1758
+ * Whether this navigation may animate. Defaults to `true`, which is what
1759
+ * every navigation does.
1760
+ *
1761
+ * `false` is how a caller says this one is a change of state rather than a
1762
+ * change of place — a tab within a page, a filter written into the query
1763
+ * string — and should be a cut. `true` does not *force* one: a browser
1764
+ * without `startViewTransition` and a reader who asked for less motion still
1765
+ * get the cut, because an application able to override the second would
1766
+ * eventually override it.
1767
+ */
1768
+ readonly transition?: boolean,
1769
+ |};
1172
1770
 
1173
1771
  /** What `useRouter()` returns. */
1174
1772
  export type Router = {|
@@ -1278,10 +1876,15 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
1278
1876
  } else {
1279
1877
  window.history.pushState(null, "", next + target.hash);
1280
1878
  }
1281
- startTransition(() => {
1879
+ const commit = () => {
1282
1880
  setResolved(nextResolved);
1283
1881
  setPending(false);
1284
- });
1882
+ };
1883
+ if (options?.transition === false) {
1884
+ startTransition(commit);
1885
+ } else {
1886
+ withViewTransition(nextResolved.viewTransition, commit);
1887
+ }
1285
1888
  if (options?.scroll !== false) {
1286
1889
  if (target.hash !== "") {
1287
1890
  const element = document.getElementById(target.hash.slice(1));
@@ -1313,7 +1916,10 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
1313
1916
  return;
1314
1917
  }
1315
1918
  resolveMatch(routeTable(), next).then((nextResolved) => {
1316
- startTransition(() => {
1919
+ // The back button is a navigation, and a navigation that animates in
1920
+ // one direction and cuts in the other would read as a bug in the
1921
+ // animation rather than as a decision.
1922
+ withViewTransition(nextResolved.viewTransition, () => {
1317
1923
  setResolved(nextResolved);
1318
1924
  });
1319
1925
  });
@@ -1351,6 +1957,10 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
1351
1957
  routeTable(),
1352
1958
  window.location.pathname + window.location.search,
1353
1959
  );
1960
+ // No view transition, and it is the one place that is right: a refresh
1961
+ // is the same URL resolved again, so a transition would animate a page
1962
+ // into itself — a cross-fade between two frames of the same thing,
1963
+ // which is a flicker with a name.
1354
1964
  startTransition(() => {
1355
1965
  setResolved(nextResolved);
1356
1966
  });
@@ -1394,11 +2004,32 @@ export hook useRoute(): RouteInfo {
1394
2004
  pathname: resolved.pathname,
1395
2005
  params: resolved.params,
1396
2006
  searchParams: resolved.searchParams,
1397
- data: resolved.data,
2007
+ data: useResolvedData(resolved),
1398
2008
  pending,
1399
2009
  };
1400
2010
  }
1401
2011
 
2012
+ /**
2013
+ * The loader's answer, waiting for it if the router deferred it.
2014
+ *
2015
+ * Both hooks that expose the data go through here, and both therefore suspend
2016
+ * when the answer is not in yet. That is the conservative choice rather than
2017
+ * the clever one: the alternative is handing back `undefined` for a value that
2018
+ * is on its way, which is a page reading a field that is about to exist and
2019
+ * finding nothing there, with nothing anywhere to say why.
2020
+ *
2021
+ * Suspending costs a caller *above* the innermost `<Suspense>` — a layout, a
2022
+ * masthead — the streaming it would otherwise have got, because React holds the
2023
+ * shell for a component that suspends with no boundary above it. That is
2024
+ * exactly what such a route did before the loader could be deferred at all, so
2025
+ * it is a benefit not taken rather than a regression, and it is visible: the
2026
+ * fallback does not appear.
2027
+ */
2028
+ hook useResolvedData(resolved: ResolvedRoute): mixed {
2029
+ const loader = resolved.deferred;
2030
+ return loader == null ? resolved.data : use(loader);
2031
+ }
2032
+
1402
2033
  /** Navigation. */
1403
2034
  export hook useRouter(): Router {
1404
2035
  return useRouterState().router;
@@ -1425,7 +2056,7 @@ export hook useRouter(): Router {
1425
2056
  * same file, keyed by route. Until it is there, this says what is true.
1426
2057
  */
1427
2058
  export hook useLoaderData(): mixed {
1428
- return useRouterState().resolved.data;
2059
+ return useResolvedData(useRouterState().resolved);
1429
2060
  }
1430
2061
 
1431
2062
  /**
@@ -1467,14 +2098,26 @@ export hook useLoaderData(): mixed {
1467
2098
  * The error boundary goes *outside* the fallback at the same depth. A throw
1468
2099
  * while the page is resolving has to reach a boundary that is still mounted,
1469
2100
  * and the `<Suspense>` is part of what the throw came out of.
2101
+ *
2102
+ * # Where the templates go
2103
+ *
2104
+ * Inside their own segment's layout and outside everything else at that depth
2105
+ * — the error boundary, the fallback and the page — which is what makes a
2106
+ * template's remount mean "this segment and what is under it" and a layout's
2107
+ * persistence mean "this segment's frame". The two files are the same wrapper
2108
+ * with opposite answers to one question, so they are one line apart here, and
2109
+ * the whole of the difference is the `key` — see [`insideTemplates`], which is
2110
+ * that line's other half.
1470
2111
  */
1471
2112
  export component RouteView() {
1472
2113
  const { resolved } = useRouterState();
1473
2114
  const { module, above } = resolved.errorBoundary;
1474
- const Page = pageComponent(resolved.page);
1475
- let element: React.Node = (
1476
- <Page params={resolved.params} searchParams={resolved.searchParams} data={resolved.data} />
1477
- );
2115
+ const loader = resolved.deferred;
2116
+ // The innermost element, so the `use` inside `AwaitedPage` suspends below
2117
+ // every boundary the loop below adds — which is what makes the layouts and
2118
+ // the fallback the shell rather than something waiting behind the loader.
2119
+ let element: React.Node =
2120
+ loader == null ? <RenderedPage data={resolved.data} /> : <AwaitedPage loader={loader} />;
1478
2121
 
1479
2122
  for (let depth = resolved.layouts.length; depth >= 0; depth -= 1) {
1480
2123
  // Backwards over a root-first list, so the deepest segment's fallback ends
@@ -1489,16 +2132,26 @@ export component RouteView() {
1489
2132
  const Fallback = loadingComponent(boundary.module);
1490
2133
  element = <Suspense fallback={<Fallback />}>{element}</Suspense>;
1491
2134
  }
2135
+ // Placed on `above` alone, and not on there being a module: a `null` one is
2136
+ // the framework's own error page, and where it renders is exactly the
2137
+ // question ubugeeei-prod/uf#351 asks. A project that declares no
2138
+ // `_uf.error.js` has the record the build synthesises for the router root,
2139
+ // whose `above` is the root's layouts — so the framework's page appears
2140
+ // inside the masthead rather than in place of the document. A table with no
2141
+ // record at all answers 0, which puts this boundary outside every layout,
2142
+ // where the outer one below already stood.
2143
+ //
1492
2144
  // Not around a route that already resolved to its error page: that page is
1493
2145
  // the boundary's own component, and wrapping it in the same boundary would
1494
2146
  // answer a throw inside it with itself.
1495
- if (depth === above && module != null && resolved.error == null) {
2147
+ if (depth === above && resolved.error == null) {
1496
2148
  element = (
1497
2149
  <RouteErrorBoundary module={module} resetKey={resolved.pathname}>
1498
2150
  {element}
1499
2151
  </RouteErrorBoundary>
1500
2152
  );
1501
2153
  }
2154
+ element = insideTemplates(element, resolved, depth);
1502
2155
  if (depth > 0) {
1503
2156
  const Layout = layoutComponent(resolved.layouts[depth - 1]);
1504
2157
  element = <Layout params={resolved.params}>{element}</Layout>;
@@ -1514,6 +2167,84 @@ export component RouteView() {
1514
2167
  );
1515
2168
  }
1516
2169
 
2170
+ /**
2171
+ * The page, with the loader's answer and the copy of it the browser hydrates
2172
+ * from.
2173
+ *
2174
+ * The two are rendered together because they are one fact told twice, and
2175
+ * anything that could put them out of step is a page whose first client render
2176
+ * disagrees with the document it was sent. Being one component is what keeps
2177
+ * the script in the same position in the tree on both sides — inside the
2178
+ * innermost `<Suspense>` when the route deferred its loader on the server, and
2179
+ * exactly there again on the client, where the data is already in hand and
2180
+ * nothing suspends at all.
2181
+ */
2182
+ component RenderedPage(data: mixed) {
2183
+ const { resolved } = useRouterState();
2184
+ const Page = pageComponent(resolved.page);
2185
+ return (
2186
+ <>
2187
+ <Page params={resolved.params} searchParams={resolved.searchParams} data={data} />
2188
+ {loaderDataScript(data)}
2189
+ </>
2190
+ );
2191
+ }
2192
+
2193
+ /**
2194
+ * The same page, once the loader the router deferred has answered.
2195
+ *
2196
+ * A component of its own rather than a `use` guarded by an `if` inside
2197
+ * [`RenderedPage`], so the call is unconditional where it is written: this one
2198
+ * is rendered only when there is a promise, and `RouteView` chooses between
2199
+ * them. `use` may legally be called conditionally, and code that reads as
2200
+ * though it may not is worth avoiding anyway.
2201
+ */
2202
+ component AwaitedPage(loader: Promise<mixed>) {
2203
+ return <RenderedPage data={use(loader)} />;
2204
+ }
2205
+
2206
+ /**
2207
+ * The loader's answer, embedded for the browser to hydrate from.
2208
+ *
2209
+ * In the tree rather than in the head, which is the third of the three options
2210
+ * ubugeeei-prod/uf#373 weighed and the only one that survives a deferred
2211
+ * loader. `server.js` wrote this into the head from the resolved route, and a
2212
+ * deferred answer does not exist when the head goes out — losing it would mean
2213
+ * every deferred route's loader running a second time in the browser, on the
2214
+ * way in, for data the document already contained.
2215
+ *
2216
+ * The two rejected options are worth naming. Writing it at the end of the body
2217
+ * from outside React would have worked — uf's client entry is a module script,
2218
+ * so it runs after parsing either way — but it would be markup inside the
2219
+ * hydration root that React did not render, which is the definition of a
2220
+ * mismatch. Emitting it through `bootstrapScriptContent` as a global is
2221
+ * React's own documented pattern and costs the one property this element has
2222
+ * that matters: `application/json` is data a browser does not execute, and a
2223
+ * script that is executed is a script a content security policy has to allow.
2224
+ *
2225
+ * `<` is escaped inside the JSON so a string holding `</script>` cannot end the
2226
+ * element early, and U+2028 and U+2029 because a JSON document is not
2227
+ * JavaScript source but is sometimes read as if it were.
2228
+ * `dangerouslySetInnerHTML` rather than a text child because React escapes a
2229
+ * text child and `&quot;` is not JSON any more. `security/no-dangerously-set-
2230
+ * inner-html` is about markup that came from somewhere and has to be sanitized
2231
+ * before a browser parses it as HTML; this is `JSON.stringify`'s output with
2232
+ * `<` escaped, in an element the browser never parses as HTML and never runs.
2233
+ * `docs/app/_uf.layout.js` carries the same suppression for the same reason.
2234
+ */
2235
+ function loaderDataScript(data: mixed): React.Node {
2236
+ if (data === undefined) {
2237
+ return null;
2238
+ }
2239
+ const json = JSON.stringify(data)
2240
+ .replace(/</g, "\\u003c")
2241
+ .replace(/\u2028/g, "\\u2028")
2242
+ .replace(/\u2029/g, "\\u2029");
2243
+ const html = { __html: json };
2244
+ // uf-lint-disable-next-line security/no-dangerously-set-inner-html
2245
+ return <script id={DATA_ID} type="application/json" dangerouslySetInnerHTML={html} />;
2246
+ }
2247
+
1517
2248
  /**
1518
2249
  * The component a page module renders: its default export, or the named
1519
2250
  * `Page` that `uf create` scaffolds. An MDX page always has a default export.
@@ -1546,6 +2277,58 @@ function loadingComponent(module: LoadingModule): React.ComponentType<{||}> {
1546
2277
  return renderable(component);
1547
2278
  }
1548
2279
 
2280
+ /**
2281
+ * `element`, wrapped in every template declared at `depth`.
2282
+ *
2283
+ * Outside the boundaries at that depth and inside the layout below it, and
2284
+ * backwards over a root-first list for the reason the fallbacks are: two
2285
+ * segments share a depth whenever the inner one declares no layout, and this
2286
+ * order is what keeps them nested the way the directories are.
2287
+ *
2288
+ * A function beside `RouteView` rather than a third loop inside it, and that
2289
+ * is not only for reading: a third nested loop assigning to `element` is what
2290
+ * the React Compiler's aliasing inference gave up on, and a component it
2291
+ * cannot compile is a component it does not memoise.
2292
+ */
2293
+ function insideTemplates(element: React.Node, resolved: ResolvedRoute, depth: number): React.Node {
2294
+ let out = element;
2295
+ for (let index = resolved.templates.length - 1; index >= 0; index -= 1) {
2296
+ const entry = resolved.templates[index];
2297
+ if (entry.above !== depth) {
2298
+ continue;
2299
+ }
2300
+ const Template = templateComponent(entry.module);
2301
+ // Keyed on the pathname, which is the whole difference between this file
2302
+ // and `_uf.layout.js`: React throws the subtree away and builds it again
2303
+ // whenever the key changes, and a navigation that changes only the query
2304
+ // string leaves it alone.
2305
+ out = (
2306
+ <Template key={resolved.pathname} params={resolved.params}>
2307
+ {out}
2308
+ </Template>
2309
+ );
2310
+ }
2311
+ return out;
2312
+ }
2313
+
2314
+ /**
2315
+ * The component a template module renders: `default`, or the named `Template`.
2316
+ *
2317
+ * The same props a layout receives, because it is a layout in every way but
2318
+ * one: it wraps `children`, it may read the route's parameters, and the only
2319
+ * difference is that `RouteView` gives the element a `key` so React builds it
2320
+ * again on every navigation.
2321
+ */
2322
+ function templateComponent(module: TemplateModule): React.ComponentType<LayoutRenderProps> {
2323
+ const component = module.default ?? module.Template;
2324
+ if (component == null) {
2325
+ throw new Error(
2326
+ "@uniflowed/router: a template module must export a component as `default` or `Template`",
2327
+ );
2328
+ }
2329
+ return renderable(component);
2330
+ }
2331
+
1549
2332
  /** The component a layout module renders: `default`, or the named `Layout`. */
1550
2333
  function layoutComponent(module: LayoutModule): React.ComponentType<LayoutRenderProps> {
1551
2334
  const component = module.default ?? module.Layout;
@@ -1607,46 +2390,227 @@ function absoluteUrl(value: string, base: void | string): string {
1607
2390
  }
1608
2391
  }
1609
2392
 
2393
+ /**
2394
+ * The `robots` directives, as one `content` string, or `null` for none.
2395
+ *
2396
+ * `null` rather than an empty string, so a page that declared nothing gets no
2397
+ * tag at all: "index, follow" is what a document with no `robots` meta already
2398
+ * means, and writing it out tells a crawler what it had already assumed.
2399
+ *
2400
+ * Each declared field contributes its directive and no field implies another.
2401
+ * `index: true` therefore emits `index` rather than nothing — the value is
2402
+ * there to overrule a section that said otherwise, and a directive that
2403
+ * disappeared because it agreed with the default would be a page saying
2404
+ * something and no evidence of it in the markup.
2405
+ */
2406
+ function robotsContent(robots: void | Robots): ?string {
2407
+ if (robots == null) {
2408
+ return null;
2409
+ }
2410
+ const directives: Array<string> = [];
2411
+ if (robots.index != null) {
2412
+ directives.push(robots.index ? "index" : "noindex");
2413
+ }
2414
+ if (robots.follow != null) {
2415
+ directives.push(robots.follow ? "follow" : "nofollow");
2416
+ }
2417
+ if (robots.maxSnippet != null) {
2418
+ directives.push(`max-snippet:${robots.maxSnippet}`);
2419
+ }
2420
+ if (robots.maxImagePreview != null) {
2421
+ directives.push(`max-image-preview:${robots.maxImagePreview}`);
2422
+ }
2423
+ return directives.length === 0 ? null : directives.join(", ");
2424
+ }
2425
+
2426
+ /**
2427
+ * One JSON-LD object as the text of a `<script>`.
2428
+ *
2429
+ * `<` is escaped so a string inside the data holding `</script>` cannot end
2430
+ * the element early — the same escape `server.js` applies to the embedded
2431
+ * loader data, and for the same reason: the text is the application's and the
2432
+ * element it lands in is terminated by a character sequence rather than by a
2433
+ * length. `dataScript` also escapes U+2028 and U+2029; those are about a
2434
+ * string being parsed as JavaScript source, and this one never is.
2435
+ */
2436
+ function jsonLdText(entry: JsonLd): string {
2437
+ return JSON.stringify(entry).replace(/</g, "\\u003c");
2438
+ }
2439
+
2440
+ /**
2441
+ * One JSON-LD object, as the element that carries it.
2442
+ *
2443
+ * A function rather than an element written inline, because the suppression
2444
+ * needs a line of its own; `docs/app/_uf.layout.js` has the same shape for the
2445
+ * same reason. `security/no-dangerously-set-inner-html` is about markup that
2446
+ * came from somewhere and has to be sanitized before a browser parses it as
2447
+ * HTML, and its escape hatch is a `@uniflowed/markdown` sanitizer — the right
2448
+ * answer for markup and no answer at all for JSON. This string is
2449
+ * `JSON.stringify`'s output with `<` escaped, so nothing in it can close the
2450
+ * element, and it is never parsed as HTML. There is also no other spelling:
2451
+ * React escapes a text child, so `{"@type":"Article"}` would reach the page as
2452
+ * `&quot;@type&quot;`, which is not JSON-LD any more.
2453
+ */
2454
+ function jsonLdScript(entry: JsonLd): React.Node {
2455
+ const text = jsonLdText(entry);
2456
+ const html = { __html: text };
2457
+ // uf-lint-disable-next-line security/no-dangerously-set-inner-html
2458
+ return <script key={text} type="application/ld+json" dangerouslySetInnerHTML={html} />;
2459
+ }
2460
+
1610
2461
  component Head(metadata: Metadata) {
1611
- const { title, description, metadataBase, canonical, openGraph, twitter } = metadata;
2462
+ const { title, description, metadataBase, canonical, robots } = metadata;
2463
+ const { alternates, pagination, jsonLd, openGraph, twitter } = metadata;
1612
2464
  const href = canonical != null ? absoluteUrl(canonical, metadataBase) : null;
2465
+ const crawler = robotsContent(robots);
2466
+ // Read out of `alternates` once rather than through it at every use: the map
2467
+ // is read inside a callback, and a refinement of `alternates.languages` does
2468
+ // not survive being carried into one.
2469
+ const languages = alternates?.languages;
2470
+ // A page that said what it is called has said what its card is called. Every
2471
+ // site that had to write both wrote the same string twice, and the second
2472
+ // one is the one that goes stale — the docs site shipped thirty pages whose
2473
+ // share cards carried an image and no title at all.
2474
+ //
2475
+ // `??`, not `||`: an empty string is a decision, and a page that deliberately
2476
+ // has no card title should get none rather than the document's.
2477
+ const cardTitle = openGraph?.title ?? title;
2478
+ const cardDescription = openGraph?.description ?? description;
2479
+ // `og:type` is one of the four properties Open Graph requires. A default is
2480
+ // the difference between a document with a card and a document without one,
2481
+ // and `website` is right for everything that is not an article or a video.
2482
+ const cardType = openGraph?.type ?? "website";
2483
+ // Only when the card was asked for. A page with no `twitter.card` gets no
2484
+ // Twitter tags at all, which is what a site that never wanted one meant.
2485
+ const twitterTitle = twitter != null ? (twitter.title ?? cardTitle) : null;
2486
+ const twitterDescription = twitter != null ? (twitter.description ?? cardDescription) : null;
2487
+ const twitterImageAlt = twitter != null ? (twitter.imageAlt ?? openGraph?.imageAlt) : null;
1613
2488
  return (
1614
2489
  <>
1615
2490
  {title != null ? <title>{title}</title> : null}
1616
2491
  {description != null ? <meta name="description" content={description} /> : null}
2492
+ {crawler != null ? <meta name="robots" content={crawler} /> : null}
1617
2493
  {href != null ? <link rel="canonical" href={href} /> : null}
2494
+ {/* The set is reciprocal and includes this page, so a `hreflang` list is
2495
+ usually the same list on every page of it — which is why it belongs
2496
+ on the layout they share rather than on each of them.
2497
+
2498
+ `hrefLang` is React's spelling and it reaches the markup unchanged,
2499
+ which is worth knowing before grepping a document for `hreflang` and
2500
+ concluding it is missing. HTML attribute names are case-insensitive,
2501
+ so the parser every crawler runs reads it as the same attribute; the
2502
+ lowercase spelling is the one React warns about. */}
2503
+ {languages != null
2504
+ ? Object.keys(languages).map((language) => (
2505
+ <link
2506
+ key={language}
2507
+ rel="alternate"
2508
+ hrefLang={language}
2509
+ href={absoluteUrl(languages[language], metadataBase)}
2510
+ />
2511
+ ))
2512
+ : null}
2513
+ {pagination?.prev != null ? (
2514
+ <link rel="prev" href={absoluteUrl(pagination.prev, metadataBase)} />
2515
+ ) : null}
2516
+ {pagination?.next != null ? (
2517
+ <link rel="next" href={absoluteUrl(pagination.next, metadataBase)} />
2518
+ ) : null}
1618
2519
  {/* `og:url` *is* the canonical URL of the page, in Open Graph's own
1619
2520
  words, so one declaration answers both rather than asking a project
1620
2521
  to write the same URL twice and keep them in step. */}
1621
2522
  {href != null ? <meta property="og:url" content={href} /> : null}
1622
- {openGraph?.title != null ? <meta property="og:title" content={openGraph.title} /> : null}
1623
- {openGraph?.description != null ? (
1624
- <meta property="og:description" content={openGraph.description} />
2523
+ {cardTitle != null ? <meta property="og:title" content={cardTitle} /> : null}
2524
+ {cardDescription != null ? (
2525
+ <meta property="og:description" content={cardDescription} />
2526
+ ) : null}
2527
+ {/* Only alongside something else. A document with `og:type` and nothing
2528
+ more is not a card; it is one meta tag saying the page is a page. */}
2529
+ {cardTitle != null || cardDescription != null || openGraph?.images != null ? (
2530
+ <meta property="og:type" content={cardType} />
2531
+ ) : null}
2532
+ {openGraph?.siteName != null ? (
2533
+ <meta property="og:site_name" content={openGraph.siteName} />
1625
2534
  ) : null}
1626
2535
  {openGraph?.images != null
1627
2536
  ? openGraph.images.map((image) => (
1628
2537
  <meta key={image} property="og:image" content={absoluteUrl(image, metadataBase)} />
1629
2538
  ))
1630
2539
  : null}
2540
+ {openGraph?.imageAlt != null && openGraph?.images != null ? (
2541
+ <meta property="og:image:alt" content={openGraph.imageAlt} />
2542
+ ) : null}
1631
2543
  {/* `name`, not `property`: Open Graph is RDFa and Twitter's cards are
1632
2544
  not, and a `property="twitter:card"` is ignored by the crawler that
1633
2545
  reads it. */}
1634
2546
  {twitter?.card != null ? <meta name="twitter:card" content={twitter.card} /> : null}
1635
2547
  {twitter?.site != null ? <meta name="twitter:site" content={twitter.site} /> : null}
1636
2548
  {twitter?.creator != null ? <meta name="twitter:creator" content={twitter.creator} /> : null}
1637
- {twitter?.title != null ? <meta name="twitter:title" content={twitter.title} /> : null}
1638
- {twitter?.description != null ? (
1639
- <meta name="twitter:description" content={twitter.description} />
2549
+ {/* X reads the `og:` tags when these are absent, so these are not
2550
+ required — and every validator asks for them anyway, which is a good
2551
+ enough reason when the value is one the page has already given. They
2552
+ fall back through the card's title to the document's. */}
2553
+ {twitterTitle != null ? <meta name="twitter:title" content={twitterTitle} /> : null}
2554
+ {twitterDescription != null ? (
2555
+ <meta name="twitter:description" content={twitterDescription} />
1640
2556
  ) : null}
1641
2557
  {twitter?.images != null
1642
2558
  ? twitter.images.map((image) => (
1643
2559
  <meta key={image} name="twitter:image" content={absoluteUrl(image, metadataBase)} />
1644
2560
  ))
1645
2561
  : null}
2562
+ {twitterImageAlt != null && twitter?.images != null ? (
2563
+ <meta name="twitter:image:alt" content={twitterImageAlt} />
2564
+ ) : null}
2565
+ {/* Last, and not hoisted into `<head>` with the rest: React hoists a
2566
+ `<title>`, a `<meta>` and a `<link>`, and not a script whose body it
2567
+ would have to carry. JSON-LD is read from anywhere in the document,
2568
+ so these render where the route does. */}
2569
+ {jsonLd != null ? jsonLd.map(jsonLdScript) : null}
1646
2570
  </>
1647
2571
  );
1648
2572
  }
1649
2573
 
2574
+ /**
2575
+ * Head elements a component contributes while it is rendering.
2576
+ *
2577
+ * `metadata` and `generateMetadata` are how a *route* says what it is, and
2578
+ * both are resolved before anything renders — which is what makes them work
2579
+ * for a crawler that runs no JavaScript. They are also declarations by the
2580
+ * route module, and part of what a page has to say is decided further in: a
2581
+ * paginated list knows its `prev` and `next` in the component that draws the
2582
+ * pager, and a breadcrumb knows the trail it has just walked.
2583
+ *
2584
+ * So this returns elements rather than writing to the head. Writing would have
2585
+ * to happen in an effect, an effect does not run on a server, and the result
2586
+ * would be a page whose tags are right in a browser and missing from the
2587
+ * crawler — `packages/web/head.js` is that escape hatch and says so at the top
2588
+ * of the file. Rendering is what puts a tag in a server-rendered head, so the
2589
+ * caller renders what comes back:
2590
+ *
2591
+ * export component Pager(page: number, of: number) {
2592
+ * const seo = useSeo({
2593
+ * pagination: {
2594
+ * prev: page > 1 ? `/posts?page=${page - 1}` : undefined,
2595
+ * next: page < of ? `/posts?page=${page + 1}` : undefined,
2596
+ * },
2597
+ * });
2598
+ * return <nav className="pager">{seo}…</nav>;
2599
+ * }
2600
+ *
2601
+ * The argument is a `Metadata` — the same type a route exports — because there
2602
+ * is one vocabulary for what a page says about itself, and a second one would
2603
+ * be a second place for it to be wrong. What this adds over rendering the tags
2604
+ * by hand is the thing a component three levels down cannot know:
2605
+ * `metadataBase`, which the root layout declared, and against which the
2606
+ * relative URLs written here are resolved.
2607
+ */
2608
+ export hook useSeo(seo: Metadata): React.Node {
2609
+ const { resolved } = useRouterState();
2610
+ const base = seo.metadataBase ?? resolved.metadata.metadataBase;
2611
+ return <Head metadata={base == null ? seo : { ...seo, metadataBase: base }} />;
2612
+ }
2613
+
1650
2614
  /** When a `Link` loads the route it points at. */
1651
2615
  export type LinkPrefetch = "off" | "intent" | "render";
1652
2616
 
@@ -1655,12 +2619,15 @@ export type LinkPrefetch = "off" | "intent" | "render";
1655
2619
  *
1656
2620
  * Renders a real anchor, so the link works before hydration and for a right
1657
2621
  * click, and takes over only a plain left click. `prefetch="intent"` (the
1658
- * default) loads the destination's chunks on hover or focus.
2622
+ * default) loads the destination's chunks on hover or focus, and
2623
+ * `transition={false}` makes this one navigation a cut — most navigations are
2624
+ * a link, so the opt-out in [`NavigateOptions`] has to be reachable from one.
1659
2625
  */
1660
2626
  export component Link(
1661
2627
  to: string,
1662
2628
  prefetch?: LinkPrefetch = "intent",
1663
2629
  replace?: boolean = false,
2630
+ transition?: boolean = true,
1664
2631
  children?: React.Node,
1665
2632
  className?: string,
1666
2633
  onClick?: (event: SyntheticMouseEvent<HTMLAnchorElement>) => mixed,
@@ -1699,7 +2666,7 @@ export component Link(
1699
2666
  return;
1700
2667
  }
1701
2668
  event.preventDefault();
1702
- router.push(to, { replace }).catch((error) => {
2669
+ router.push(to, { replace, transition }).catch((error) => {
1703
2670
  // A failed navigation falls back to the browser doing it.
1704
2671
  console.error(error);
1705
2672
  window.location.assign(to);