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