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