@uniflowed/router 0.0.0-alpha.12 → 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.
- package/action.js +198 -0
- package/client.js +35 -1
- package/handler.js +65 -2
- package/index.js +10 -1
- package/internal/action-endpoint.js +408 -0
- package/internal/action-wire.js +382 -0
- package/internal/hydration.js +861 -0
- package/internal/runtime.js +960 -63
- package/internal/stream.js +122 -12
- package/package.json +4 -2
- package/server.js +68 -39
package/internal/runtime.js
CHANGED
|
@@ -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,6 +248,69 @@ 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?: {
|
|
170
315
|
/**
|
|
171
316
|
* The title a share card shows.
|
|
@@ -262,6 +407,26 @@ export type RouteRecord = {|
|
|
|
262
407
|
* exactly what it had before.
|
|
263
408
|
*/
|
|
264
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>,
|
|
265
430
|
|};
|
|
266
431
|
|
|
267
432
|
/**
|
|
@@ -290,7 +455,19 @@ export type NotFoundBoundary = {|
|
|
|
290
455
|
readonly path: string,
|
|
291
456
|
readonly mdx: boolean,
|
|
292
457
|
readonly file: string,
|
|
293
|
-
|
|
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>,
|
|
294
471
|
readonly layouts: $ReadOnlyArray<() => Promise<LayoutModule>>,
|
|
295
472
|
|};
|
|
296
473
|
|
|
@@ -305,7 +482,8 @@ export type NotFoundBoundary = {|
|
|
|
305
482
|
export type ErrorBoundary = {|
|
|
306
483
|
readonly path: string,
|
|
307
484
|
readonly file: string,
|
|
308
|
-
|
|
485
|
+
/** `null` for the synthesised root record; see [`NotFoundBoundary`]`.page`. */
|
|
486
|
+
readonly module: ?() => Promise<ErrorModule>,
|
|
309
487
|
readonly layouts: $ReadOnlyArray<() => Promise<LayoutModule>>,
|
|
310
488
|
|};
|
|
311
489
|
|
|
@@ -358,8 +536,8 @@ export function routeErrorStatus(error: RouteError): 401 | 403 | 500 {
|
|
|
358
536
|
}
|
|
359
537
|
|
|
360
538
|
/**
|
|
361
|
-
* A match whose modules are loaded and whose loader has run — or,
|
|
362
|
-
* 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.
|
|
363
541
|
*/
|
|
364
542
|
export type ResolvedRoute = {|
|
|
365
543
|
readonly pathname: string,
|
|
@@ -369,8 +547,34 @@ export type ResolvedRoute = {|
|
|
|
369
547
|
readonly searchParams: SearchParams,
|
|
370
548
|
readonly page: PageModule,
|
|
371
549
|
readonly layouts: $ReadOnlyArray<LayoutModule>,
|
|
550
|
+
/** What the loader returned, once it has. `undefined` while `deferred` is set. */
|
|
372
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>,
|
|
373
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,
|
|
374
578
|
readonly status: 200 | 401 | 403 | 404 | 500,
|
|
375
579
|
/**
|
|
376
580
|
* Set when this resolution *is* the error page: the loader threw, or the
|
|
@@ -401,6 +605,20 @@ export type ResolvedRoute = {|
|
|
|
401
605
|
* ordinary case and renders exactly the tree it did before.
|
|
402
606
|
*/
|
|
403
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
|
+
|}>,
|
|
404
622
|
|};
|
|
405
623
|
|
|
406
624
|
/** Thrown by `notFound()`; the renderer answers with the not-found page. */
|
|
@@ -672,11 +890,24 @@ function loadOnce<T>(load: () => Promise<T>): Promise<T> {
|
|
|
672
890
|
* before `hydrateRoot`, so a rejection there is not an error page — it is no
|
|
673
891
|
* `hydrateRoot` call at all, and the document the server sent stays on screen
|
|
674
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.
|
|
675
906
|
*/
|
|
676
907
|
export async function resolveMatch(
|
|
677
908
|
table: RouteTable,
|
|
678
909
|
url: string,
|
|
679
|
-
options?:
|
|
910
|
+
options?: ResolveOptions,
|
|
680
911
|
): Promise<ResolvedRoute> {
|
|
681
912
|
try {
|
|
682
913
|
return await resolveRoute(table, url, options);
|
|
@@ -688,15 +919,52 @@ export async function resolveMatch(
|
|
|
688
919
|
}
|
|
689
920
|
}
|
|
690
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
|
+
|
|
691
955
|
async function resolveRoute(
|
|
692
956
|
table: RouteTable,
|
|
693
957
|
url: string,
|
|
694
|
-
options?:
|
|
958
|
+
options?: ResolveOptions,
|
|
695
959
|
): Promise<ResolvedRoute> {
|
|
696
960
|
const { pathname, search } = splitUrl(url);
|
|
697
961
|
const searchParams = parseSearch(search);
|
|
698
962
|
const matched = matchRoute(table.routes, pathname);
|
|
699
963
|
|
|
964
|
+
if (matched != null) {
|
|
965
|
+
options?.onMatch?.(matched.route.path);
|
|
966
|
+
}
|
|
967
|
+
|
|
700
968
|
if (matched == null) {
|
|
701
969
|
return resolveNotFound(table, pathname, search, searchParams);
|
|
702
970
|
}
|
|
@@ -724,22 +992,44 @@ async function resolveRoute(
|
|
|
724
992
|
// Started alongside for the same reason, and awaited at the end: a fallback
|
|
725
993
|
// depends on nothing the loader produces.
|
|
726
994
|
const loading = resolveLoading(matched.route, matched.route.layouts.length);
|
|
995
|
+
const templates = resolveTemplates(matched.route, matched.route.layouts.length);
|
|
727
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.
|
|
728
1017
|
let data: mixed = options?.data;
|
|
1018
|
+
let deferred: ?Promise<mixed> = null;
|
|
729
1019
|
if (options?.skipLoader !== true && typeof page.loader === "function") {
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
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
|
+
}
|
|
743
1033
|
}
|
|
744
1034
|
|
|
745
1035
|
const metadata = await resolveMetadata(page, layouts, {
|
|
@@ -756,14 +1046,52 @@ async function resolveRoute(
|
|
|
756
1046
|
page,
|
|
757
1047
|
layouts,
|
|
758
1048
|
data,
|
|
1049
|
+
deferred,
|
|
759
1050
|
metadata,
|
|
1051
|
+
viewTransition: resolveViewTransition(page, layouts),
|
|
760
1052
|
status: 200,
|
|
761
1053
|
error: null,
|
|
762
1054
|
errorBoundary: await boundary,
|
|
763
1055
|
loading: await loading,
|
|
1056
|
+
templates: await templates,
|
|
764
1057
|
};
|
|
765
1058
|
}
|
|
766
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
|
+
|
|
767
1095
|
/**
|
|
768
1096
|
* The route's loading boundaries, imported.
|
|
769
1097
|
*
|
|
@@ -874,13 +1202,22 @@ async function resolveErrorBoundary(
|
|
|
874
1202
|
// the boundary covering it, and an `above` past the end would compose the
|
|
875
1203
|
// layouts out of nothing.
|
|
876
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
|
+
}
|
|
877
1212
|
try {
|
|
878
|
-
return { module: await loadOnce(
|
|
1213
|
+
return { module: await loadOnce(load), above };
|
|
879
1214
|
} catch {
|
|
880
1215
|
// A boundary whose module will not load cannot be the answer to a throw,
|
|
881
1216
|
// and this is why the field is nullable: containment must not itself
|
|
882
|
-
// depend on an import working.
|
|
883
|
-
|
|
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 };
|
|
884
1221
|
}
|
|
885
1222
|
}
|
|
886
1223
|
|
|
@@ -903,11 +1240,15 @@ async function resolveError(
|
|
|
903
1240
|
let module: ?ErrorModule = null;
|
|
904
1241
|
let layouts: $ReadOnlyArray<LayoutModule> = [];
|
|
905
1242
|
if (boundary != null) {
|
|
1243
|
+
const load = boundary.module;
|
|
906
1244
|
try {
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
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);
|
|
911
1252
|
} catch {
|
|
912
1253
|
// See `resolveErrorBoundary`: the framework's own page answers instead.
|
|
913
1254
|
module = null;
|
|
@@ -929,7 +1270,12 @@ async function resolveError(
|
|
|
929
1270
|
page: { default: ResolvedErrorPage },
|
|
930
1271
|
layouts,
|
|
931
1272
|
data: undefined,
|
|
1273
|
+
deferred: null,
|
|
932
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),
|
|
933
1279
|
status: routeErrorStatus(routeError),
|
|
934
1280
|
error: routeError,
|
|
935
1281
|
// All of the boundary's layouts are above it, and no inner boundary is
|
|
@@ -939,6 +1285,7 @@ async function resolveError(
|
|
|
939
1285
|
// resolved with. A fallback around it would be a boundary that can never
|
|
940
1286
|
// show, which is worse than none.
|
|
941
1287
|
loading: [],
|
|
1288
|
+
templates: [],
|
|
942
1289
|
};
|
|
943
1290
|
}
|
|
944
1291
|
|
|
@@ -953,6 +1300,21 @@ async function resolveError(
|
|
|
953
1300
|
* layouts *below* the boundary — so `app/guide/[slug]/_uf.layout.js` would
|
|
954
1301
|
* wrap a 404 that `app/guide/_uf.not-found.js` answered, which is the layout
|
|
955
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.
|
|
956
1318
|
*/
|
|
957
1319
|
async function resolveNotFound(
|
|
958
1320
|
table: RouteTable,
|
|
@@ -961,26 +1323,12 @@ async function resolveNotFound(
|
|
|
961
1323
|
searchParams: SearchParams,
|
|
962
1324
|
): Promise<ResolvedRoute> {
|
|
963
1325
|
const record = nearestBoundary(table.notFound, pathname);
|
|
964
|
-
|
|
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
|
-
}
|
|
1326
|
+
const load = record?.page;
|
|
981
1327
|
const [page, ...layouts] = await Promise.all([
|
|
982
|
-
|
|
983
|
-
|
|
1328
|
+
load == null
|
|
1329
|
+
? Promise.resolve<PageModule>({ default: DefaultNotFound, metadata: { title: "Not found" } })
|
|
1330
|
+
: loadOnce(load),
|
|
1331
|
+
...(record?.layouts ?? []).map((layout) => loadOnce(layout)),
|
|
984
1332
|
]);
|
|
985
1333
|
const metadata = await resolveMetadata(page, layouts, {
|
|
986
1334
|
params: {},
|
|
@@ -996,7 +1344,9 @@ async function resolveNotFound(
|
|
|
996
1344
|
page,
|
|
997
1345
|
layouts,
|
|
998
1346
|
data: undefined,
|
|
1347
|
+
deferred: null,
|
|
999
1348
|
metadata,
|
|
1349
|
+
viewTransition: resolveViewTransition(page, layouts),
|
|
1000
1350
|
status: 404,
|
|
1001
1351
|
error: null,
|
|
1002
1352
|
// A not-found page is a page: one that throws is contained like any other.
|
|
@@ -1005,18 +1355,36 @@ async function resolveNotFound(
|
|
|
1005
1355
|
// record and the loading files are a property of the route that was walked
|
|
1006
1356
|
// to, which this URL never reached. Nothing to wait for, so no boundary.
|
|
1007
1357
|
loading: [],
|
|
1358
|
+
// Templates are accumulated on that same walk, and for the same reason.
|
|
1359
|
+
templates: [],
|
|
1008
1360
|
};
|
|
1009
1361
|
}
|
|
1010
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
|
+
*/
|
|
1011
1371
|
async function resolveMetadata(
|
|
1012
1372
|
page: PageModule,
|
|
1013
1373
|
layouts: $ReadOnlyArray<LayoutModule>,
|
|
1014
1374
|
args: MetadataArgs,
|
|
1015
1375
|
): Promise<Metadata> {
|
|
1016
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
|
+
|
|
1017
1385
|
for (const layout of layouts) {
|
|
1018
1386
|
if (layout.metadata != null) {
|
|
1019
|
-
|
|
1387
|
+
take(layout.metadata);
|
|
1020
1388
|
}
|
|
1021
1389
|
}
|
|
1022
1390
|
if (page.frontmatter != null) {
|
|
@@ -1028,12 +1396,42 @@ async function resolveMetadata(
|
|
|
1028
1396
|
};
|
|
1029
1397
|
}
|
|
1030
1398
|
if (page.metadata != null) {
|
|
1031
|
-
|
|
1399
|
+
take(page.metadata);
|
|
1032
1400
|
}
|
|
1033
1401
|
if (typeof page.generateMetadata === "function") {
|
|
1034
|
-
|
|
1402
|
+
take(await page.generateMetadata(args));
|
|
1035
1403
|
}
|
|
1036
|
-
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;
|
|
1037
1435
|
}
|
|
1038
1436
|
|
|
1039
1437
|
component DefaultNotFound() {
|
|
@@ -1197,12 +1595,178 @@ class RouteErrorBoundary extends React.Component<RouteErrorBoundaryProps, RouteE
|
|
|
1197
1595
|
}
|
|
1198
1596
|
}
|
|
1199
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
|
+
|
|
1200
1749
|
// ---------------------------------------------------------------------------
|
|
1201
1750
|
// The React binding
|
|
1202
1751
|
// ---------------------------------------------------------------------------
|
|
1203
1752
|
|
|
1204
1753
|
/** How a navigation is performed. */
|
|
1205
|
-
export type NavigateOptions = {|
|
|
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
|
+
|};
|
|
1206
1770
|
|
|
1207
1771
|
/** What `useRouter()` returns. */
|
|
1208
1772
|
export type Router = {|
|
|
@@ -1312,10 +1876,15 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
|
|
|
1312
1876
|
} else {
|
|
1313
1877
|
window.history.pushState(null, "", next + target.hash);
|
|
1314
1878
|
}
|
|
1315
|
-
|
|
1879
|
+
const commit = () => {
|
|
1316
1880
|
setResolved(nextResolved);
|
|
1317
1881
|
setPending(false);
|
|
1318
|
-
}
|
|
1882
|
+
};
|
|
1883
|
+
if (options?.transition === false) {
|
|
1884
|
+
startTransition(commit);
|
|
1885
|
+
} else {
|
|
1886
|
+
withViewTransition(nextResolved.viewTransition, commit);
|
|
1887
|
+
}
|
|
1319
1888
|
if (options?.scroll !== false) {
|
|
1320
1889
|
if (target.hash !== "") {
|
|
1321
1890
|
const element = document.getElementById(target.hash.slice(1));
|
|
@@ -1347,7 +1916,10 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
|
|
|
1347
1916
|
return;
|
|
1348
1917
|
}
|
|
1349
1918
|
resolveMatch(routeTable(), next).then((nextResolved) => {
|
|
1350
|
-
|
|
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, () => {
|
|
1351
1923
|
setResolved(nextResolved);
|
|
1352
1924
|
});
|
|
1353
1925
|
});
|
|
@@ -1385,6 +1957,10 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
|
|
|
1385
1957
|
routeTable(),
|
|
1386
1958
|
window.location.pathname + window.location.search,
|
|
1387
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.
|
|
1388
1964
|
startTransition(() => {
|
|
1389
1965
|
setResolved(nextResolved);
|
|
1390
1966
|
});
|
|
@@ -1428,11 +2004,32 @@ export hook useRoute(): RouteInfo {
|
|
|
1428
2004
|
pathname: resolved.pathname,
|
|
1429
2005
|
params: resolved.params,
|
|
1430
2006
|
searchParams: resolved.searchParams,
|
|
1431
|
-
data: resolved
|
|
2007
|
+
data: useResolvedData(resolved),
|
|
1432
2008
|
pending,
|
|
1433
2009
|
};
|
|
1434
2010
|
}
|
|
1435
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
|
+
|
|
1436
2033
|
/** Navigation. */
|
|
1437
2034
|
export hook useRouter(): Router {
|
|
1438
2035
|
return useRouterState().router;
|
|
@@ -1459,7 +2056,7 @@ export hook useRouter(): Router {
|
|
|
1459
2056
|
* same file, keyed by route. Until it is there, this says what is true.
|
|
1460
2057
|
*/
|
|
1461
2058
|
export hook useLoaderData(): mixed {
|
|
1462
|
-
return useRouterState().resolved
|
|
2059
|
+
return useResolvedData(useRouterState().resolved);
|
|
1463
2060
|
}
|
|
1464
2061
|
|
|
1465
2062
|
/**
|
|
@@ -1501,14 +2098,26 @@ export hook useLoaderData(): mixed {
|
|
|
1501
2098
|
* The error boundary goes *outside* the fallback at the same depth. A throw
|
|
1502
2099
|
* while the page is resolving has to reach a boundary that is still mounted,
|
|
1503
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.
|
|
1504
2111
|
*/
|
|
1505
2112
|
export component RouteView() {
|
|
1506
2113
|
const { resolved } = useRouterState();
|
|
1507
2114
|
const { module, above } = resolved.errorBoundary;
|
|
1508
|
-
const
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
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} />;
|
|
1512
2121
|
|
|
1513
2122
|
for (let depth = resolved.layouts.length; depth >= 0; depth -= 1) {
|
|
1514
2123
|
// Backwards over a root-first list, so the deepest segment's fallback ends
|
|
@@ -1523,16 +2132,26 @@ export component RouteView() {
|
|
|
1523
2132
|
const Fallback = loadingComponent(boundary.module);
|
|
1524
2133
|
element = <Suspense fallback={<Fallback />}>{element}</Suspense>;
|
|
1525
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
|
+
//
|
|
1526
2144
|
// Not around a route that already resolved to its error page: that page is
|
|
1527
2145
|
// the boundary's own component, and wrapping it in the same boundary would
|
|
1528
2146
|
// answer a throw inside it with itself.
|
|
1529
|
-
if (depth === above &&
|
|
2147
|
+
if (depth === above && resolved.error == null) {
|
|
1530
2148
|
element = (
|
|
1531
2149
|
<RouteErrorBoundary module={module} resetKey={resolved.pathname}>
|
|
1532
2150
|
{element}
|
|
1533
2151
|
</RouteErrorBoundary>
|
|
1534
2152
|
);
|
|
1535
2153
|
}
|
|
2154
|
+
element = insideTemplates(element, resolved, depth);
|
|
1536
2155
|
if (depth > 0) {
|
|
1537
2156
|
const Layout = layoutComponent(resolved.layouts[depth - 1]);
|
|
1538
2157
|
element = <Layout params={resolved.params}>{element}</Layout>;
|
|
@@ -1548,6 +2167,84 @@ export component RouteView() {
|
|
|
1548
2167
|
);
|
|
1549
2168
|
}
|
|
1550
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 `"` 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
|
+
|
|
1551
2248
|
/**
|
|
1552
2249
|
* The component a page module renders: its default export, or the named
|
|
1553
2250
|
* `Page` that `uf create` scaffolds. An MDX page always has a default export.
|
|
@@ -1580,6 +2277,58 @@ function loadingComponent(module: LoadingModule): React.ComponentType<{||}> {
|
|
|
1580
2277
|
return renderable(component);
|
|
1581
2278
|
}
|
|
1582
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
|
+
|
|
1583
2332
|
/** The component a layout module renders: `default`, or the named `Layout`. */
|
|
1584
2333
|
function layoutComponent(module: LayoutModule): React.ComponentType<LayoutRenderProps> {
|
|
1585
2334
|
const component = module.default ?? module.Layout;
|
|
@@ -1641,9 +2390,83 @@ function absoluteUrl(value: string, base: void | string): string {
|
|
|
1641
2390
|
}
|
|
1642
2391
|
}
|
|
1643
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
|
+
* `"@type"`, 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
|
+
|
|
1644
2461
|
component Head(metadata: Metadata) {
|
|
1645
|
-
const { title, description, metadataBase, canonical,
|
|
2462
|
+
const { title, description, metadataBase, canonical, robots } = metadata;
|
|
2463
|
+
const { alternates, pagination, jsonLd, openGraph, twitter } = metadata;
|
|
1646
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;
|
|
1647
2470
|
// A page that said what it is called has said what its card is called. Every
|
|
1648
2471
|
// site that had to write both wrote the same string twice, and the second
|
|
1649
2472
|
// one is the one that goes stale — the docs site shipped thirty pages whose
|
|
@@ -1666,7 +2489,33 @@ component Head(metadata: Metadata) {
|
|
|
1666
2489
|
<>
|
|
1667
2490
|
{title != null ? <title>{title}</title> : null}
|
|
1668
2491
|
{description != null ? <meta name="description" content={description} /> : null}
|
|
2492
|
+
{crawler != null ? <meta name="robots" content={crawler} /> : null}
|
|
1669
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}
|
|
1670
2519
|
{/* `og:url` *is* the canonical URL of the page, in Open Graph's own
|
|
1671
2520
|
words, so one declaration answers both rather than asking a project
|
|
1672
2521
|
to write the same URL twice and keep them in step. */}
|
|
@@ -1713,10 +2562,55 @@ component Head(metadata: Metadata) {
|
|
|
1713
2562
|
{twitterImageAlt != null && twitter?.images != null ? (
|
|
1714
2563
|
<meta name="twitter:image:alt" content={twitterImageAlt} />
|
|
1715
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}
|
|
1716
2570
|
</>
|
|
1717
2571
|
);
|
|
1718
2572
|
}
|
|
1719
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
|
+
|
|
1720
2614
|
/** When a `Link` loads the route it points at. */
|
|
1721
2615
|
export type LinkPrefetch = "off" | "intent" | "render";
|
|
1722
2616
|
|
|
@@ -1725,12 +2619,15 @@ export type LinkPrefetch = "off" | "intent" | "render";
|
|
|
1725
2619
|
*
|
|
1726
2620
|
* Renders a real anchor, so the link works before hydration and for a right
|
|
1727
2621
|
* click, and takes over only a plain left click. `prefetch="intent"` (the
|
|
1728
|
-
* 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.
|
|
1729
2625
|
*/
|
|
1730
2626
|
export component Link(
|
|
1731
2627
|
to: string,
|
|
1732
2628
|
prefetch?: LinkPrefetch = "intent",
|
|
1733
2629
|
replace?: boolean = false,
|
|
2630
|
+
transition?: boolean = true,
|
|
1734
2631
|
children?: React.Node,
|
|
1735
2632
|
className?: string,
|
|
1736
2633
|
onClick?: (event: SyntheticMouseEvent<HTMLAnchorElement>) => mixed,
|
|
@@ -1769,7 +2666,7 @@ export component Link(
|
|
|
1769
2666
|
return;
|
|
1770
2667
|
}
|
|
1771
2668
|
event.preventDefault();
|
|
1772
|
-
router.push(to, { replace }).catch((error) => {
|
|
2669
|
+
router.push(to, { replace, transition }).catch((error) => {
|
|
1773
2670
|
// A failed navigation falls back to the browser doing it.
|
|
1774
2671
|
console.error(error);
|
|
1775
2672
|
window.location.assign(to);
|