@uniflowed/router 0.0.0-alpha.12 → 0.0.0-alpha.14

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,13 +14,31 @@ import {
14
14
  Suspense,
15
15
  createContext,
16
16
  startTransition,
17
- useCallback,
17
+ use,
18
18
  useContext,
19
19
  useEffect,
20
- useMemo,
21
20
  useState,
22
21
  useSyncExternalStore,
23
22
  } from "react";
23
+ // The one thing in this module that only a browser can do, and the reason it
24
+ // is imported here rather than from `../client.js`: a view transition needs
25
+ // the DOM updated inside the callback it was handed, and `startTransition`
26
+ // schedules. "View transitions", below, is the argument. Importing `react-dom`
27
+ // costs the server bundle nothing it did not already have — `internal/stream.js`
28
+ // imports `react-dom/server` — and this entry touches no document while it is
29
+ // being evaluated.
30
+ import { flushSync } from "react-dom";
31
+
32
+ // The two things a render has to fix — its instant and its random seed — and
33
+ // the provider that fixes them. Imported here rather than left to the
34
+ // application, because a hydration guarantee nobody wires is not a guarantee:
35
+ // see [`routerView`] and ubugeeei-prod/uf#559.
36
+ import { RenderProvider } from "@uniflowed/hooks/render";
37
+
38
+ // The id of the script the loader data is embedded in. It moved out of the
39
+ // head and into the tree with ubugeeei-prod/uf#373 — see [`loaderDataScript`]
40
+ // — so the module that renders it is this one rather than `../server.js`.
41
+ import { DATA_ID } from "./document.js";
24
42
 
25
43
  /** One parameter a route path captures. */
26
44
  export type RouteParamSpec = {| readonly name: string, readonly catchAll: boolean |};
@@ -89,6 +107,22 @@ export type PageModule = {
89
107
  | $ReadOnlyArray<RouteParams>
90
108
  | Promise<$ReadOnlyArray<RouteParams>>,
91
109
  readonly frontmatter?: { readonly title?: string, readonly description?: string, ... },
110
+ /**
111
+ * What a stylesheet calls the transition this route arrives under.
112
+ *
113
+ * Not a switch. Every client navigation opts into a view transition where
114
+ * the browser has one, and this is how one arrival is told from another —
115
+ * the name reaches CSS as an attribute on the document element for as long
116
+ * as the transition runs:
117
+ *
118
+ * html[data-uf-view-transition="manual"]::view-transition-old(root) { … }
119
+ *
120
+ * A layout may declare one too, and then it covers every route under it; the
121
+ * page's own wins. That is the rule `metadata` already follows, and there is
122
+ * no reason for a second one — "the nearest declaration" is how everything
123
+ * else in a route module is resolved.
124
+ */
125
+ readonly viewTransition?: string,
92
126
  ...
93
127
  };
94
128
 
@@ -97,6 +131,23 @@ export type LayoutModule = {
97
131
  readonly default?: RouteComponent,
98
132
  readonly Layout?: RouteComponent,
99
133
  readonly metadata?: Metadata,
134
+ /** A transition name for every route under this layout; see [`PageModule`]. */
135
+ readonly viewTransition?: string,
136
+ ...
137
+ };
138
+
139
+ /**
140
+ * What a template module may export. The component is `default` or `Template`.
141
+ *
142
+ * A layout's shape without its `metadata`, and the omission is the type saying
143
+ * what a template is for. A layout persists across navigation, so a title it
144
+ * declares is a claim about a section of the site; a template is thrown away
145
+ * and built again on every navigation, so a title on one would be a claim
146
+ * about nothing. Titles come from the page and the layouts above it.
147
+ */
148
+ export type TemplateModule = {
149
+ readonly default?: RouteComponent,
150
+ readonly Template?: RouteComponent,
100
151
  ...
101
152
  };
102
153
 
@@ -140,6 +191,41 @@ export type LoadingModule = {
140
191
  */
141
192
  export type TwitterCard = "summary" | "summary_large_image" | "app" | "player";
142
193
 
194
+ /**
195
+ * What a crawler may do with a page.
196
+ *
197
+ * Four fields rather than the whole `robots` vocabulary, and the omissions are
198
+ * the argument. `index` and `follow` are the two directives a page has an
199
+ * opinion about; `maxSnippet` and `maxImagePreview` are the two that change
200
+ * what a result *looks* like and have no other spelling. `nosnippet` is not
201
+ * here because `maxSnippet: 0` is the same instruction, and a type with two
202
+ * ways to say one thing is a type somebody will eventually ask which of them
203
+ * wins.
204
+ *
205
+ * Every field is optional and every one is only emitted when it is declared,
206
+ * because "index, follow" is what a document with no `robots` meta already
207
+ * says — the tag exists to say something else.
208
+ */
209
+ export type Robots = {
210
+ readonly index?: boolean,
211
+ readonly follow?: boolean,
212
+ /** The longest snippet a result may quote; `0` is none, `-1` is no limit. */
213
+ readonly maxSnippet?: number,
214
+ readonly maxImagePreview?: "none" | "standard" | "large",
215
+ };
216
+
217
+ /**
218
+ * One JSON-LD object, as a page hands it over.
219
+ *
220
+ * `mixed` values rather than a schema.org type, because there is no useful
221
+ * middle: the vocabulary is hundreds of types deep, it grows without asking
222
+ * anyone, and a partial transcription of it would reject correct documents far
223
+ * more often than it caught wrong ones. What this type does claim is the part
224
+ * uf is answerable for — that the thing is an object, and therefore that it
225
+ * serialises into one `<script>`.
226
+ */
227
+ export type JsonLd = { readonly [string]: mixed };
228
+
143
229
  /** Document metadata a page or layout declares. */
144
230
  export type Metadata = {
145
231
  readonly title?: string,
@@ -166,6 +252,69 @@ export type Metadata = {
166
252
  * under a second section — is one page, and this is how it says so.
167
253
  */
168
254
  readonly canonical?: string,
255
+ /**
256
+ * What a crawler may do with this page. See [`Robots`].
257
+ *
258
+ * The one field here that is usually declared on a *layout*: a staging
259
+ * section, a preview tree or an account area is `index: false` for
260
+ * everything under it, and saying so once is the only version of that which
261
+ * stays true when a page is added.
262
+ */
263
+ readonly robots?: Robots,
264
+ /**
265
+ * The other addresses this same page is published at.
266
+ *
267
+ * `languages` maps a BCP 47 tag to that translation's URL and becomes one
268
+ * `<link rel="alternate" hreflang>` each. The set has to be reciprocal —
269
+ * every page in it lists every other one *and itself*, which is what makes a
270
+ * search engine read them as translations rather than as duplicates — so it
271
+ * is usually the same map on every page of the set, declared on the layout
272
+ * they share. `"x-default"` is a tag like any other here, and names what a
273
+ * reader whose language is not in the set should be given.
274
+ *
275
+ * Nested under `alternates` rather than sitting at the top level as
276
+ * `languages`, because `alternate` is the link relation and a language is
277
+ * only one kind of alternate; the outer name is a fact about the wire rather
278
+ * than a shape invented here.
279
+ */
280
+ readonly alternates?: {
281
+ readonly languages?: { readonly [string]: string },
282
+ },
283
+ /**
284
+ * The pages either side of this one in a sequence.
285
+ *
286
+ * `<link rel="prev">` and `<link rel="next">`, resolved against
287
+ * `metadataBase` like every other URL here. A page four of a list, and a
288
+ * chapter in the middle of a manual, are the same statement: this document
289
+ * is one of a series and here is where the series continues.
290
+ *
291
+ * `canonical` still belongs to the page itself. Pointing every page of a
292
+ * paginated list at page one is the mistake this pair exists to make
293
+ * unnecessary — it tells a search engine that pages two onwards are
294
+ * duplicates of page one, and everything only reachable from them stops
295
+ * being reachable at all.
296
+ */
297
+ readonly pagination?: {
298
+ readonly prev?: string,
299
+ readonly next?: string,
300
+ },
301
+ /**
302
+ * Structured data, as JSON-LD.
303
+ *
304
+ * One `<script type="application/ld+json">` per entry. Unlike everything
305
+ * else here it *accumulates* down the tree rather than being replaced by the
306
+ * nearest declaration: an `Organization` on the root layout and an `Article`
307
+ * on the page are two statements about one page, not two answers to one
308
+ * question, and replacing would mean a page that describes itself silently
309
+ * deletes the site's description of itself.
310
+ *
311
+ * The scripts are rendered with the rest of the route rather than hoisted
312
+ * into `<head>`, because React hoists `<title>`, `<meta>` and `<link>` and
313
+ * not a script it has to keep the body of. JSON-LD is read from anywhere in
314
+ * the document, so this costs nothing; it is worth knowing when reading the
315
+ * markup.
316
+ */
317
+ readonly jsonLd?: $ReadOnlyArray<JsonLd>,
169
318
  readonly openGraph?: {
170
319
  /**
171
320
  * The title a share card shows.
@@ -262,6 +411,26 @@ export type RouteRecord = {|
262
411
  * exactly what it had before.
263
412
  */
264
413
  readonly loading?: $ReadOnlyArray<LoadingRecord>,
414
+ /**
415
+ * The `_uf.template.js` wrappers this route renders inside, root first.
416
+ *
417
+ * Optional for the reason `loading` is: a table written before templates
418
+ * existed is still a table this router can render, and a route with no
419
+ * template renders exactly the tree it did before.
420
+ */
421
+ readonly templates?: $ReadOnlyArray<TemplateRecord>,
422
+ |};
423
+
424
+ /**
425
+ * One `_uf.template.js`, as the route table carries it.
426
+ *
427
+ * The same shape as [`LoadingRecord`] and the same `above`, because it answers
428
+ * the same question — where in the stack of layouts this thing sits — and
429
+ * there is no second vocabulary for it.
430
+ */
431
+ export type TemplateRecord = {|
432
+ readonly above: number,
433
+ readonly module: () => Promise<TemplateModule>,
265
434
  |};
266
435
 
267
436
  /**
@@ -290,7 +459,19 @@ export type NotFoundBoundary = {|
290
459
  readonly path: string,
291
460
  readonly mdx: boolean,
292
461
  readonly file: string,
293
- readonly page: () => Promise<PageModule>,
462
+ /**
463
+ * The page this boundary renders — `null` for the one the build synthesises
464
+ * at the router root when a project declares no `_uf.not-found.js` there.
465
+ *
466
+ * A project that had declared none used to get `layouts: []` along with the
467
+ * framework's page: not the nearest-ancestor rule failing, but the fallback
468
+ * having no record to take layouts from. So the root's layouts are a record
469
+ * like any other, with the framework's component in place of a module to
470
+ * import — which is a nullable field rather than a second kind of answer, and
471
+ * is why an unmatched URL still arrives inside the site's own masthead. See
472
+ * ubugeeei-prod/uf#351.
473
+ */
474
+ readonly page: ?() => Promise<PageModule>,
294
475
  readonly layouts: $ReadOnlyArray<() => Promise<LayoutModule>>,
295
476
  |};
296
477
 
@@ -305,7 +486,8 @@ export type NotFoundBoundary = {|
305
486
  export type ErrorBoundary = {|
306
487
  readonly path: string,
307
488
  readonly file: string,
308
- readonly module: () => Promise<ErrorModule>,
489
+ /** `null` for the synthesised root record; see [`NotFoundBoundary`]`.page`. */
490
+ readonly module: ?() => Promise<ErrorModule>,
309
491
  readonly layouts: $ReadOnlyArray<() => Promise<LayoutModule>>,
310
492
  |};
311
493
 
@@ -358,8 +540,8 @@ export function routeErrorStatus(error: RouteError): 401 | 403 | 500 {
358
540
  }
359
541
 
360
542
  /**
361
- * A match whose modules are loaded and whose loader has run — or, when `error`
362
- * is set, the error page that stands in for it.
543
+ * A match whose modules are loaded and whose loader has run or is running — or,
544
+ * when `error` is set, the error page that stands in for it.
363
545
  */
364
546
  export type ResolvedRoute = {|
365
547
  readonly pathname: string,
@@ -369,8 +551,34 @@ export type ResolvedRoute = {|
369
551
  readonly searchParams: SearchParams,
370
552
  readonly page: PageModule,
371
553
  readonly layouts: $ReadOnlyArray<LayoutModule>,
554
+ /** What the loader returned, once it has. `undefined` while `deferred` is set. */
372
555
  readonly data: mixed,
556
+ /**
557
+ * The loader still running, when the router handed the page its promise
558
+ * rather than its value. `null` on every other path, which is most of them.
559
+ *
560
+ * Two fields rather than a `data` that is sometimes a promise, because a
561
+ * loader is free to return something with a `then` on it and no duck test
562
+ * could tell that apart from a deferral. This one is the router's own answer
563
+ * to a question the router asked, so it says so.
564
+ *
565
+ * Set only by a streaming render of a route that declares a
566
+ * `_uf.loading.js` and generates no metadata from its data — the two
567
+ * conditions under which deferring buys anything and costs nothing that was
568
+ * not already spent. [`resolveRoute`] is where that is decided and argued.
569
+ */
570
+ readonly deferred: ?Promise<mixed>,
373
571
  readonly metadata: Metadata,
572
+ /**
573
+ * What a stylesheet calls the transition this route arrives under, or `null`
574
+ * when neither the page nor a layout above it named one.
575
+ *
576
+ * Resolved with the route rather than looked up at the moment of the
577
+ * navigation, because by then the answer is a property of the destination's
578
+ * modules and those are exactly what has just been loaded. A server render
579
+ * carries it and never reads it; see "View transitions".
580
+ */
581
+ readonly viewTransition: ?string,
374
582
  readonly status: 200 | 401 | 403 | 404 | 500,
375
583
  /**
376
584
  * Set when this resolution *is* the error page: the loader threw, or the
@@ -401,6 +609,20 @@ export type ResolvedRoute = {|
401
609
  * ordinary case and renders exactly the tree it did before.
402
610
  */
403
611
  readonly loading: $ReadOnlyArray<{| readonly above: number, readonly module: LoadingModule |}>,
612
+ /**
613
+ * The templates around this route, root first, already imported.
614
+ *
615
+ * Empty for a route with no `_uf.template.js` above it, which is the
616
+ * ordinary case and renders exactly the tree it did before templates
617
+ * existed. Empty too on a resolution that *is* a boundary — a not-found or
618
+ * an error page — for the reason its `loading` is: those are matched rather
619
+ * than walked to, and templates are accumulated on the walk down to a route
620
+ * the URL never reached.
621
+ */
622
+ readonly templates: $ReadOnlyArray<{|
623
+ readonly above: number,
624
+ readonly module: TemplateModule,
625
+ |}>,
404
626
  |};
405
627
 
406
628
  /** Thrown by `notFound()`; the renderer answers with the not-found page. */
@@ -672,11 +894,24 @@ function loadOnce<T>(load: () => Promise<T>): Promise<T> {
672
894
  * before `hydrateRoot`, so a rejection there is not an error page — it is no
673
895
  * `hydrateRoot` call at all, and the document the server sent stays on screen
674
896
  * with nothing attached to it.
897
+ *
898
+ * # `onMatch`
899
+ *
900
+ * Called with the route pattern the moment the URL matches one, before any
901
+ * module is imported and before the loader runs. It exists because the server
902
+ * has something to do with that fact and does it too late otherwise: the route
903
+ * a request turned out to be is what its log line carries, and a loader is
904
+ * inside this call, so a server that recorded the route after this resolved
905
+ * would have every line a loader wrote saying it belonged to no route.
906
+ *
907
+ * A callback rather than a return value because both callers already have one
908
+ * — the pattern is on the `ResolvedRoute` this hands back — and only one of
909
+ * them needs it *early*. The browser passes nothing and pays nothing.
675
910
  */
676
911
  export async function resolveMatch(
677
912
  table: RouteTable,
678
913
  url: string,
679
- options?: {| readonly data?: mixed, readonly skipLoader?: boolean |},
914
+ options?: ResolveOptions,
680
915
  ): Promise<ResolvedRoute> {
681
916
  try {
682
917
  return await resolveRoute(table, url, options);
@@ -688,15 +923,52 @@ export async function resolveMatch(
688
923
  }
689
924
  }
690
925
 
926
+ /** What a caller may tell [`resolveMatch`] about the resolution it wants. */
927
+ export type ResolveOptions = {|
928
+ /** The loader's answer, already in hand — the value the server embedded. */
929
+ readonly data?: mixed,
930
+ /** Do not run the loader at all; `data` is the answer. */
931
+ readonly skipLoader?: boolean,
932
+ /**
933
+ * Whether the caller can render a route whose loader has not answered yet.
934
+ *
935
+ * Only a streaming server render can, and that is the whole of why this is
936
+ * a caller's choice rather than the router's. `createRenderer`'s `render`
937
+ * sends a `<Suspense>` fallback now and the content when it arrives, so
938
+ * deferring is what turns a slow loader from a delay before the first byte
939
+ * into a fallback the reader is already looking at.
940
+ *
941
+ * Nothing else is in that position, and each for its own reason. `prerender`
942
+ * writes a file, which has no first paint to improve and no reader to show a
943
+ * fallback to. `hydrate` has the server's answer already. A client
944
+ * navigation has a page on screen that stays interactive while the next one
945
+ * resolves, which is the browser's version of the same idea and does not
946
+ * need this one.
947
+ *
948
+ * It costs the loader its say in the response: a status is decided when the
949
+ * shell goes out, so a deferred `notFound()` reaches the error boundary
950
+ * rather than the 404 page, and the document is a 200. That is inherent to
951
+ * streaming rather than a shortcut — the bytes have gone — and it is the
952
+ * reason this is off unless a caller asks.
953
+ */
954
+ readonly defer?: boolean,
955
+ /** The route pattern, the moment the URL matches one; see [`resolveMatch`]. */
956
+ readonly onMatch?: (pattern: string) => void,
957
+ |};
958
+
691
959
  async function resolveRoute(
692
960
  table: RouteTable,
693
961
  url: string,
694
- options?: {| readonly data?: mixed, readonly skipLoader?: boolean |},
962
+ options?: ResolveOptions,
695
963
  ): Promise<ResolvedRoute> {
696
964
  const { pathname, search } = splitUrl(url);
697
965
  const searchParams = parseSearch(search);
698
966
  const matched = matchRoute(table.routes, pathname);
699
967
 
968
+ if (matched != null) {
969
+ options?.onMatch?.(matched.route.path);
970
+ }
971
+
700
972
  if (matched == null) {
701
973
  return resolveNotFound(table, pathname, search, searchParams);
702
974
  }
@@ -724,22 +996,44 @@ async function resolveRoute(
724
996
  // Started alongside for the same reason, and awaited at the end: a fallback
725
997
  // depends on nothing the loader produces.
726
998
  const loading = resolveLoading(matched.route, matched.route.layouts.length);
999
+ const templates = resolveTemplates(matched.route, matched.route.layouts.length);
727
1000
 
1001
+ // The loader, run here and awaited below — or not awaited at all.
1002
+ //
1003
+ // A page that suspends while *rendering* has always streamed; a page waiting
1004
+ // on its loader could not, because this function awaited the loader before it
1005
+ // returned and by the time React saw the tree the data was already in hand.
1006
+ // The fallback beside such a page showed for zero milliseconds, which made
1007
+ // `_uf.loading.js` useful for the one case a page usually is not slow for.
1008
+ //
1009
+ // Two things stand in the way of simply not awaiting, and both are about the
1010
+ // document rather than about the route. Metadata goes in the head and the
1011
+ // head is written before the body, so a title computed from the data
1012
+ // genuinely cannot be deferred — that is a rule worth stating rather than a
1013
+ // limitation to hide, and it is the `generateMetadata` half of the condition
1014
+ // below. The other is that a route with no `<Suspense>` above it has nothing
1015
+ // to defer *into*: React holds the whole shell for a page that suspends with
1016
+ // no boundary, which is the same wait by another name, with an unresolved
1017
+ // promise flowing through the tree for nothing. So the loader is deferred
1018
+ // exactly when there is a boundary to defer it into.
1019
+ //
1020
+ // See ubugeeei-prod/uf#373, and `ResolveOptions.defer` for who asks.
728
1021
  let data: mixed = options?.data;
1022
+ let deferred: ?Promise<mixed> = null;
729
1023
  if (options?.skipLoader !== true && typeof page.loader === "function") {
730
- // Awaited here, so a route's time to first byte is still its slowest
731
- // loader. A page that suspends while *rendering* streams — that is what the
732
- // `<Suspense>` boundaries below are for — but a page waiting on its loader
733
- // has already waited by the time React sees the tree, so its fallback shows
734
- // for no time at all.
735
- //
736
- // Deferring it means handing the page a promise and unwrapping it inside
737
- // the boundary, and the obstacle is not the awaiting: it is that
738
- // `generateMetadata` reads `data` and metadata goes in the head, and that
739
- // the loader data is embedded in the head too, for hydration. Both are
740
- // decisions about the document rather than about the route.
741
- // ubugeeei-prod/uf#373 has the design.
742
- data = await page.loader({ params: matched.params, searchParams, pathname });
1024
+ const running = page.loader({ params: matched.params, searchParams, pathname });
1025
+ const canDefer =
1026
+ options?.defer === true &&
1027
+ (matched.route.loading ?? []).length > 0 &&
1028
+ typeof page.generateMetadata !== "function";
1029
+ if (canDefer) {
1030
+ // `Promise.resolve`, because a loader may return a plain value and `use`
1031
+ // wants a promise either way. A loader that answered without waiting
1032
+ // costs one microtask and renders in the same pass.
1033
+ deferred = Promise.resolve(running);
1034
+ } else {
1035
+ data = await running;
1036
+ }
743
1037
  }
744
1038
 
745
1039
  const metadata = await resolveMetadata(page, layouts, {
@@ -756,14 +1050,52 @@ async function resolveRoute(
756
1050
  page,
757
1051
  layouts,
758
1052
  data,
1053
+ deferred,
759
1054
  metadata,
1055
+ viewTransition: resolveViewTransition(page, layouts),
760
1056
  status: 200,
761
1057
  error: null,
762
1058
  errorBoundary: await boundary,
763
1059
  loading: await loading,
1060
+ templates: await templates,
764
1061
  };
765
1062
  }
766
1063
 
1064
+ /**
1065
+ * The route's templates, imported.
1066
+ *
1067
+ * A template that will not load is dropped, the way a fallback is: it is a
1068
+ * wrapper around the page, not the page, so a broken wrapper must not become a
1069
+ * broken route. The tree renders without it — the page keeps the layout it was
1070
+ * inside, and loses only the remount — and the import error surfaces where it
1071
+ * belongs, when the module is next asked for.
1072
+ */
1073
+ async function resolveTemplates(
1074
+ route: RouteRecord,
1075
+ layoutCount: number,
1076
+ ): Promise<$ReadOnlyArray<{| readonly above: number, readonly module: TemplateModule |}>> {
1077
+ const records = route.templates ?? [];
1078
+ if (records.length === 0) {
1079
+ return [];
1080
+ }
1081
+ const loaded = await Promise.all(
1082
+ records.map(async (record) => {
1083
+ try {
1084
+ return {
1085
+ // Clamped exactly as the error and loading boundaries' are: a
1086
+ // `(group)` directory can leave a route with fewer layouts than the
1087
+ // template declared above it.
1088
+ above: Math.min(record.above, layoutCount),
1089
+ module: await loadOnce(record.module),
1090
+ };
1091
+ } catch {
1092
+ return null;
1093
+ }
1094
+ }),
1095
+ );
1096
+ return loaded.filter(Boolean);
1097
+ }
1098
+
767
1099
  /**
768
1100
  * The route's loading boundaries, imported.
769
1101
  *
@@ -874,13 +1206,22 @@ async function resolveErrorBoundary(
874
1206
  // the boundary covering it, and an `above` past the end would compose the
875
1207
  // layouts out of nothing.
876
1208
  const above = Math.min(boundary.layouts.length, layoutCount);
1209
+ const load = boundary.module;
1210
+ // The synthesised root record, which names layouts and no module: the
1211
+ // framework's page renders, and `above` still says where — inside the site's
1212
+ // own layouts rather than outside everything. See [`NotFoundBoundary`]`.page`.
1213
+ if (load == null) {
1214
+ return { module: null, above };
1215
+ }
877
1216
  try {
878
- return { module: await loadOnce(boundary.module), above };
1217
+ return { module: await loadOnce(load), above };
879
1218
  } catch {
880
1219
  // A boundary whose module will not load cannot be the answer to a throw,
881
1220
  // and this is why the field is nullable: containment must not itself
882
- // depend on an import working.
883
- return { module: null, above: 0 };
1221
+ // depend on an import working. The depth is kept, because the layouts the
1222
+ // boundary named are still there and the framework's page is better inside
1223
+ // them than outside them.
1224
+ return { module: null, above };
884
1225
  }
885
1226
  }
886
1227
 
@@ -903,11 +1244,15 @@ async function resolveError(
903
1244
  let module: ?ErrorModule = null;
904
1245
  let layouts: $ReadOnlyArray<LayoutModule> = [];
905
1246
  if (boundary != null) {
1247
+ const load = boundary.module;
906
1248
  try {
907
- [module, layouts] = await Promise.all([
908
- loadOnce(boundary.module),
909
- Promise.all(boundary.layouts.map((layout) => loadOnce(layout))),
910
- ]);
1249
+ // The layouts whether or not there is a module, because the synthesised
1250
+ // root record has layouts and no module and its whole purpose is that
1251
+ // the framework's error page renders inside them: a site whose root
1252
+ // layout owns the masthead and the stylesheet answered a 500 with
1253
+ // neither. See ubugeeei-prod/uf#351.
1254
+ layouts = await Promise.all(boundary.layouts.map((layout) => loadOnce(layout)));
1255
+ module = load == null ? null : await loadOnce(load);
911
1256
  } catch {
912
1257
  // See `resolveErrorBoundary`: the framework's own page answers instead.
913
1258
  module = null;
@@ -929,7 +1274,12 @@ async function resolveError(
929
1274
  page: { default: ResolvedErrorPage },
930
1275
  layouts,
931
1276
  data: undefined,
1277
+ deferred: null,
932
1278
  metadata: declared.title != null ? declared : { ...declared, title: errorTitle(routeError) },
1279
+ // The boundary's own layouts may name one; the page cannot, because the
1280
+ // page here is this module's. An error arriving under the section's
1281
+ // transition is the same answer as a page arriving under it.
1282
+ viewTransition: resolveViewTransition({}, layouts),
933
1283
  status: routeErrorStatus(routeError),
934
1284
  error: routeError,
935
1285
  // All of the boundary's layouts are above it, and no inner boundary is
@@ -939,6 +1289,7 @@ async function resolveError(
939
1289
  // resolved with. A fallback around it would be a boundary that can never
940
1290
  // show, which is worse than none.
941
1291
  loading: [],
1292
+ templates: [],
942
1293
  };
943
1294
  }
944
1295
 
@@ -953,6 +1304,21 @@ async function resolveError(
953
1304
  * layouts *below* the boundary — so `app/guide/[slug]/_uf.layout.js` would
954
1305
  * wrap a 404 that `app/guide/_uf.not-found.js` answered, which is the layout
955
1306
  * of the page that just said it does not exist.
1307
+ *
1308
+ * # The record with no page
1309
+ *
1310
+ * A project that declares no `_uf.not-found.js` anywhere still has a record —
1311
+ * the one the build synthesises for the router root — and it names the root's
1312
+ * layouts and no module. Before that record existed this function answered
1313
+ * with `layouts: []`, so a site whose root layout owns the masthead, the
1314
+ * stylesheet and often `<html>` itself answered an unmatched URL with a white
1315
+ * page carrying `404` and no way to leave it. That was not the nearest-ancestor
1316
+ * rule failing; it was the fallback having no record to take layouts from, and
1317
+ * giving it one is the whole of ubugeeei-prod/uf#351.
1318
+ *
1319
+ * The framework's page then merges its title over the layouts' metadata like
1320
+ * any page would, so a `metadataBase` or an `og:site_name` declared on the root
1321
+ * layout still applies to the 404.
956
1322
  */
957
1323
  async function resolveNotFound(
958
1324
  table: RouteTable,
@@ -961,26 +1327,12 @@ async function resolveNotFound(
961
1327
  searchParams: SearchParams,
962
1328
  ): Promise<ResolvedRoute> {
963
1329
  const record = nearestBoundary(table.notFound, pathname);
964
- if (record == null) {
965
- return {
966
- pathname,
967
- search,
968
- path: "*",
969
- params: {},
970
- searchParams,
971
- page: { default: DefaultNotFound },
972
- layouts: [],
973
- data: undefined,
974
- metadata: { title: "Not found" },
975
- status: 404,
976
- error: null,
977
- errorBoundary: await resolveErrorBoundary(table, pathname, 0),
978
- loading: [],
979
- };
980
- }
1330
+ const load = record?.page;
981
1331
  const [page, ...layouts] = await Promise.all([
982
- loadOnce(record.page),
983
- ...record.layouts.map((layout) => loadOnce(layout)),
1332
+ load == null
1333
+ ? Promise.resolve<PageModule>({ default: DefaultNotFound, metadata: { title: "Not found" } })
1334
+ : loadOnce(load),
1335
+ ...(record?.layouts ?? []).map((layout) => loadOnce(layout)),
984
1336
  ]);
985
1337
  const metadata = await resolveMetadata(page, layouts, {
986
1338
  params: {},
@@ -996,7 +1348,9 @@ async function resolveNotFound(
996
1348
  page,
997
1349
  layouts,
998
1350
  data: undefined,
1351
+ deferred: null,
999
1352
  metadata,
1353
+ viewTransition: resolveViewTransition(page, layouts),
1000
1354
  status: 404,
1001
1355
  error: null,
1002
1356
  // A not-found page is a page: one that throws is contained like any other.
@@ -1005,18 +1359,36 @@ async function resolveNotFound(
1005
1359
  // record and the loading files are a property of the route that was walked
1006
1360
  // to, which this URL never reached. Nothing to wait for, so no boundary.
1007
1361
  loading: [],
1362
+ // Templates are accumulated on that same walk, and for the same reason.
1363
+ templates: [],
1008
1364
  };
1009
1365
  }
1010
1366
 
1367
+ /**
1368
+ * The route's metadata: each declaration merged over the ones outside it.
1369
+ *
1370
+ * Per key, so a page that declares only `canonical` keeps the title its layout
1371
+ * set — with one exception, and it is deliberate. `jsonLd` is gathered along
1372
+ * the way instead of merged, because a nearer declaration of it is an addition
1373
+ * rather than a correction; [`Metadata`] has the argument.
1374
+ */
1011
1375
  async function resolveMetadata(
1012
1376
  page: PageModule,
1013
1377
  layouts: $ReadOnlyArray<LayoutModule>,
1014
1378
  args: MetadataArgs,
1015
1379
  ): Promise<Metadata> {
1016
1380
  let merged: Metadata = {};
1381
+ let structured: $ReadOnlyArray<JsonLd> = [];
1382
+ const take = (declared: Metadata) => {
1383
+ if (declared.jsonLd != null) {
1384
+ structured = [...structured, ...declared.jsonLd];
1385
+ }
1386
+ merged = { ...merged, ...declared };
1387
+ };
1388
+
1017
1389
  for (const layout of layouts) {
1018
1390
  if (layout.metadata != null) {
1019
- merged = { ...merged, ...layout.metadata };
1391
+ take(layout.metadata);
1020
1392
  }
1021
1393
  }
1022
1394
  if (page.frontmatter != null) {
@@ -1028,12 +1400,42 @@ async function resolveMetadata(
1028
1400
  };
1029
1401
  }
1030
1402
  if (page.metadata != null) {
1031
- merged = { ...merged, ...page.metadata };
1403
+ take(page.metadata);
1032
1404
  }
1033
1405
  if (typeof page.generateMetadata === "function") {
1034
- merged = { ...merged, ...(await page.generateMetadata(args)) };
1406
+ take(await page.generateMetadata(args));
1035
1407
  }
1036
- return merged;
1408
+ return structured.length === 0 ? merged : { ...merged, jsonLd: structured };
1409
+ }
1410
+
1411
+ /** A module that may name the transition its route arrives under. */
1412
+ type Transitioning = { readonly viewTransition?: string, ... };
1413
+
1414
+ /**
1415
+ * What a stylesheet calls this route's arrival: the nearest declaration wins.
1416
+ *
1417
+ * The same walk `resolveMetadata` does one function above, and stated as its
1418
+ * own function rather than folded into that one because the two answer
1419
+ * different questions and only one of them is a document. Layouts are root
1420
+ * first, so overwriting as it descends leaves the innermost, and the page has
1421
+ * the last word.
1422
+ *
1423
+ * The parameters say what is read rather than naming `PageModule` and
1424
+ * `LayoutModule`, which is the shape `nearestBoundary` already takes for the
1425
+ * same reason: this reads one optional field, so requiring the whole of either
1426
+ * type would be a claim it does not need and cannot use.
1427
+ */
1428
+ function resolveViewTransition(
1429
+ page: Transitioning,
1430
+ layouts: $ReadOnlyArray<Transitioning>,
1431
+ ): ?string {
1432
+ let name: ?string = null;
1433
+ for (const layout of layouts) {
1434
+ if (layout.viewTransition != null) {
1435
+ name = layout.viewTransition;
1436
+ }
1437
+ }
1438
+ return page.viewTransition ?? name;
1037
1439
  }
1038
1440
 
1039
1441
  component DefaultNotFound() {
@@ -1132,9 +1534,9 @@ component RouteErrorView(module: ?ErrorModule, error: RouteError, reset: () => v
1132
1534
  */
1133
1535
  component ResolvedErrorPage() {
1134
1536
  const { resolved, router } = useRouterState();
1135
- const reset = useCallback(() => {
1537
+ const reset = () => {
1136
1538
  router.refresh().catch(() => {});
1137
- }, [router]);
1539
+ };
1138
1540
 
1139
1541
  if (resolved.error == null) {
1140
1542
  // Unreachable: this module is only ever the page of a resolved error route.
@@ -1197,12 +1599,178 @@ class RouteErrorBoundary extends React.Component<RouteErrorBoundaryProps, RouteE
1197
1599
  }
1198
1600
  }
1199
1601
 
1602
+ // ---------------------------------------------------------------------------
1603
+ // View transitions
1604
+ // ---------------------------------------------------------------------------
1605
+ //
1606
+ // A client navigation replaces the tree and the browser paints the new one,
1607
+ // which is a cut. `document.startViewTransition` is the platform's answer, and
1608
+ // it is opt-in per navigation rather than per site — so somebody has to call
1609
+ // it, and the somebody is whatever replaced the tree. That is this module.
1610
+ // Leaving it to the application would mean every application reimplementing
1611
+ // the same four decisions below, and getting the last one wrong.
1612
+ //
1613
+ // # Why `flushSync` rather than `startTransition`
1614
+ //
1615
+ // The browser captures the old frame, calls the callback, and waits on the
1616
+ // promise the callback returns before capturing the new one. So the callback
1617
+ // has to leave the DOM updated, and `startTransition` deliberately does not:
1618
+ // it schedules, and returns having changed nothing.
1619
+ //
1620
+ // The alternative was to hand the browser a promise resolved from a layout
1621
+ // effect after the commit, which keeps the render concurrent and can hang: a
1622
+ // running view transition blocks input until its callback settles, so a commit
1623
+ // React decides not to make — an interrupted transition, an unmounted provider
1624
+ // — is a frozen page with no way back. `flushSync` cannot hang.
1625
+ //
1626
+ // The cost is real and worth stating rather than discovering. Inside a
1627
+ // transition the commit is synchronous, so a route that suspends *while
1628
+ // rendering* shows its `_uf.loading.js` fallback instead of leaving the
1629
+ // previous page up until it resolves. Its modules and its loader are already
1630
+ // finished by this point — `resolveMatch` awaited both — so what is left is a
1631
+ // component suspending on something else, and it degrades to the fallback the
1632
+ // project wrote for exactly that.
1633
+ //
1634
+ // # Why not React's `<ViewTransition>`
1635
+ //
1636
+ // It is not in a stable React. This package's peer range is `react >= 19`, and
1637
+ // reaching for a component that exists only in an experimental build would
1638
+ // turn an animation into a reason a project cannot use the router at all.
1639
+ // `startViewTransition` is the same feature one layer down, and it is in the
1640
+ // browser rather than in a dependency.
1641
+ //
1642
+ // # What must not change
1643
+ //
1644
+ // A browser without `startViewTransition` navigates exactly as it did before
1645
+ // any of this. A reader who asked for less motion gets the cut they asked for,
1646
+ // without the application having to remember to ask on their behalf. And the
1647
+ // server renders nothing about it: a transition is a client-only concern, and
1648
+ // the moment one reaches the markup it is a hydration difference instead.
1649
+
1650
+ /**
1651
+ * The attribute a running transition's name reaches CSS through.
1652
+ *
1653
+ * On the document element, because that is where the `::view-transition`
1654
+ * pseudo-elements hang and therefore the only element a selector can reach
1655
+ * them from.
1656
+ */
1657
+ const VIEW_TRANSITION_ATTRIBUTE = "data-uf-view-transition";
1658
+
1659
+ /**
1660
+ * The part of a running transition this module reads.
1661
+ *
1662
+ * One property, because one is what a navigation needs: `finished` settles
1663
+ * when the animation is over, which is when the document may stop saying which
1664
+ * transition is running. `ready` and `updateCallbackDone` are for an
1665
+ * application animating something itself, and a router holding them would be
1666
+ * claiming to know what they were for.
1667
+ */
1668
+ type ViewTransition = { readonly finished: Promise<mixed>, ... };
1669
+
1670
+ /**
1671
+ * The document, under the one description this module has of it.
1672
+ *
1673
+ * Flow's library definitions have no `startViewTransition` — the API is newer
1674
+ * than they are — and reading it off `any` would leave the one call that
1675
+ * performs a navigation unchecked, where a wrong type is a broken navigation
1676
+ * rather than a broken animation. Optional, because "this browser may not have
1677
+ * it" is the entire point.
1678
+ *
1679
+ * An `interface` rather than an object type, because a `Document` is a class
1680
+ * instance and class instances are not subtypes of object types. `documentElement`
1681
+ * is nullable for the same reason it is in Flow's own libdef: a document parsed
1682
+ * from nothing has no root element.
1683
+ */
1684
+ interface ViewTransitionDocument {
1685
+ readonly startViewTransition?: (update: () => mixed) => ViewTransition;
1686
+ readonly documentElement: HTMLElement | null;
1687
+ }
1688
+
1689
+ /**
1690
+ * Whether the reader has asked for less motion.
1691
+ *
1692
+ * Asked at the moment of the navigation rather than subscribed to, because it
1693
+ * is not a rendered value: nothing re-renders when the preference changes, and
1694
+ * the only question is what to do with the click that just happened.
1695
+ * `usePrefersReducedMotion` in `@uniflowed/hooks` is the rendered form of the
1696
+ * same query and answers a different question.
1697
+ *
1698
+ * `matchMedia` is optional here because a document installed by a test runner
1699
+ * may not have one, and a media query that cannot be asked is not a reason to
1700
+ * fail a navigation.
1701
+ */
1702
+ function prefersReducedMotion(): boolean {
1703
+ const query = window.matchMedia?.("(prefers-reduced-motion: reduce)");
1704
+ return query != null && query.matches === true;
1705
+ }
1706
+
1707
+ /**
1708
+ * Apply `update`, inside a view transition where there is one to be had.
1709
+ *
1710
+ * Two ways out and they are one decision: with no `startViewTransition`, or
1711
+ * with a reader who asked for less motion, this is the `startTransition` the
1712
+ * router did before any of this existed — same commit, same concurrency, no
1713
+ * animation.
1714
+ *
1715
+ * `name` is the route's, and it reaches CSS as an attribute for as long as the
1716
+ * transition runs. The other spelling is the `types` option, which is the
1717
+ * platform's own vocabulary for the same idea and is *newer than
1718
+ * `startViewTransition` itself* — so passing the options object to a browser
1719
+ * that has only the callback form is a `TypeError` thrown out of the call that
1720
+ * performs the navigation. Naming a transition would then need a second and
1721
+ * finer feature detection than the one for having transitions at all, and the
1722
+ * cost of getting that one wrong is the navigation rather than the animation.
1723
+ * One attribute needs no detection and is removed again when the transition
1724
+ * ends.
1725
+ */
1726
+ function withViewTransition(name: ?string, update: () => void): void {
1727
+ const owner: ViewTransitionDocument = document;
1728
+ const start = owner.startViewTransition?.bind(owner);
1729
+ if (start == null || prefersReducedMotion()) {
1730
+ startTransition(update);
1731
+ return;
1732
+ }
1733
+
1734
+ const root = owner.documentElement;
1735
+ if (name != null && root != null) {
1736
+ root.setAttribute(VIEW_TRANSITION_ATTRIBUTE, name);
1737
+ }
1738
+ const ended = () => {
1739
+ if (name != null && root != null) {
1740
+ root.removeAttribute(VIEW_TRANSITION_ATTRIBUTE);
1741
+ }
1742
+ };
1743
+ // Both settlements do the same thing, and the rejection is not a failure:
1744
+ // `finished` rejects when the transition is skipped — a second navigation
1745
+ // before this one finished, a tab that went to the background — and a
1746
+ // skipped transition has still ended. Handling it is also what keeps a
1747
+ // routine interruption from being reported as an unhandled rejection.
1748
+ start(() => {
1749
+ flushSync(update);
1750
+ }).finished.then(ended, ended);
1751
+ }
1752
+
1200
1753
  // ---------------------------------------------------------------------------
1201
1754
  // The React binding
1202
1755
  // ---------------------------------------------------------------------------
1203
1756
 
1204
1757
  /** How a navigation is performed. */
1205
- export type NavigateOptions = {| readonly replace?: boolean, readonly scroll?: boolean |};
1758
+ export type NavigateOptions = {|
1759
+ readonly replace?: boolean,
1760
+ readonly scroll?: boolean,
1761
+ /**
1762
+ * Whether this navigation may animate. Defaults to `true`, which is what
1763
+ * every navigation does.
1764
+ *
1765
+ * `false` is how a caller says this one is a change of state rather than a
1766
+ * change of place — a tab within a page, a filter written into the query
1767
+ * string — and should be a cut. `true` does not *force* one: a browser
1768
+ * without `startViewTransition` and a reader who asked for less motion still
1769
+ * get the cut, because an application able to override the second would
1770
+ * eventually override it.
1771
+ */
1772
+ readonly transition?: boolean,
1773
+ |};
1206
1774
 
1207
1775
  /** What `useRouter()` returns. */
1208
1776
  export type Router = {|
@@ -1286,7 +1854,7 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
1286
1854
  const [resolved, setResolved] = useState<ResolvedRoute>(initial);
1287
1855
  const [pending, setPending] = useState<boolean>(false);
1288
1856
 
1289
- const navigate = useCallback(async (to: string, options?: NavigateOptions): Promise<void> => {
1857
+ const navigate = async (to: string, options?: NavigateOptions): Promise<void> => {
1290
1858
  if (!isBrowser()) {
1291
1859
  return;
1292
1860
  }
@@ -1312,10 +1880,15 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
1312
1880
  } else {
1313
1881
  window.history.pushState(null, "", next + target.hash);
1314
1882
  }
1315
- startTransition(() => {
1883
+ const commit = () => {
1316
1884
  setResolved(nextResolved);
1317
1885
  setPending(false);
1318
- });
1886
+ };
1887
+ if (options?.transition === false) {
1888
+ startTransition(commit);
1889
+ } else {
1890
+ withViewTransition(nextResolved.viewTransition, commit);
1891
+ }
1319
1892
  if (options?.scroll !== false) {
1320
1893
  if (target.hash !== "") {
1321
1894
  const element = document.getElementById(target.hash.slice(1));
@@ -1330,7 +1903,7 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
1330
1903
  setPending(false);
1331
1904
  throw error;
1332
1905
  }
1333
- }, []);
1906
+ };
1334
1907
 
1335
1908
  useEffect(() => {
1336
1909
  if (!isBrowser()) {
@@ -1347,7 +1920,10 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
1347
1920
  return;
1348
1921
  }
1349
1922
  resolveMatch(routeTable(), next).then((nextResolved) => {
1350
- startTransition(() => {
1923
+ // The back button is a navigation, and a navigation that animates in
1924
+ // one direction and cuts in the other would read as a bug in the
1925
+ // animation rather than as a decision.
1926
+ withViewTransition(nextResolved.viewTransition, () => {
1351
1927
  setResolved(nextResolved);
1352
1928
  });
1353
1929
  });
@@ -1358,55 +1934,53 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
1358
1934
  };
1359
1935
  }, []);
1360
1936
 
1361
- const router = useMemo<Router>(
1362
- () => ({
1363
- push: (to, options) => navigate(to, options),
1364
- replace: (to) => navigate(to, { replace: true }),
1365
- prefetch: async (to) => {
1366
- if (!isBrowser()) {
1367
- return;
1368
- }
1369
- const target = new URL(to, window.location.href);
1370
- const matched = matchRoute(routeTable().routes, target.pathname);
1371
- const load = matched?.route.page;
1372
- if (matched == null || load == null) {
1373
- return;
1374
- }
1375
- await Promise.all([
1376
- loadOnce(load),
1377
- ...matched.route.layouts.map((layout) => loadOnce(layout)),
1378
- ]);
1379
- },
1380
- refresh: async () => {
1381
- if (!isBrowser()) {
1382
- return;
1383
- }
1384
- const nextResolved = await resolveMatch(
1385
- routeTable(),
1386
- window.location.pathname + window.location.search,
1387
- );
1388
- startTransition(() => {
1389
- setResolved(nextResolved);
1390
- });
1391
- },
1392
- back: () => {
1393
- if (isBrowser()) {
1394
- window.history.back();
1395
- }
1396
- },
1397
- forward: () => {
1398
- if (isBrowser()) {
1399
- window.history.forward();
1400
- }
1401
- },
1402
- }),
1403
- [navigate],
1404
- );
1937
+ const router: Router = {
1938
+ push: (to, options) => navigate(to, options),
1939
+ replace: (to) => navigate(to, { replace: true }),
1940
+ prefetch: async (to) => {
1941
+ if (!isBrowser()) {
1942
+ return;
1943
+ }
1944
+ const target = new URL(to, window.location.href);
1945
+ const matched = matchRoute(routeTable().routes, target.pathname);
1946
+ const load = matched?.route.page;
1947
+ if (matched == null || load == null) {
1948
+ return;
1949
+ }
1950
+ await Promise.all([
1951
+ loadOnce(load),
1952
+ ...matched.route.layouts.map((layout) => loadOnce(layout)),
1953
+ ]);
1954
+ },
1955
+ refresh: async () => {
1956
+ if (!isBrowser()) {
1957
+ return;
1958
+ }
1959
+ const nextResolved = await resolveMatch(
1960
+ routeTable(),
1961
+ window.location.pathname + window.location.search,
1962
+ );
1963
+ // No view transition, and it is the one place that is right: a refresh
1964
+ // is the same URL resolved again, so a transition would animate a page
1965
+ // into itself — a cross-fade between two frames of the same thing,
1966
+ // which is a flicker with a name.
1967
+ startTransition(() => {
1968
+ setResolved(nextResolved);
1969
+ });
1970
+ },
1971
+ back: () => {
1972
+ if (isBrowser()) {
1973
+ window.history.back();
1974
+ }
1975
+ },
1976
+ forward: () => {
1977
+ if (isBrowser()) {
1978
+ window.history.forward();
1979
+ }
1980
+ },
1981
+ };
1405
1982
 
1406
- const value = useMemo<RouterState>(
1407
- () => ({ resolved, router, pending }),
1408
- [resolved, router, pending],
1409
- );
1983
+ const value: RouterState = { resolved, router, pending };
1410
1984
  return <RouterContext.Provider value={value}>{children}</RouterContext.Provider>;
1411
1985
  }
1412
1986
 
@@ -1428,11 +2002,32 @@ export hook useRoute(): RouteInfo {
1428
2002
  pathname: resolved.pathname,
1429
2003
  params: resolved.params,
1430
2004
  searchParams: resolved.searchParams,
1431
- data: resolved.data,
2005
+ data: useResolvedData(resolved),
1432
2006
  pending,
1433
2007
  };
1434
2008
  }
1435
2009
 
2010
+ /**
2011
+ * The loader's answer, waiting for it if the router deferred it.
2012
+ *
2013
+ * Both hooks that expose the data go through here, and both therefore suspend
2014
+ * when the answer is not in yet. That is the conservative choice rather than
2015
+ * the clever one: the alternative is handing back `undefined` for a value that
2016
+ * is on its way, which is a page reading a field that is about to exist and
2017
+ * finding nothing there, with nothing anywhere to say why.
2018
+ *
2019
+ * Suspending costs a caller *above* the innermost `<Suspense>` — a layout, a
2020
+ * masthead — the streaming it would otherwise have got, because React holds the
2021
+ * shell for a component that suspends with no boundary above it. That is
2022
+ * exactly what such a route did before the loader could be deferred at all, so
2023
+ * it is a benefit not taken rather than a regression, and it is visible: the
2024
+ * fallback does not appear.
2025
+ */
2026
+ hook useResolvedData(resolved: ResolvedRoute): mixed {
2027
+ const loader = resolved.deferred;
2028
+ return loader == null ? resolved.data : use(loader);
2029
+ }
2030
+
1436
2031
  /** Navigation. */
1437
2032
  export hook useRouter(): Router {
1438
2033
  return useRouterState().router;
@@ -1459,7 +2054,7 @@ export hook useRouter(): Router {
1459
2054
  * same file, keyed by route. Until it is there, this says what is true.
1460
2055
  */
1461
2056
  export hook useLoaderData(): mixed {
1462
- return useRouterState().resolved.data;
2057
+ return useResolvedData(useRouterState().resolved);
1463
2058
  }
1464
2059
 
1465
2060
  /**
@@ -1501,14 +2096,26 @@ export hook useLoaderData(): mixed {
1501
2096
  * The error boundary goes *outside* the fallback at the same depth. A throw
1502
2097
  * while the page is resolving has to reach a boundary that is still mounted,
1503
2098
  * and the `<Suspense>` is part of what the throw came out of.
2099
+ *
2100
+ * # Where the templates go
2101
+ *
2102
+ * Inside their own segment's layout and outside everything else at that depth
2103
+ * — the error boundary, the fallback and the page — which is what makes a
2104
+ * template's remount mean "this segment and what is under it" and a layout's
2105
+ * persistence mean "this segment's frame". The two files are the same wrapper
2106
+ * with opposite answers to one question, so they are one line apart here, and
2107
+ * the whole of the difference is the `key` — see [`insideTemplates`], which is
2108
+ * that line's other half.
1504
2109
  */
1505
2110
  export component RouteView() {
1506
2111
  const { resolved } = useRouterState();
1507
2112
  const { module, above } = resolved.errorBoundary;
1508
- const Page = pageComponent(resolved.page);
1509
- let element: React.Node = (
1510
- <Page params={resolved.params} searchParams={resolved.searchParams} data={resolved.data} />
1511
- );
2113
+ const loader = resolved.deferred;
2114
+ // The innermost element, so the `use` inside `AwaitedPage` suspends below
2115
+ // every boundary the loop below adds — which is what makes the layouts and
2116
+ // the fallback the shell rather than something waiting behind the loader.
2117
+ let element: React.Node =
2118
+ loader == null ? <RenderedPage data={resolved.data} /> : <AwaitedPage loader={loader} />;
1512
2119
 
1513
2120
  for (let depth = resolved.layouts.length; depth >= 0; depth -= 1) {
1514
2121
  // Backwards over a root-first list, so the deepest segment's fallback ends
@@ -1523,16 +2130,26 @@ export component RouteView() {
1523
2130
  const Fallback = loadingComponent(boundary.module);
1524
2131
  element = <Suspense fallback={<Fallback />}>{element}</Suspense>;
1525
2132
  }
2133
+ // Placed on `above` alone, and not on there being a module: a `null` one is
2134
+ // the framework's own error page, and where it renders is exactly the
2135
+ // question ubugeeei-prod/uf#351 asks. A project that declares no
2136
+ // `_uf.error.js` has the record the build synthesises for the router root,
2137
+ // whose `above` is the root's layouts — so the framework's page appears
2138
+ // inside the masthead rather than in place of the document. A table with no
2139
+ // record at all answers 0, which puts this boundary outside every layout,
2140
+ // where the outer one below already stood.
2141
+ //
1526
2142
  // Not around a route that already resolved to its error page: that page is
1527
2143
  // the boundary's own component, and wrapping it in the same boundary would
1528
2144
  // answer a throw inside it with itself.
1529
- if (depth === above && module != null && resolved.error == null) {
2145
+ if (depth === above && resolved.error == null) {
1530
2146
  element = (
1531
2147
  <RouteErrorBoundary module={module} resetKey={resolved.pathname}>
1532
2148
  {element}
1533
2149
  </RouteErrorBoundary>
1534
2150
  );
1535
2151
  }
2152
+ element = insideTemplates(element, resolved, depth);
1536
2153
  if (depth > 0) {
1537
2154
  const Layout = layoutComponent(resolved.layouts[depth - 1]);
1538
2155
  element = <Layout params={resolved.params}>{element}</Layout>;
@@ -1548,6 +2165,84 @@ export component RouteView() {
1548
2165
  );
1549
2166
  }
1550
2167
 
2168
+ /**
2169
+ * The page, with the loader's answer and the copy of it the browser hydrates
2170
+ * from.
2171
+ *
2172
+ * The two are rendered together because they are one fact told twice, and
2173
+ * anything that could put them out of step is a page whose first client render
2174
+ * disagrees with the document it was sent. Being one component is what keeps
2175
+ * the script in the same position in the tree on both sides — inside the
2176
+ * innermost `<Suspense>` when the route deferred its loader on the server, and
2177
+ * exactly there again on the client, where the data is already in hand and
2178
+ * nothing suspends at all.
2179
+ */
2180
+ component RenderedPage(data: mixed) {
2181
+ const { resolved } = useRouterState();
2182
+ const Page = pageComponent(resolved.page);
2183
+ return (
2184
+ <>
2185
+ <Page params={resolved.params} searchParams={resolved.searchParams} data={data} />
2186
+ {loaderDataScript(data)}
2187
+ </>
2188
+ );
2189
+ }
2190
+
2191
+ /**
2192
+ * The same page, once the loader the router deferred has answered.
2193
+ *
2194
+ * A component of its own rather than a `use` guarded by an `if` inside
2195
+ * [`RenderedPage`], so the call is unconditional where it is written: this one
2196
+ * is rendered only when there is a promise, and `RouteView` chooses between
2197
+ * them. `use` may legally be called conditionally, and code that reads as
2198
+ * though it may not is worth avoiding anyway.
2199
+ */
2200
+ component AwaitedPage(loader: Promise<mixed>) {
2201
+ return <RenderedPage data={use(loader)} />;
2202
+ }
2203
+
2204
+ /**
2205
+ * The loader's answer, embedded for the browser to hydrate from.
2206
+ *
2207
+ * In the tree rather than in the head, which is the third of the three options
2208
+ * ubugeeei-prod/uf#373 weighed and the only one that survives a deferred
2209
+ * loader. `server.js` wrote this into the head from the resolved route, and a
2210
+ * deferred answer does not exist when the head goes out — losing it would mean
2211
+ * every deferred route's loader running a second time in the browser, on the
2212
+ * way in, for data the document already contained.
2213
+ *
2214
+ * The two rejected options are worth naming. Writing it at the end of the body
2215
+ * from outside React would have worked — uf's client entry is a module script,
2216
+ * so it runs after parsing either way — but it would be markup inside the
2217
+ * hydration root that React did not render, which is the definition of a
2218
+ * mismatch. Emitting it through `bootstrapScriptContent` as a global is
2219
+ * React's own documented pattern and costs the one property this element has
2220
+ * that matters: `application/json` is data a browser does not execute, and a
2221
+ * script that is executed is a script a content security policy has to allow.
2222
+ *
2223
+ * `<` is escaped inside the JSON so a string holding `</script>` cannot end the
2224
+ * element early, and U+2028 and U+2029 because a JSON document is not
2225
+ * JavaScript source but is sometimes read as if it were.
2226
+ * `dangerouslySetInnerHTML` rather than a text child because React escapes a
2227
+ * text child and `&quot;` is not JSON any more. `security/no-dangerously-set-
2228
+ * inner-html` is about markup that came from somewhere and has to be sanitized
2229
+ * before a browser parses it as HTML; this is `JSON.stringify`'s output with
2230
+ * `<` escaped, in an element the browser never parses as HTML and never runs.
2231
+ * `docs/app/_uf.layout.js` carries the same suppression for the same reason.
2232
+ */
2233
+ function loaderDataScript(data: mixed): React.Node {
2234
+ if (data === undefined) {
2235
+ return null;
2236
+ }
2237
+ const json = JSON.stringify(data)
2238
+ .replace(/</g, "\\u003c")
2239
+ .replace(/\u2028/g, "\\u2028")
2240
+ .replace(/\u2029/g, "\\u2029");
2241
+ const html = { __html: json };
2242
+ // uf-lint-disable-next-line security/no-dangerously-set-inner-html
2243
+ return <script id={DATA_ID} type="application/json" dangerouslySetInnerHTML={html} />;
2244
+ }
2245
+
1551
2246
  /**
1552
2247
  * The component a page module renders: its default export, or the named
1553
2248
  * `Page` that `uf create` scaffolds. An MDX page always has a default export.
@@ -1580,6 +2275,58 @@ function loadingComponent(module: LoadingModule): React.ComponentType<{||}> {
1580
2275
  return renderable(component);
1581
2276
  }
1582
2277
 
2278
+ /**
2279
+ * `element`, wrapped in every template declared at `depth`.
2280
+ *
2281
+ * Outside the boundaries at that depth and inside the layout below it, and
2282
+ * backwards over a root-first list for the reason the fallbacks are: two
2283
+ * segments share a depth whenever the inner one declares no layout, and this
2284
+ * order is what keeps them nested the way the directories are.
2285
+ *
2286
+ * A function beside `RouteView` rather than a third loop inside it, and that
2287
+ * is not only for reading: a third nested loop assigning to `element` is what
2288
+ * the React Compiler's aliasing inference gave up on, and a component it
2289
+ * cannot compile is a component it does not memoise.
2290
+ */
2291
+ function insideTemplates(element: React.Node, resolved: ResolvedRoute, depth: number): React.Node {
2292
+ let out = element;
2293
+ for (let index = resolved.templates.length - 1; index >= 0; index -= 1) {
2294
+ const entry = resolved.templates[index];
2295
+ if (entry.above !== depth) {
2296
+ continue;
2297
+ }
2298
+ const Template = templateComponent(entry.module);
2299
+ // Keyed on the pathname, which is the whole difference between this file
2300
+ // and `_uf.layout.js`: React throws the subtree away and builds it again
2301
+ // whenever the key changes, and a navigation that changes only the query
2302
+ // string leaves it alone.
2303
+ out = (
2304
+ <Template key={resolved.pathname} params={resolved.params}>
2305
+ {out}
2306
+ </Template>
2307
+ );
2308
+ }
2309
+ return out;
2310
+ }
2311
+
2312
+ /**
2313
+ * The component a template module renders: `default`, or the named `Template`.
2314
+ *
2315
+ * The same props a layout receives, because it is a layout in every way but
2316
+ * one: it wraps `children`, it may read the route's parameters, and the only
2317
+ * difference is that `RouteView` gives the element a `key` so React builds it
2318
+ * again on every navigation.
2319
+ */
2320
+ function templateComponent(module: TemplateModule): React.ComponentType<LayoutRenderProps> {
2321
+ const component = module.default ?? module.Template;
2322
+ if (component == null) {
2323
+ throw new Error(
2324
+ "@uniflowed/router: a template module must export a component as `default` or `Template`",
2325
+ );
2326
+ }
2327
+ return renderable(component);
2328
+ }
2329
+
1583
2330
  /** The component a layout module renders: `default`, or the named `Layout`. */
1584
2331
  function layoutComponent(module: LayoutModule): React.ComponentType<LayoutRenderProps> {
1585
2332
  const component = module.default ?? module.Layout;
@@ -1641,9 +2388,83 @@ function absoluteUrl(value: string, base: void | string): string {
1641
2388
  }
1642
2389
  }
1643
2390
 
2391
+ /**
2392
+ * The `robots` directives, as one `content` string, or `null` for none.
2393
+ *
2394
+ * `null` rather than an empty string, so a page that declared nothing gets no
2395
+ * tag at all: "index, follow" is what a document with no `robots` meta already
2396
+ * means, and writing it out tells a crawler what it had already assumed.
2397
+ *
2398
+ * Each declared field contributes its directive and no field implies another.
2399
+ * `index: true` therefore emits `index` rather than nothing — the value is
2400
+ * there to overrule a section that said otherwise, and a directive that
2401
+ * disappeared because it agreed with the default would be a page saying
2402
+ * something and no evidence of it in the markup.
2403
+ */
2404
+ function robotsContent(robots: void | Robots): ?string {
2405
+ if (robots == null) {
2406
+ return null;
2407
+ }
2408
+ const directives: Array<string> = [];
2409
+ if (robots.index != null) {
2410
+ directives.push(robots.index ? "index" : "noindex");
2411
+ }
2412
+ if (robots.follow != null) {
2413
+ directives.push(robots.follow ? "follow" : "nofollow");
2414
+ }
2415
+ if (robots.maxSnippet != null) {
2416
+ directives.push(`max-snippet:${robots.maxSnippet}`);
2417
+ }
2418
+ if (robots.maxImagePreview != null) {
2419
+ directives.push(`max-image-preview:${robots.maxImagePreview}`);
2420
+ }
2421
+ return directives.length === 0 ? null : directives.join(", ");
2422
+ }
2423
+
2424
+ /**
2425
+ * One JSON-LD object as the text of a `<script>`.
2426
+ *
2427
+ * `<` is escaped so a string inside the data holding `</script>` cannot end
2428
+ * the element early — the same escape `server.js` applies to the embedded
2429
+ * loader data, and for the same reason: the text is the application's and the
2430
+ * element it lands in is terminated by a character sequence rather than by a
2431
+ * length. `dataScript` also escapes U+2028 and U+2029; those are about a
2432
+ * string being parsed as JavaScript source, and this one never is.
2433
+ */
2434
+ function jsonLdText(entry: JsonLd): string {
2435
+ return JSON.stringify(entry).replace(/</g, "\\u003c");
2436
+ }
2437
+
2438
+ /**
2439
+ * One JSON-LD object, as the element that carries it.
2440
+ *
2441
+ * A function rather than an element written inline, because the suppression
2442
+ * needs a line of its own; `docs/app/_uf.layout.js` has the same shape for the
2443
+ * same reason. `security/no-dangerously-set-inner-html` is about markup that
2444
+ * came from somewhere and has to be sanitized before a browser parses it as
2445
+ * HTML, and its escape hatch is a `@uniflowed/markdown` sanitizer — the right
2446
+ * answer for markup and no answer at all for JSON. This string is
2447
+ * `JSON.stringify`'s output with `<` escaped, so nothing in it can close the
2448
+ * element, and it is never parsed as HTML. There is also no other spelling:
2449
+ * React escapes a text child, so `{"@type":"Article"}` would reach the page as
2450
+ * `&quot;@type&quot;`, which is not JSON-LD any more.
2451
+ */
2452
+ function jsonLdScript(entry: JsonLd): React.Node {
2453
+ const text = jsonLdText(entry);
2454
+ const html = { __html: text };
2455
+ // uf-lint-disable-next-line security/no-dangerously-set-inner-html
2456
+ return <script key={text} type="application/ld+json" dangerouslySetInnerHTML={html} />;
2457
+ }
2458
+
1644
2459
  component Head(metadata: Metadata) {
1645
- const { title, description, metadataBase, canonical, openGraph, twitter } = metadata;
2460
+ const { title, description, metadataBase, canonical, robots } = metadata;
2461
+ const { alternates, pagination, jsonLd, openGraph, twitter } = metadata;
1646
2462
  const href = canonical != null ? absoluteUrl(canonical, metadataBase) : null;
2463
+ const crawler = robotsContent(robots);
2464
+ // Read out of `alternates` once rather than through it at every use: the map
2465
+ // is read inside a callback, and a refinement of `alternates.languages` does
2466
+ // not survive being carried into one.
2467
+ const languages = alternates?.languages;
1647
2468
  // A page that said what it is called has said what its card is called. Every
1648
2469
  // site that had to write both wrote the same string twice, and the second
1649
2470
  // one is the one that goes stale — the docs site shipped thirty pages whose
@@ -1666,7 +2487,33 @@ component Head(metadata: Metadata) {
1666
2487
  <>
1667
2488
  {title != null ? <title>{title}</title> : null}
1668
2489
  {description != null ? <meta name="description" content={description} /> : null}
2490
+ {crawler != null ? <meta name="robots" content={crawler} /> : null}
1669
2491
  {href != null ? <link rel="canonical" href={href} /> : null}
2492
+ {/* The set is reciprocal and includes this page, so a `hreflang` list is
2493
+ usually the same list on every page of it — which is why it belongs
2494
+ on the layout they share rather than on each of them.
2495
+
2496
+ `hrefLang` is React's spelling and it reaches the markup unchanged,
2497
+ which is worth knowing before grepping a document for `hreflang` and
2498
+ concluding it is missing. HTML attribute names are case-insensitive,
2499
+ so the parser every crawler runs reads it as the same attribute; the
2500
+ lowercase spelling is the one React warns about. */}
2501
+ {languages != null
2502
+ ? Object.keys(languages).map((language) => (
2503
+ <link
2504
+ key={language}
2505
+ rel="alternate"
2506
+ hrefLang={language}
2507
+ href={absoluteUrl(languages[language], metadataBase)}
2508
+ />
2509
+ ))
2510
+ : null}
2511
+ {pagination?.prev != null ? (
2512
+ <link rel="prev" href={absoluteUrl(pagination.prev, metadataBase)} />
2513
+ ) : null}
2514
+ {pagination?.next != null ? (
2515
+ <link rel="next" href={absoluteUrl(pagination.next, metadataBase)} />
2516
+ ) : null}
1670
2517
  {/* `og:url` *is* the canonical URL of the page, in Open Graph's own
1671
2518
  words, so one declaration answers both rather than asking a project
1672
2519
  to write the same URL twice and keep them in step. */}
@@ -1713,10 +2560,55 @@ component Head(metadata: Metadata) {
1713
2560
  {twitterImageAlt != null && twitter?.images != null ? (
1714
2561
  <meta name="twitter:image:alt" content={twitterImageAlt} />
1715
2562
  ) : null}
2563
+ {/* Last, and not hoisted into `<head>` with the rest: React hoists a
2564
+ `<title>`, a `<meta>` and a `<link>`, and not a script whose body it
2565
+ would have to carry. JSON-LD is read from anywhere in the document,
2566
+ so these render where the route does. */}
2567
+ {jsonLd != null ? jsonLd.map(jsonLdScript) : null}
1716
2568
  </>
1717
2569
  );
1718
2570
  }
1719
2571
 
2572
+ /**
2573
+ * Head elements a component contributes while it is rendering.
2574
+ *
2575
+ * `metadata` and `generateMetadata` are how a *route* says what it is, and
2576
+ * both are resolved before anything renders — which is what makes them work
2577
+ * for a crawler that runs no JavaScript. They are also declarations by the
2578
+ * route module, and part of what a page has to say is decided further in: a
2579
+ * paginated list knows its `prev` and `next` in the component that draws the
2580
+ * pager, and a breadcrumb knows the trail it has just walked.
2581
+ *
2582
+ * So this returns elements rather than writing to the head. Writing would have
2583
+ * to happen in an effect, an effect does not run on a server, and the result
2584
+ * would be a page whose tags are right in a browser and missing from the
2585
+ * crawler — `packages/web/head.js` is that escape hatch and says so at the top
2586
+ * of the file. Rendering is what puts a tag in a server-rendered head, so the
2587
+ * caller renders what comes back:
2588
+ *
2589
+ * export component Pager(page: number, of: number) {
2590
+ * const seo = useSeo({
2591
+ * pagination: {
2592
+ * prev: page > 1 ? `/posts?page=${page - 1}` : undefined,
2593
+ * next: page < of ? `/posts?page=${page + 1}` : undefined,
2594
+ * },
2595
+ * });
2596
+ * return <nav className="pager">{seo}…</nav>;
2597
+ * }
2598
+ *
2599
+ * The argument is a `Metadata` — the same type a route exports — because there
2600
+ * is one vocabulary for what a page says about itself, and a second one would
2601
+ * be a second place for it to be wrong. What this adds over rendering the tags
2602
+ * by hand is the thing a component three levels down cannot know:
2603
+ * `metadataBase`, which the root layout declared, and against which the
2604
+ * relative URLs written here are resolved.
2605
+ */
2606
+ export hook useSeo(seo: Metadata): React.Node {
2607
+ const { resolved } = useRouterState();
2608
+ const base = seo.metadataBase ?? resolved.metadata.metadataBase;
2609
+ return <Head metadata={base == null ? seo : { ...seo, metadataBase: base }} />;
2610
+ }
2611
+
1720
2612
  /** When a `Link` loads the route it points at. */
1721
2613
  export type LinkPrefetch = "off" | "intent" | "render";
1722
2614
 
@@ -1725,12 +2617,15 @@ export type LinkPrefetch = "off" | "intent" | "render";
1725
2617
  *
1726
2618
  * Renders a real anchor, so the link works before hydration and for a right
1727
2619
  * click, and takes over only a plain left click. `prefetch="intent"` (the
1728
- * default) loads the destination's chunks on hover or focus.
2620
+ * default) loads the destination's chunks on hover or focus, and
2621
+ * `transition={false}` makes this one navigation a cut — most navigations are
2622
+ * a link, so the opt-out in [`NavigateOptions`] has to be reachable from one.
1729
2623
  */
1730
2624
  export component Link(
1731
2625
  to: string,
1732
2626
  prefetch?: LinkPrefetch = "intent",
1733
2627
  replace?: boolean = false,
2628
+ transition?: boolean = true,
1734
2629
  children?: React.Node,
1735
2630
  className?: string,
1736
2631
  onClick?: (event: SyntheticMouseEvent<HTMLAnchorElement>) => mixed,
@@ -1769,7 +2664,7 @@ export component Link(
1769
2664
  return;
1770
2665
  }
1771
2666
  event.preventDefault();
1772
- router.push(to, { replace }).catch((error) => {
2667
+ router.push(to, { replace, transition }).catch((error) => {
1773
2668
  // A failed navigation falls back to the browser doing it.
1774
2669
  console.error(error);
1775
2670
  window.location.assign(to);
@@ -1800,14 +2695,37 @@ function isExternal(to: string): boolean {
1800
2695
  * The argument documents where the routes live; the table itself is generated
1801
2696
  * from that directory at build time and installed by the entry that starts
1802
2697
  * the app, so the component only has to render it.
2698
+ *
2699
+ * # Why the render anchor is here
2700
+ *
2701
+ * `RenderProvider` fixes the render's instant, time zone and random seed once,
2702
+ * writes them into the markup and reads them back on the client, which is what
2703
+ * makes `useRenderedAt` and `useRandom` agree across hydration. An application
2704
+ * that did not render one got no error — it got the old behaviour, which is a
2705
+ * silent hydration mismatch in every page with a clock or a shuffle on it. A
2706
+ * guarantee that depends on remembering to opt in is not one, so the router
2707
+ * provides it and an application that wants different values *replaces* it by
2708
+ * rendering its own inside this one. See ubugeeei-prod/uf#559.
2709
+ *
2710
+ * Above `RouterProvider` rather than below it, because the route's own
2711
+ * modules — layouts as much as pages — are things that read a clock, and a
2712
+ * masthead showing the time is the first component anybody writes that does.
2713
+ *
2714
+ * It is safe above a root layout that renders `<html>` only because the
2715
+ * envelope's carrier is a `<meta>`: React hoists one into the head of a
2716
+ * document it rendered, and to the front of a tree that is not one, where uf's
2717
+ * shell lifts it into the head it wrote itself. `packages/hooks/render.js` has
2718
+ * the argument, and it is the reason the carrier is no longer a `<script>`.
1803
2719
  */
1804
2720
  export function routerView(root: string): React.ComponentType<AppProps> {
1805
2721
  void root;
1806
2722
  component App(url: string, initial: ResolvedRoute) {
1807
2723
  return (
1808
- <RouterProvider url={url} initial={initial}>
1809
- <RouteView />
1810
- </RouterProvider>
2724
+ <RenderProvider>
2725
+ <RouterProvider url={url} initial={initial}>
2726
+ <RouteView />
2727
+ </RouterProvider>
2728
+ </RenderProvider>
1811
2729
  );
1812
2730
  }
1813
2731
  return App;