@uniflowed/router 0.0.0-alpha.34 → 0.0.0-alpha.35
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/client.js +65 -0
- package/internal/boundaries.js +48 -25
- package/internal/compose.js +478 -0
- package/internal/error-view.js +189 -0
- package/internal/flight-browser.js +230 -0
- package/internal/flight-chunks.js +181 -0
- package/internal/flight-ssr.js +72 -0
- package/internal/flight.js +132 -0
- package/internal/head.js +219 -0
- package/internal/resolve.js +1615 -0
- package/internal/runtime.js +522 -2452
- package/internal/server-route.js +52 -0
- package/internal/stream.js +156 -3
- package/package.json +18 -6
- package/rsc.js +323 -0
- package/server-components.js +155 -0
- package/server.js +428 -1
package/internal/runtime.js
CHANGED
|
@@ -1,1851 +1,141 @@
|
|
|
1
1
|
// @flow
|
|
2
2
|
//
|
|
3
|
-
// The router runtime:
|
|
3
|
+
// The router runtime: the browser's binding.
|
|
4
4
|
//
|
|
5
|
-
// A
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// the
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
useState,
|
|
21
|
-
useSyncExternalStore,
|
|
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 [`payloadElements`]
|
|
40
|
-
// — so the module that renders it is this one rather than `../server.js`.
|
|
41
|
-
import { DATA_ID } from "./document.js";
|
|
42
|
-
|
|
43
|
-
// The payload the loader's answer is written as, and the rows it defers. Row 0
|
|
44
|
-
// is the element `DATA_ID` names and is byte-identical to what this file wrote
|
|
45
|
-
// inline before the payload existed whenever nothing is deferred; a promise
|
|
46
|
-
// anywhere in the data turns into a reference and a row of its own. See
|
|
47
|
-
// `./payload.js` for the format and ubugeeei-prod/uf#519 for the half of it
|
|
48
|
-
// that is still an element payload rather than a data one.
|
|
49
|
-
import {
|
|
50
|
-
type PayloadRowMessage,
|
|
51
|
-
PayloadRowError,
|
|
52
|
-
encodePayload,
|
|
53
|
-
encodeRowValue,
|
|
54
|
-
payloadJson,
|
|
55
|
-
} from "./payload.js";
|
|
56
|
-
|
|
57
|
-
// The development-only half of [`RouteView`]: the marks that say which DOM
|
|
58
|
-
// subtree each boundary owns, and the report that reads them. Every reference
|
|
59
|
-
// to it is inside a `BOUNDARY_MARKS` branch, which is why a static import is
|
|
60
|
-
// safe here where `../client.js` needs a dynamic one — a component cannot be
|
|
61
|
-
// awaited in the middle of a render, and `false` folds the references away
|
|
62
|
-
// before the bundler is asked to keep the module. See [`BOUNDARY_MARKS`].
|
|
63
|
-
import {
|
|
64
|
-
BoundaryReporter,
|
|
65
|
-
ROOT_ERROR_ID,
|
|
66
|
-
ROUTE_ERROR_ID,
|
|
67
|
-
insideBoundary,
|
|
68
|
-
routeBoundaries,
|
|
69
|
-
suspenseId,
|
|
70
|
-
} from "./boundaries.js";
|
|
71
|
-
import {
|
|
72
|
-
ForbiddenError,
|
|
73
|
-
NotFoundError,
|
|
74
|
-
RedirectError,
|
|
75
|
-
UnauthorizedError,
|
|
76
|
-
hasClientPage,
|
|
77
|
-
matchIn,
|
|
78
|
-
matchRoute,
|
|
79
|
-
nearestBoundary,
|
|
80
|
-
parseSearch,
|
|
81
|
-
routeErrorStatus,
|
|
82
|
-
splitUrl,
|
|
83
|
-
} from "./routing.js";
|
|
84
|
-
import type {
|
|
85
|
-
ErrorBoundary as RoutingErrorBoundary,
|
|
86
|
-
LoadingRecord as RoutingLoadingRecord,
|
|
87
|
-
NotFoundBoundary as RoutingNotFoundBoundary,
|
|
88
|
-
RouteError,
|
|
89
|
-
RouteMatch as RoutingRouteMatch,
|
|
90
|
-
RouteParams,
|
|
91
|
-
RouteRecord as RoutingRouteRecord,
|
|
92
|
-
RouteTable as RoutingRouteTable,
|
|
93
|
-
SearchParams,
|
|
94
|
-
SlotRecord as RoutingSlotRecord,
|
|
95
|
-
SlotRouteRecord as RoutingSlotRouteRecord,
|
|
96
|
-
TemplateRecord as RoutingTemplateRecord,
|
|
97
|
-
} from "./routing.js";
|
|
98
|
-
|
|
99
|
-
export type { RouteError, RouteParamSpec, RouteParams, SearchParams } from "./routing.js";
|
|
100
|
-
|
|
101
|
-
export {
|
|
102
|
-
ForbiddenError,
|
|
103
|
-
NotFoundError,
|
|
104
|
-
RedirectError,
|
|
105
|
-
UnauthorizedError,
|
|
106
|
-
buildRoute,
|
|
107
|
-
forbidden,
|
|
108
|
-
hasClientPage,
|
|
109
|
-
matchRoute,
|
|
110
|
-
notFound,
|
|
111
|
-
parseSearch,
|
|
112
|
-
permanentRedirect,
|
|
113
|
-
redirect,
|
|
114
|
-
routeErrorStatus,
|
|
115
|
-
splitUrl,
|
|
116
|
-
unauthorized,
|
|
117
|
-
} from "./routing.js";
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* A component found in a route module.
|
|
121
|
-
*
|
|
122
|
-
* `React.ComponentType<empty>` is "some React component", and it is a claim
|
|
123
|
-
* rather than a shrug. `ComponentType` is contravariant in its props — Flow's
|
|
124
|
-
* library definition writes it `component(...P)` with `in P` — so `empty` is
|
|
125
|
-
* the *top* of the component types: every component is one, and nothing may be
|
|
126
|
-
* passed to one until a caller has said which props it is passing. That is
|
|
127
|
-
* exactly what is known here. The router finds these by dynamic import, and
|
|
128
|
-
* nobody has told it what a page's props are.
|
|
129
|
-
*
|
|
130
|
-
* It cannot be `React.ComponentType<PageRenderProps>`, the props the router
|
|
131
|
-
* actually passes, because Flow's `component` syntax gives a component *exact*
|
|
132
|
-
* props and a page is free to want none of them. This repository's own pages
|
|
133
|
-
* and layouts are `component NotFound()` and
|
|
134
|
-
* `component Layout(children: React.Node)`, and against the props the router
|
|
135
|
-
* hands them that reads:
|
|
136
|
-
*
|
|
137
|
-
* error[incompatible-type]: property `data`, property `params`, and
|
|
138
|
-
* property `searchParams` are extra in `PageRenderProps` but missing in
|
|
139
|
-
* `props of component NotFound`. Exact objects do not accept extra props.
|
|
140
|
-
*
|
|
141
|
-
* React passing a component a prop it did not declare is allowed and always
|
|
142
|
-
* has been. `renderable` is the one line that says so.
|
|
143
|
-
*/
|
|
144
|
-
type RouteComponent = React.ComponentType<empty>;
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* The props `RouteView` gives the page it renders.
|
|
148
|
-
*
|
|
149
|
-
* The same three as the public `PageProps` in `../index.js`, at the arguments
|
|
150
|
-
* the runtime instantiates it with: the runtime knows the parameters as
|
|
151
|
-
* strings and the loader's data as `mixed`, and a page narrows both by
|
|
152
|
-
* annotating its own props.
|
|
153
|
-
*/
|
|
154
|
-
type PageRenderProps = {|
|
|
155
|
-
readonly params: RouteParams,
|
|
156
|
-
readonly searchParams: SearchParams,
|
|
157
|
-
readonly data: mixed,
|
|
158
|
-
|};
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* The props `RouteView` gives each layout, outermost first.
|
|
162
|
-
*
|
|
163
|
-
* Inexact, and that is the parallel routes reaching the type: a layout on a
|
|
164
|
-
* segment that declares `@team` is handed a `team` prop beside `children`, and
|
|
165
|
-
* the names are the project's rather than this file's. Every extra prop is a
|
|
166
|
-
* `React.Node` — a rendered slot, or `null` when the URL addressed neither the
|
|
167
|
-
* slot's routes nor a `$default.js`.
|
|
168
|
-
*
|
|
169
|
-
* The exactness is not lost so much as moved: what a layout may be *given* is
|
|
170
|
-
* open, and what it *declares* is still its own exact props type, which is
|
|
171
|
-
* where a typo in a slot name shows up.
|
|
172
|
-
*/
|
|
173
|
-
type LayoutRenderProps = {
|
|
174
|
-
readonly params: RouteParams,
|
|
175
|
-
readonly children: React.Node,
|
|
176
|
-
...
|
|
177
|
-
};
|
|
178
|
-
|
|
179
|
-
/** What a page module may export. The component is `default` or `Page`. */
|
|
180
|
-
export type PageModule = {
|
|
181
|
-
readonly default?: RouteComponent,
|
|
182
|
-
readonly Page?: RouteComponent,
|
|
183
|
-
readonly loader?: (args: LoaderArgs) => mixed | Promise<mixed>,
|
|
184
|
-
readonly metadata?: Metadata,
|
|
185
|
-
readonly generateMetadata?: (args: MetadataArgs) => Metadata | Promise<Metadata>,
|
|
186
|
-
readonly generateStaticParams?: () =>
|
|
187
|
-
| $ReadOnlyArray<RouteParams>
|
|
188
|
-
| Promise<$ReadOnlyArray<RouteParams>>,
|
|
189
|
-
readonly frontmatter?: { readonly title?: string, readonly description?: string, ... },
|
|
190
|
-
/**
|
|
191
|
-
* What a stylesheet calls the transition this route arrives under.
|
|
192
|
-
*
|
|
193
|
-
* Not a switch. Every client navigation opts into a view transition where
|
|
194
|
-
* the browser has one, and this is how one arrival is told from another —
|
|
195
|
-
* the name reaches CSS as an attribute on the document element for as long
|
|
196
|
-
* as the transition runs:
|
|
197
|
-
*
|
|
198
|
-
* html[data-uf-view-transition="manual"]::view-transition-old(root) { … }
|
|
199
|
-
*
|
|
200
|
-
* A layout may declare one too, and then it covers every route under it; the
|
|
201
|
-
* page's own wins. That is the rule `metadata` already follows, and there is
|
|
202
|
-
* no reason for a second one — "the nearest declaration" is how everything
|
|
203
|
-
* else in a route module is resolved.
|
|
204
|
-
*/
|
|
205
|
-
readonly viewTransition?: string,
|
|
206
|
-
...
|
|
207
|
-
};
|
|
208
|
-
|
|
209
|
-
/** What a layout module may export. The component is `default` or `Layout`. */
|
|
210
|
-
export type LayoutModule = {
|
|
211
|
-
readonly default?: RouteComponent,
|
|
212
|
-
readonly Layout?: RouteComponent,
|
|
213
|
-
readonly metadata?: Metadata,
|
|
214
|
-
/** A transition name for every route under this layout; see [`PageModule`]. */
|
|
215
|
-
readonly viewTransition?: string,
|
|
216
|
-
...
|
|
217
|
-
};
|
|
218
|
-
|
|
219
|
-
/**
|
|
220
|
-
* What a template module may export. The component is `default` or `Template`.
|
|
221
|
-
*
|
|
222
|
-
* A layout's shape without its `metadata`, and the omission is the type saying
|
|
223
|
-
* what a template is for. A layout persists across navigation, so a title it
|
|
224
|
-
* declares is a claim about a section of the site; a template is thrown away
|
|
225
|
-
* and built again on every navigation, so a title on one would be a claim
|
|
226
|
-
* about nothing. Titles come from the page and the layouts above it.
|
|
227
|
-
*/
|
|
228
|
-
export type TemplateModule = {
|
|
229
|
-
readonly default?: RouteComponent,
|
|
230
|
-
readonly Template?: RouteComponent,
|
|
231
|
-
...
|
|
232
|
-
};
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* What an error module may export. The component is `default` or `Error`.
|
|
236
|
-
*
|
|
237
|
-
* `Error` shadows the global inside the file that writes it, which is the
|
|
238
|
-
* cost of naming the export after what it is; a file that needs the
|
|
239
|
-
* constructor still has `globalThis.Error`. The alternative was a name the
|
|
240
|
-
* convention would have to explain — `ErrorPage`, `Boundary` — for a file
|
|
241
|
-
* whose whole job is already in its name.
|
|
242
|
-
*/
|
|
243
|
-
export type ErrorModule = {
|
|
244
|
-
readonly default?: RouteComponent,
|
|
245
|
-
readonly Error?: RouteComponent,
|
|
246
|
-
readonly metadata?: Metadata,
|
|
247
|
-
...
|
|
248
|
-
};
|
|
249
|
-
|
|
250
|
-
/**
|
|
251
|
-
* What a loading module may export. The component is `default` or `Loading`.
|
|
252
|
-
*
|
|
253
|
-
* No `metadata`, and that is the type saying something true rather than an
|
|
254
|
-
* omission. A fallback renders while the route is still resolving, and the
|
|
255
|
-
* route's metadata was decided before the first byte — a title on a file that
|
|
256
|
-
* renders after the head has gone could never be used. `packages/web/head.js`
|
|
257
|
-
* documents the same constraint from the other side.
|
|
258
|
-
*/
|
|
259
|
-
export type LoadingModule = {
|
|
260
|
-
readonly default?: RouteComponent,
|
|
261
|
-
readonly Loading?: RouteComponent,
|
|
262
|
-
...
|
|
263
|
-
};
|
|
264
|
-
|
|
265
|
-
/**
|
|
266
|
-
* How a Twitter card is laid out, which is the whole of what `card` may be.
|
|
267
|
-
*
|
|
268
|
-
* A union rather than a string: every one of the four is spelled exactly this
|
|
269
|
-
* way and a fifth value is silently ignored by the crawler, so a typo in it
|
|
270
|
-
* costs a card and produces no error anywhere.
|
|
271
|
-
*/
|
|
272
|
-
export type TwitterCard = "summary" | "summary_large_image" | "app" | "player";
|
|
273
|
-
|
|
274
|
-
/**
|
|
275
|
-
* What a crawler may do with a page.
|
|
276
|
-
*
|
|
277
|
-
* Four fields rather than the whole `robots` vocabulary, and the omissions are
|
|
278
|
-
* the argument. `index` and `follow` are the two directives a page has an
|
|
279
|
-
* opinion about; `maxSnippet` and `maxImagePreview` are the two that change
|
|
280
|
-
* what a result *looks* like and have no other spelling. `nosnippet` is not
|
|
281
|
-
* here because `maxSnippet: 0` is the same instruction, and a type with two
|
|
282
|
-
* ways to say one thing is a type somebody will eventually ask which of them
|
|
283
|
-
* wins.
|
|
284
|
-
*
|
|
285
|
-
* Every field is optional and every one is only emitted when it is declared,
|
|
286
|
-
* because "index, follow" is what a document with no `robots` meta already
|
|
287
|
-
* says — the tag exists to say something else.
|
|
288
|
-
*/
|
|
289
|
-
export type Robots = {
|
|
290
|
-
readonly index?: boolean,
|
|
291
|
-
readonly follow?: boolean,
|
|
292
|
-
/** The longest snippet a result may quote; `0` is none, `-1` is no limit. */
|
|
293
|
-
readonly maxSnippet?: number,
|
|
294
|
-
readonly maxImagePreview?: "none" | "standard" | "large",
|
|
295
|
-
};
|
|
296
|
-
|
|
297
|
-
/**
|
|
298
|
-
* One JSON-LD object, as a page hands it over.
|
|
299
|
-
*
|
|
300
|
-
* `mixed` values rather than a schema.org type, because there is no useful
|
|
301
|
-
* middle: the vocabulary is hundreds of types deep, it grows without asking
|
|
302
|
-
* anyone, and a partial transcription of it would reject correct documents far
|
|
303
|
-
* more often than it caught wrong ones. What this type does claim is the part
|
|
304
|
-
* uf is answerable for — that the thing is an object, and therefore that it
|
|
305
|
-
* serialises into one `<script>`.
|
|
306
|
-
*/
|
|
307
|
-
export type JsonLd = { readonly [string]: mixed };
|
|
308
|
-
|
|
309
|
-
/** Document metadata a page or layout declares. */
|
|
310
|
-
export type Metadata = {
|
|
311
|
-
readonly title?: string,
|
|
312
|
-
readonly description?: string,
|
|
313
|
-
/**
|
|
314
|
-
* The absolute URL every other URL here is resolved against.
|
|
315
|
-
*
|
|
316
|
-
* Open Graph and Twitter both require absolute image URLs, and a route
|
|
317
|
-
* module has no way to know the host it will be served from — so without
|
|
318
|
-
* this, `openGraph.images: ["/og.png"]` ships exactly as written and is not
|
|
319
|
-
* a valid `og:image`. Declare it once on the root layout and every
|
|
320
|
-
* descendant inherits it through the same merge as everything else.
|
|
321
|
-
*
|
|
322
|
-
* Resolution is the URL standard's, so `"/og.png"` is resolved against the
|
|
323
|
-
* *origin* and `"og.png"` against the base's own path — not against the
|
|
324
|
-
* page's URL, which `Head` does not know.
|
|
325
|
-
*/
|
|
326
|
-
readonly metadataBase?: string,
|
|
327
|
-
/**
|
|
328
|
-
* This page's canonical URL, for `<link rel="canonical">` and `og:url`.
|
|
329
|
-
*
|
|
330
|
-
* Relative to `metadataBase` when it is not absolute. A page
|
|
331
|
-
* reachable at more than one path — a query a filter added, a duplicate
|
|
332
|
-
* under a second section — is one page, and this is how it says so.
|
|
333
|
-
*/
|
|
334
|
-
readonly canonical?: string,
|
|
335
|
-
/**
|
|
336
|
-
* What a crawler may do with this page. See [`Robots`].
|
|
337
|
-
*
|
|
338
|
-
* The one field here that is usually declared on a *layout*: a staging
|
|
339
|
-
* section, a preview tree or an account area is `index: false` for
|
|
340
|
-
* everything under it, and saying so once is the only version of that which
|
|
341
|
-
* stays true when a page is added.
|
|
342
|
-
*/
|
|
343
|
-
readonly robots?: Robots,
|
|
344
|
-
/**
|
|
345
|
-
* The other addresses this same page is published at.
|
|
346
|
-
*
|
|
347
|
-
* `languages` maps a BCP 47 tag to that translation's URL and becomes one
|
|
348
|
-
* `<link rel="alternate" hreflang>` each. The set has to be reciprocal —
|
|
349
|
-
* every page in it lists every other one *and itself*, which is what makes a
|
|
350
|
-
* search engine read them as translations rather than as duplicates — so it
|
|
351
|
-
* is usually the same map on every page of the set, declared on the layout
|
|
352
|
-
* they share. `"x-default"` is a tag like any other here, and names what a
|
|
353
|
-
* reader whose language is not in the set should be given.
|
|
354
|
-
*
|
|
355
|
-
* Nested under `alternates` rather than sitting at the top level as
|
|
356
|
-
* `languages`, because `alternate` is the link relation and a language is
|
|
357
|
-
* only one kind of alternate; the outer name is a fact about the wire rather
|
|
358
|
-
* than a shape invented here.
|
|
359
|
-
*/
|
|
360
|
-
readonly alternates?: {
|
|
361
|
-
readonly languages?: { readonly [string]: string },
|
|
362
|
-
},
|
|
363
|
-
/**
|
|
364
|
-
* The pages either side of this one in a sequence.
|
|
365
|
-
*
|
|
366
|
-
* `<link rel="prev">` and `<link rel="next">`, resolved against
|
|
367
|
-
* `metadataBase` like every other URL here. A page four of a list, and a
|
|
368
|
-
* chapter in the middle of a manual, are the same statement: this document
|
|
369
|
-
* is one of a series and here is where the series continues.
|
|
370
|
-
*
|
|
371
|
-
* `canonical` still belongs to the page itself. Pointing every page of a
|
|
372
|
-
* paginated list at page one is the mistake this pair exists to make
|
|
373
|
-
* unnecessary — it tells a search engine that pages two onwards are
|
|
374
|
-
* duplicates of page one, and everything only reachable from them stops
|
|
375
|
-
* being reachable at all.
|
|
376
|
-
*/
|
|
377
|
-
readonly pagination?: {
|
|
378
|
-
readonly prev?: string,
|
|
379
|
-
readonly next?: string,
|
|
380
|
-
},
|
|
381
|
-
/**
|
|
382
|
-
* Structured data, as JSON-LD.
|
|
383
|
-
*
|
|
384
|
-
* One `<script type="application/ld+json">` per entry. Unlike everything
|
|
385
|
-
* else here it *accumulates* down the tree rather than being replaced by the
|
|
386
|
-
* nearest declaration: an `Organization` on the root layout and an `Article`
|
|
387
|
-
* on the page are two statements about one page, not two answers to one
|
|
388
|
-
* question, and replacing would mean a page that describes itself silently
|
|
389
|
-
* deletes the site's description of itself.
|
|
390
|
-
*
|
|
391
|
-
* The scripts are rendered with the rest of the route rather than hoisted
|
|
392
|
-
* into `<head>`, because React hoists `<title>`, `<meta>` and `<link>` and
|
|
393
|
-
* not a script it has to keep the body of. JSON-LD is read from anywhere in
|
|
394
|
-
* the document, so this costs nothing; it is worth knowing when reading the
|
|
395
|
-
* markup.
|
|
396
|
-
*/
|
|
397
|
-
readonly jsonLd?: $ReadOnlyArray<JsonLd>,
|
|
398
|
-
readonly openGraph?: {
|
|
399
|
-
/**
|
|
400
|
-
* The title a share card shows.
|
|
401
|
-
*
|
|
402
|
-
* Falls back to `title`, because a page that has said what it is called
|
|
403
|
-
* has said what its card is called — and a site made to write it twice
|
|
404
|
-
* writes it twice once and then lets them drift.
|
|
405
|
-
*/
|
|
406
|
-
readonly title?: string,
|
|
407
|
-
/** The description a share card shows. Falls back to `description`. */
|
|
408
|
-
readonly description?: string,
|
|
409
|
-
/**
|
|
410
|
-
* The Open Graph object type. `website` unless a page says otherwise.
|
|
411
|
-
*
|
|
412
|
-
* Defaulted rather than omitted because `og:type` is one of the four
|
|
413
|
-
* properties Open Graph requires, and a document without it is not an
|
|
414
|
-
* Open Graph document at all — so leaving it to every project to remember
|
|
415
|
-
* is leaving most of them without one.
|
|
416
|
-
*/
|
|
417
|
-
readonly type?: string,
|
|
418
|
-
/**
|
|
419
|
-
* The name of the site the page belongs to, which a card prints above the
|
|
420
|
-
* title. Declared once on the root layout.
|
|
421
|
-
*/
|
|
422
|
-
readonly siteName?: string,
|
|
423
|
-
readonly images?: $ReadOnlyArray<string>,
|
|
424
|
-
/**
|
|
425
|
-
* What the card's image shows, for a reader who cannot see it.
|
|
426
|
-
*
|
|
427
|
-
* One description rather than one per image: a card shows one image, and
|
|
428
|
-
* the array exists so a site can offer a crawler a choice of sizes rather
|
|
429
|
-
* than so it can show several.
|
|
430
|
-
*/
|
|
431
|
-
readonly imageAlt?: string,
|
|
432
|
-
},
|
|
433
|
-
readonly twitter?: {
|
|
434
|
-
readonly card?: TwitterCard,
|
|
435
|
-
readonly site?: string,
|
|
436
|
-
readonly creator?: string,
|
|
437
|
-
/** Falls back to `openGraph.title`, and then to `title`. */
|
|
438
|
-
readonly title?: string,
|
|
439
|
-
/** Falls back to `openGraph.description`, and then to `description`. */
|
|
440
|
-
readonly description?: string,
|
|
441
|
-
readonly images?: $ReadOnlyArray<string>,
|
|
442
|
-
/** Falls back to `openGraph.imageAlt`. */
|
|
443
|
-
readonly imageAlt?: string,
|
|
444
|
-
},
|
|
445
|
-
};
|
|
446
|
-
|
|
447
|
-
/** Arguments a loader receives. */
|
|
448
|
-
export type LoaderArgs = {|
|
|
449
|
-
readonly params: RouteParams,
|
|
450
|
-
readonly searchParams: SearchParams,
|
|
451
|
-
readonly pathname: string,
|
|
452
|
-
|};
|
|
453
|
-
|
|
454
|
-
/** Arguments `generateMetadata` receives. */
|
|
455
|
-
export type MetadataArgs = {|
|
|
456
|
-
readonly params: RouteParams,
|
|
457
|
-
readonly searchParams: SearchParams,
|
|
458
|
-
readonly data: mixed,
|
|
459
|
-
|};
|
|
460
|
-
|
|
461
|
-
export type RouteRecord = RoutingRouteRecord<
|
|
462
|
-
PageModule,
|
|
463
|
-
LayoutModule,
|
|
464
|
-
TemplateModule,
|
|
465
|
-
LoadingModule,
|
|
466
|
-
ErrorModule,
|
|
467
|
-
>;
|
|
468
|
-
|
|
469
|
-
export type SlotRecord = RoutingSlotRecord<
|
|
470
|
-
PageModule,
|
|
471
|
-
LayoutModule,
|
|
472
|
-
TemplateModule,
|
|
473
|
-
LoadingModule,
|
|
474
|
-
ErrorModule,
|
|
475
|
-
>;
|
|
476
|
-
|
|
477
|
-
export type SlotRouteRecord = RoutingSlotRouteRecord<
|
|
478
|
-
PageModule,
|
|
479
|
-
LayoutModule,
|
|
480
|
-
TemplateModule,
|
|
481
|
-
LoadingModule,
|
|
482
|
-
ErrorModule,
|
|
483
|
-
>;
|
|
484
|
-
|
|
485
|
-
export type TemplateRecord = RoutingTemplateRecord<TemplateModule>;
|
|
486
|
-
|
|
487
|
-
export type LoadingRecord = RoutingLoadingRecord<LoadingModule>;
|
|
488
|
-
|
|
489
|
-
export type NotFoundBoundary = RoutingNotFoundBoundary<PageModule, LayoutModule>;
|
|
490
|
-
|
|
491
|
-
export type ErrorBoundary = RoutingErrorBoundary<ErrorModule, LayoutModule>;
|
|
492
|
-
|
|
493
|
-
export type RouteTable = RoutingRouteTable<
|
|
494
|
-
PageModule,
|
|
495
|
-
LayoutModule,
|
|
496
|
-
TemplateModule,
|
|
497
|
-
LoadingModule,
|
|
498
|
-
ErrorModule,
|
|
499
|
-
>;
|
|
500
|
-
|
|
501
|
-
export type RouteMatch = RoutingRouteMatch<RouteRecord>;
|
|
502
|
-
|
|
503
|
-
type ResolvedTemplate = {|
|
|
504
|
-
readonly above: number,
|
|
505
|
-
readonly module: TemplateModule,
|
|
506
|
-
|};
|
|
507
|
-
|
|
508
|
-
type ResolvedSlotErrorBoundary = {|
|
|
509
|
-
readonly above: number,
|
|
510
|
-
readonly module: ?ErrorModule,
|
|
511
|
-
|};
|
|
512
|
-
|
|
513
|
-
type SlotErrorBoundaryLoader = {|
|
|
514
|
-
readonly above: number,
|
|
515
|
-
readonly module: () => Promise<ErrorModule>,
|
|
516
|
-
|};
|
|
517
|
-
|
|
518
|
-
/**
|
|
519
|
-
* A match whose modules are loaded and whose loader has run or is running — or,
|
|
520
|
-
* when `error` is set, the error page that stands in for it.
|
|
521
|
-
*/
|
|
522
|
-
export type ResolvedRoute = {|
|
|
523
|
-
readonly pathname: string,
|
|
524
|
-
readonly search: string,
|
|
525
|
-
readonly path: string,
|
|
526
|
-
readonly params: RouteParams,
|
|
527
|
-
readonly searchParams: SearchParams,
|
|
528
|
-
readonly page: PageModule,
|
|
529
|
-
readonly layouts: $ReadOnlyArray<LayoutModule>,
|
|
530
|
-
/** What the loader returned, once it has. `undefined` while `deferred` is set. */
|
|
531
|
-
readonly data: mixed,
|
|
532
|
-
/**
|
|
533
|
-
* The loader still running, when the router handed the page its promise
|
|
534
|
-
* rather than its value. `null` on every other path, which is most of them.
|
|
535
|
-
*
|
|
536
|
-
* Two fields rather than a `data` that is sometimes a promise, because a
|
|
537
|
-
* loader is free to return something with a `then` on it and no duck test
|
|
538
|
-
* could tell that apart from a deferral. This one is the router's own answer
|
|
539
|
-
* to a question the router asked, so it says so.
|
|
540
|
-
*
|
|
541
|
-
* Set only by a streaming render of a route that declares a
|
|
542
|
-
* `$loading.js` and generates no metadata from its data — the two
|
|
543
|
-
* conditions under which deferring buys anything and costs nothing that was
|
|
544
|
-
* not already spent. [`resolveRoute`] is where that is decided and argued.
|
|
545
|
-
*/
|
|
546
|
-
readonly deferred: ?Promise<mixed>,
|
|
547
|
-
readonly metadata: Metadata,
|
|
548
|
-
/**
|
|
549
|
-
* What a stylesheet calls the transition this route arrives under, or `null`
|
|
550
|
-
* when neither the page nor a layout above it named one.
|
|
551
|
-
*
|
|
552
|
-
* Resolved with the route rather than looked up at the moment of the
|
|
553
|
-
* navigation, because by then the answer is a property of the destination's
|
|
554
|
-
* modules and those are exactly what has just been loaded. A server render
|
|
555
|
-
* carries it and never reads it; see "View transitions".
|
|
556
|
-
*/
|
|
557
|
-
readonly viewTransition: ?string,
|
|
558
|
-
readonly status: 200 | 401 | 403 | 404 | 500,
|
|
559
|
-
/**
|
|
560
|
-
* Set when this resolution *is* the error page: the loader threw, or the
|
|
561
|
-
* server render did and the renderer resolved again. `null` on the ordinary
|
|
562
|
-
* path.
|
|
563
|
-
*/
|
|
564
|
-
readonly error: ?RouteError,
|
|
565
|
-
/**
|
|
566
|
-
* The boundary that would catch a throw while rendering this route.
|
|
567
|
-
*
|
|
568
|
-
* Always present, because every route has an answer for a throw: `module`
|
|
569
|
-
* is `null` when the project declares no `$error.js` above the path, and
|
|
570
|
-
* the framework's own error page renders instead. `above` is how many of
|
|
571
|
-
* `layouts` are outside the boundary — the ones that stay mounted, which is
|
|
572
|
-
* what "the rest of the document is still interactive" means.
|
|
573
|
-
*/
|
|
574
|
-
readonly errorBoundary: {|
|
|
575
|
-
readonly module: ?ErrorModule,
|
|
576
|
-
readonly above: number,
|
|
577
|
-
|},
|
|
578
|
-
/**
|
|
579
|
-
* The loading boundaries around this route, root first, already imported.
|
|
580
|
-
*
|
|
581
|
-
* Imported rather than lazy: React decides to render a fallback
|
|
582
|
-
* synchronously, during the render that suspended, so a module that is still
|
|
583
|
-
* being fetched is a module that is not there at the only moment it is
|
|
584
|
-
* wanted. Empty for a route with no `$loading.js` above it, which is the
|
|
585
|
-
* ordinary case and renders exactly the tree it did before.
|
|
586
|
-
*/
|
|
587
|
-
readonly loading: $ReadOnlyArray<{| readonly above: number, readonly module: LoadingModule |}>,
|
|
588
|
-
/**
|
|
589
|
-
* The templates around this route, root first, already imported.
|
|
590
|
-
*
|
|
591
|
-
* Empty for a route with no `$template.js` above it, which is the
|
|
592
|
-
* ordinary case and renders exactly the tree it did before templates
|
|
593
|
-
* existed. Empty too on a resolution that *is* a boundary — a not-found or
|
|
594
|
-
* an error page — for the reason its `loading` is: those are matched rather
|
|
595
|
-
* than walked to, and templates are accumulated on the walk down to a route
|
|
596
|
-
* the URL never reached.
|
|
597
|
-
*/
|
|
598
|
-
readonly templates: $ReadOnlyArray<ResolvedTemplate>,
|
|
599
|
-
/**
|
|
600
|
-
* The slots this route renders, outermost first, already imported.
|
|
601
|
-
*
|
|
602
|
-
* Empty for a route with no slot above it, and empty on a resolution that
|
|
603
|
-
* *is* a boundary — a not-found or an error page — for the reason its
|
|
604
|
-
* `templates` is: a boundary is matched rather than walked to, and a slot
|
|
605
|
-
* belongs to the segment the walk went through.
|
|
606
|
-
*/
|
|
607
|
-
readonly slots: $ReadOnlyArray<ResolvedSlot>,
|
|
608
|
-
/**
|
|
609
|
-
* Set when a client navigation was intercepted: this is then the route the
|
|
610
|
-
* navigation came from, still on screen, with the intercepting route in one
|
|
611
|
-
* of its slots.
|
|
612
|
-
*
|
|
613
|
-
* `null` or absent on every other resolution — which includes every one a
|
|
614
|
-
* server makes, because a document request is never intercepted. See
|
|
615
|
-
* [`resolveInterception`].
|
|
616
|
-
*/
|
|
617
|
-
readonly interception?: ?Interception,
|
|
618
|
-
|};
|
|
619
|
-
|
|
620
|
-
/**
|
|
621
|
-
* What an intercepted navigation went to, and what it went there from.
|
|
622
|
-
*
|
|
623
|
-
* A route is resolved *for* a URL, and an intercepted navigation is the one
|
|
624
|
-
* case where the URL in the address bar is not the URL the page on screen was
|
|
625
|
-
* resolved for. The `ResolvedRoute` that carries this is the page the
|
|
626
|
-
* navigation started on — its `pathname`, `params` and `data` are that page's,
|
|
627
|
-
* because that page is what `children` still renders and what `useRoute()`
|
|
628
|
-
* still describes — and this is the other half: where the address bar went.
|
|
629
|
-
*
|
|
630
|
-
* `base` is the same page as it was before anything intercepted it. Kept rather
|
|
631
|
-
* than re-derived, because a second interception from inside the first — the
|
|
632
|
-
* next photo, from a photo already open in the modal — has to start from the
|
|
633
|
-
* page underneath rather than from a page that already has a modal in it, and
|
|
634
|
-
* going back from the second to the first has to find that page where it left
|
|
635
|
-
* it.
|
|
636
|
-
*/
|
|
637
|
-
export type Interception = {|
|
|
638
|
-
/** The URL the navigation went to: the one in the address bar. */
|
|
639
|
-
readonly pathname: string,
|
|
640
|
-
readonly search: string,
|
|
641
|
-
/** The route the navigation came from, as it was before it was intercepted. */
|
|
642
|
-
readonly base: ResolvedRoute,
|
|
643
|
-
|};
|
|
644
|
-
|
|
645
|
-
/**
|
|
646
|
-
* One slot, matched against the URL and imported.
|
|
647
|
-
*
|
|
648
|
-
* `page` is `null` for a slot the URL addressed and that declares no
|
|
649
|
-
* `$default.js`, and the layout receives `null` rather than nothing at all:
|
|
650
|
-
* a layout that declares a slot always gets that prop, so a project can write
|
|
651
|
-
* `{team ?? <Empty />}` and mean it.
|
|
652
|
-
*
|
|
653
|
-
* `params` are the slot's own. A slot matches the same URL by its own patterns,
|
|
654
|
-
* so `@team/[member]` captures `member` while the page beside it captures
|
|
655
|
-
* nothing — which is the point of matching twice rather than sharing one match.
|
|
656
|
-
*/
|
|
657
|
-
export type ResolvedSlot = {|
|
|
658
|
-
readonly name: string,
|
|
659
|
-
readonly above: number,
|
|
660
|
-
readonly page: ?PageModule,
|
|
661
|
-
readonly params: RouteParams,
|
|
662
|
-
readonly layouts: $ReadOnlyArray<LayoutModule>,
|
|
663
|
-
readonly loading: $ReadOnlyArray<{| readonly above: number, readonly module: LoadingModule |}>,
|
|
664
|
-
readonly templates: $ReadOnlyArray<ResolvedTemplate>,
|
|
665
|
-
readonly errorBoundary: ?ResolvedSlotErrorBoundary,
|
|
666
|
-
readonly slots: $ReadOnlyArray<ResolvedSlot>,
|
|
667
|
-
/**
|
|
668
|
-
* The table record this slot was resolved from.
|
|
669
|
-
*
|
|
670
|
-
* Carried so a client navigation can ask the slots *on screen* whether they
|
|
671
|
-
* intercept where it is going. Interception is a question about the page a
|
|
672
|
-
* navigation starts on, and this is that page's own answer rather than a
|
|
673
|
-
* second match of the table that could arrive at different slots. Absent on
|
|
674
|
-
* a slot written by hand, which then intercepts nothing.
|
|
675
|
-
*/
|
|
676
|
-
readonly record?: SlotRecord,
|
|
677
|
-
/**
|
|
678
|
-
* Set when this slot renders an intercepting route, or sits inside one: the
|
|
679
|
-
* URL that was intercepted.
|
|
680
|
-
*
|
|
681
|
-
* The slot's keys and its page's `searchParams` come from here rather than
|
|
682
|
-
* from the route on screen, because the route on screen is the page the
|
|
683
|
-
* navigation started on. Keying the modal's templates on that page's
|
|
684
|
-
* pathname would leave the second photo mounted as the first one.
|
|
685
|
-
*/
|
|
686
|
-
readonly intercepted?: ?InterceptedUrl,
|
|
687
|
-
|};
|
|
688
|
-
|
|
689
|
-
/** The URL an intercepting route was matched against. */
|
|
690
|
-
type InterceptedUrl = {|
|
|
691
|
-
readonly pathname: string,
|
|
692
|
-
readonly searchParams: SearchParams,
|
|
693
|
-
|};
|
|
694
|
-
|
|
695
|
-
// ---------------------------------------------------------------------------
|
|
696
|
-
// Loading
|
|
697
|
-
// ---------------------------------------------------------------------------
|
|
698
|
-
|
|
699
|
-
const moduleCache: Map<() => Promise<mixed>, Promise<mixed>> = new Map();
|
|
700
|
-
|
|
701
|
-
function loadOnce<T>(load: () => Promise<T>): Promise<T> {
|
|
702
|
-
let pending = moduleCache.get(load);
|
|
703
|
-
if (pending == null) {
|
|
704
|
-
pending = load();
|
|
705
|
-
moduleCache.set(load, pending);
|
|
706
|
-
}
|
|
707
|
-
// $FlowFixMe[incompatible-return] the cache is keyed by the loader, whose result type it stores.
|
|
708
|
-
return pending;
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
/**
|
|
712
|
-
* Load a match's modules and run its loader.
|
|
713
|
-
*
|
|
714
|
-
* `data` is what the loader returned; on the client after hydration it is the
|
|
715
|
-
* value the server embedded, so the loader does not run twice for the first
|
|
716
|
-
* page.
|
|
717
|
-
*
|
|
718
|
-
* # This resolves or redirects; it does not reject
|
|
719
|
-
*
|
|
720
|
-
* Everything a route can go wrong with is a route to render: no match and
|
|
721
|
-
* `notFound()` are the not-found boundary, a loader that threw and
|
|
722
|
-
* `forbidden()`/`unauthorized()` are the error boundary. Only `redirect()`
|
|
723
|
-
* comes back out, because a redirect is a response rather than a page and the
|
|
724
|
-
* caller is what has one to send.
|
|
725
|
-
*
|
|
726
|
-
* That guarantee is the point rather than a convenience. `hydrate` awaits this
|
|
727
|
-
* before `hydrateRoot`, so a rejection there is not an error page — it is no
|
|
728
|
-
* `hydrateRoot` call at all, and the document the server sent stays on screen
|
|
729
|
-
* with nothing attached to it.
|
|
730
|
-
*
|
|
731
|
-
* # `onMatch`
|
|
732
|
-
*
|
|
733
|
-
* Called with the route pattern the moment the URL matches one, before any
|
|
734
|
-
* module is imported and before the loader runs. It exists because the server
|
|
735
|
-
* has something to do with that fact and does it too late otherwise: the route
|
|
736
|
-
* a request turned out to be is what its log line carries, and a loader is
|
|
737
|
-
* inside this call, so a server that recorded the route after this resolved
|
|
738
|
-
* would have every line a loader wrote saying it belonged to no route.
|
|
739
|
-
*
|
|
740
|
-
* A callback rather than a return value because both callers already have one
|
|
741
|
-
* — the pattern is on the `ResolvedRoute` this hands back — and only one of
|
|
742
|
-
* them needs it *early*. The browser passes nothing and pays nothing.
|
|
743
|
-
*/
|
|
744
|
-
export async function resolveMatch(
|
|
745
|
-
table: RouteTable,
|
|
746
|
-
url: string,
|
|
747
|
-
options?: ResolveOptions,
|
|
748
|
-
): Promise<ResolvedRoute> {
|
|
749
|
-
try {
|
|
750
|
-
return await resolveRoute(table, url, options);
|
|
751
|
-
} catch (error) {
|
|
752
|
-
if (error instanceof RedirectError) {
|
|
753
|
-
throw error;
|
|
754
|
-
}
|
|
755
|
-
return resolveFailure(table, url, error);
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
/** What a caller may tell [`resolveMatch`] about the resolution it wants. */
|
|
760
|
-
export type ResolveOptions = {|
|
|
761
|
-
/** The loader's answer, already in hand — the value the server embedded. */
|
|
762
|
-
readonly data?: mixed,
|
|
763
|
-
/** Do not run the loader at all; `data` is the answer. */
|
|
764
|
-
readonly skipLoader?: boolean,
|
|
765
|
-
/**
|
|
766
|
-
* Whether the caller can render a route whose loader has not answered yet.
|
|
767
|
-
*
|
|
768
|
-
* Only a streaming server render can, and that is the whole of why this is
|
|
769
|
-
* a caller's choice rather than the router's. `createRenderer`'s `render`
|
|
770
|
-
* sends a `<Suspense>` fallback now and the content when it arrives, so
|
|
771
|
-
* deferring is what turns a slow loader from a delay before the first byte
|
|
772
|
-
* into a fallback the reader is already looking at.
|
|
773
|
-
*
|
|
774
|
-
* Nothing else is in that position, and each for its own reason. `prerender`
|
|
775
|
-
* writes a file, which has no first paint to improve and no reader to show a
|
|
776
|
-
* fallback to. `hydrate` has the server's answer already. A client
|
|
777
|
-
* navigation has a page on screen that stays interactive while the next one
|
|
778
|
-
* resolves, which is the browser's version of the same idea and does not
|
|
779
|
-
* need this one.
|
|
780
|
-
*
|
|
781
|
-
* It costs the loader its say in the response: a status is decided when the
|
|
782
|
-
* shell goes out, so a deferred `notFound()` reaches the error boundary
|
|
783
|
-
* rather than the 404 page, and the document is a 200. That is inherent to
|
|
784
|
-
* streaming rather than a shortcut — the bytes have gone — and it is the
|
|
785
|
-
* reason this is off unless a caller asks.
|
|
786
|
-
*/
|
|
787
|
-
readonly defer?: boolean,
|
|
788
|
-
/** The route pattern, the moment the URL matches one; see [`resolveMatch`]. */
|
|
789
|
-
readonly onMatch?: (pattern: string) => void,
|
|
790
|
-
|};
|
|
791
|
-
|
|
792
|
-
async function resolveRoute(
|
|
793
|
-
table: RouteTable,
|
|
794
|
-
url: string,
|
|
795
|
-
options?: ResolveOptions,
|
|
796
|
-
): Promise<ResolvedRoute> {
|
|
797
|
-
const { pathname, search } = splitUrl(url);
|
|
798
|
-
const searchParams = parseSearch(search);
|
|
799
|
-
const matched = matchRoute(table.routes, pathname);
|
|
800
|
-
|
|
801
|
-
if (matched != null) {
|
|
802
|
-
options?.onMatch?.(matched.route.path);
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
if (matched == null) {
|
|
806
|
-
return resolveNotFound(table, pathname, search, searchParams);
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
const load = matched.route.page;
|
|
810
|
-
if (load == null) {
|
|
811
|
-
// Reachable only by asking this table to render a route it was built
|
|
812
|
-
// without. `hydrate` and every navigation check `hasClientPage` first and
|
|
813
|
-
// hand the URL to the browser instead, so arriving here means a caller
|
|
814
|
-
// went around them — and the honest answer is to say so rather than to
|
|
815
|
-
// render an empty page.
|
|
816
|
-
throw new Error(
|
|
817
|
-
`@uniflowed/router: ${matched.route.path} has no page in this route table; it ships no ` +
|
|
818
|
-
"client JavaScript, so the browser navigates to it rather than rendering it",
|
|
819
|
-
);
|
|
820
|
-
}
|
|
821
|
-
const [page, ...layouts] = await Promise.all([
|
|
822
|
-
loadOnce(load),
|
|
823
|
-
...matched.route.layouts.map((layout) => loadOnce(layout)),
|
|
824
|
-
]);
|
|
825
|
-
// Started here and awaited at the end: the boundary's module does not depend
|
|
826
|
-
// on the loader, so importing it alongside costs a navigation nothing. It
|
|
827
|
-
// never rejects, so an early throw below leaves no unhandled rejection.
|
|
828
|
-
const boundary = resolveErrorBoundary(table, pathname, matched.route.layouts.length);
|
|
829
|
-
// Started alongside for the same reason, and awaited at the end: a fallback
|
|
830
|
-
// depends on nothing the loader produces.
|
|
831
|
-
const loading = resolveLoading(matched.route, matched.route.layouts.length);
|
|
832
|
-
const templates = resolveTemplates(matched.route, matched.route.layouts.length);
|
|
833
|
-
// Started alongside and awaited at the end, for the reason the boundaries
|
|
834
|
-
// are: a slot is matched against the URL and depends on nothing the loader
|
|
835
|
-
// produces, so the second match and its imports overlap the first page's
|
|
836
|
-
// loader rather than following it.
|
|
837
|
-
const slots = resolveSlots(
|
|
838
|
-
matched.route.slots ?? [],
|
|
839
|
-
pathname,
|
|
840
|
-
matched.route.layouts.length,
|
|
841
|
-
matched.params,
|
|
842
|
-
);
|
|
843
|
-
|
|
844
|
-
// The loader, run here and awaited below — or not awaited at all.
|
|
845
|
-
//
|
|
846
|
-
// A page that suspends while *rendering* has always streamed; a page waiting
|
|
847
|
-
// on its loader could not, because this function awaited the loader before it
|
|
848
|
-
// returned and by the time React saw the tree the data was already in hand.
|
|
849
|
-
// The fallback beside such a page showed for zero milliseconds, which made
|
|
850
|
-
// `$loading.js` useful for the one case a page usually is not slow for.
|
|
851
|
-
//
|
|
852
|
-
// Two things stand in the way of simply not awaiting, and both are about the
|
|
853
|
-
// document rather than about the route. Metadata goes in the head and the
|
|
854
|
-
// head is written before the body, so a title computed from the data
|
|
855
|
-
// genuinely cannot be deferred — that is a rule worth stating rather than a
|
|
856
|
-
// limitation to hide, and it is the `generateMetadata` half of the condition
|
|
857
|
-
// below. The other is that a route with no `<Suspense>` above it has nothing
|
|
858
|
-
// to defer *into*: React holds the whole shell for a page that suspends with
|
|
859
|
-
// no boundary, which is the same wait by another name, with an unresolved
|
|
860
|
-
// promise flowing through the tree for nothing. So the loader is deferred
|
|
861
|
-
// exactly when there is a boundary to defer it into.
|
|
862
|
-
//
|
|
863
|
-
// See ubugeeei-prod/uf#373, and `ResolveOptions.defer` for who asks.
|
|
864
|
-
let data: mixed = options?.data;
|
|
865
|
-
let deferred: ?Promise<mixed> = null;
|
|
866
|
-
if (options?.skipLoader !== true && typeof page.loader === "function") {
|
|
867
|
-
const running = page.loader({ params: matched.params, searchParams, pathname });
|
|
868
|
-
const canDefer =
|
|
869
|
-
options?.defer === true &&
|
|
870
|
-
(matched.route.loading ?? []).length > 0 &&
|
|
871
|
-
typeof page.generateMetadata !== "function";
|
|
872
|
-
if (canDefer) {
|
|
873
|
-
// `Promise.resolve`, because a loader may return a plain value and `use`
|
|
874
|
-
// wants a promise either way. A loader that answered without waiting
|
|
875
|
-
// costs one microtask and renders in the same pass.
|
|
876
|
-
deferred = Promise.resolve(running);
|
|
877
|
-
} else {
|
|
878
|
-
data = await running;
|
|
879
|
-
}
|
|
880
|
-
}
|
|
881
|
-
|
|
882
|
-
const metadata = await resolveMetadata(page, layouts, {
|
|
883
|
-
params: matched.params,
|
|
884
|
-
searchParams,
|
|
885
|
-
data,
|
|
886
|
-
});
|
|
887
|
-
return {
|
|
888
|
-
pathname,
|
|
889
|
-
search,
|
|
890
|
-
path: matched.route.path,
|
|
891
|
-
params: matched.params,
|
|
892
|
-
searchParams,
|
|
893
|
-
page,
|
|
894
|
-
layouts,
|
|
895
|
-
data,
|
|
896
|
-
deferred,
|
|
897
|
-
metadata,
|
|
898
|
-
viewTransition: resolveViewTransition(page, layouts),
|
|
899
|
-
status: 200,
|
|
900
|
-
error: null,
|
|
901
|
-
errorBoundary: await boundary,
|
|
902
|
-
loading: await loading,
|
|
903
|
-
templates: await templates,
|
|
904
|
-
slots: await slots,
|
|
905
|
-
};
|
|
906
|
-
}
|
|
907
|
-
|
|
908
|
-
/**
|
|
909
|
-
* The route's slots, matched against the URL and imported.
|
|
910
|
-
*
|
|
911
|
-
* The second matching pass parallel routes are, and it is a pass rather than a
|
|
912
|
-
* branch of the first: a slot has its own patterns over the same path, so
|
|
913
|
-
* `/dashboard/members` can be `[member]` to one slot, a static segment to
|
|
914
|
-
* another and nothing at all to a third, at once.
|
|
915
|
-
*
|
|
916
|
-
* A slot that will not load renders nothing rather than taking the page with
|
|
917
|
-
* it, which is the judgement `resolveTemplates` and `resolveLoading` already
|
|
918
|
-
* make: a slot is a second thing beside the page, and a broken second thing
|
|
919
|
-
* must not become a broken route. The entry stays in the list with `page:
|
|
920
|
-
* null`, so the layout still receives the prop it declares.
|
|
921
|
-
*/
|
|
922
|
-
async function resolveSlots(
|
|
923
|
-
records: $ReadOnlyArray<SlotRecord>,
|
|
924
|
-
pathname: string,
|
|
925
|
-
layoutCount: number,
|
|
926
|
-
fallbackParams: RouteParams,
|
|
927
|
-
intercepted?: ?InterceptedUrl,
|
|
928
|
-
): Promise<$ReadOnlyArray<ResolvedSlot>> {
|
|
929
|
-
if (records.length === 0) {
|
|
930
|
-
return [];
|
|
931
|
-
}
|
|
932
|
-
return Promise.all(
|
|
933
|
-
records.map((record) =>
|
|
934
|
-
resolveSlot(record, pathname, layoutCount, fallbackParams, intercepted),
|
|
935
|
-
),
|
|
936
|
-
);
|
|
937
|
-
}
|
|
938
|
-
|
|
939
|
-
async function resolveSlot(
|
|
940
|
-
record: SlotRecord,
|
|
941
|
-
pathname: string,
|
|
942
|
-
layoutCount: number,
|
|
943
|
-
fallbackParams: RouteParams,
|
|
944
|
-
intercepted?: ?InterceptedUrl,
|
|
945
|
-
): Promise<ResolvedSlot> {
|
|
946
|
-
// Clamped exactly as a template's `above` is, and for the same reason: a
|
|
947
|
-
// hand-written table, or a `(group)` between the layout and the route, can
|
|
948
|
-
// leave a route with fewer layouts than the slot was declared above.
|
|
949
|
-
const above = Math.min(record.above, layoutCount);
|
|
950
|
-
const empty: ResolvedSlot = {
|
|
951
|
-
name: record.name,
|
|
952
|
-
above,
|
|
953
|
-
page: null,
|
|
954
|
-
params: fallbackParams,
|
|
955
|
-
layouts: [],
|
|
956
|
-
loading: [],
|
|
957
|
-
templates: [],
|
|
958
|
-
errorBoundary: null,
|
|
959
|
-
slots: [],
|
|
960
|
-
record,
|
|
961
|
-
intercepted,
|
|
962
|
-
};
|
|
963
|
-
|
|
964
|
-
const matched = matchIn(record.routes, pathname);
|
|
965
|
-
if (matched == null) {
|
|
966
|
-
// The URL says nothing about this slot. `$default.js` is what it says
|
|
967
|
-
// instead, and a slot that declares none renders nothing at all.
|
|
968
|
-
const load = record.defaultPage;
|
|
969
|
-
if (load == null) {
|
|
970
|
-
return empty;
|
|
971
|
-
}
|
|
972
|
-
const module = await loadOrNull(load);
|
|
973
|
-
if (module == null) {
|
|
974
|
-
return empty;
|
|
975
|
-
}
|
|
976
|
-
return {
|
|
977
|
-
...empty,
|
|
978
|
-
page: withoutLoader(module, record.defaultFile ?? record.name),
|
|
979
|
-
errorBoundary: await resolveSlotErrorBoundary(record.defaultErrorBoundary ?? null, 0),
|
|
980
|
-
};
|
|
981
|
-
}
|
|
982
|
-
|
|
983
|
-
return (await resolveSlotRoute(record, matched, above, pathname, intercepted)) ?? empty;
|
|
984
|
-
}
|
|
985
|
-
|
|
986
|
-
/**
|
|
987
|
-
* One route inside a slot, imported: what the slot renders for a match.
|
|
988
|
-
*
|
|
989
|
-
* Shared by the two ways a slot comes to render a route — one of its own
|
|
990
|
-
* `routes`, matched against the URL, and one of its `intercepts`, matched
|
|
991
|
-
* against where a client navigation is going — so an intercepting page is
|
|
992
|
-
* composed exactly the way every other slot page is: inside its own layouts,
|
|
993
|
-
* fallbacks, templates and error boundary, with the slots those layouts
|
|
994
|
-
* declare.
|
|
995
|
-
*
|
|
996
|
-
* `null` when the page or a layout will not import, and what that means is the
|
|
997
|
-
* caller's to say. For a match it is an empty slot, for the reason
|
|
998
|
-
* [`resolveSlots`] gives; for an interception it is no interception, and the
|
|
999
|
-
* navigation goes where the URL says instead.
|
|
1000
|
-
*/
|
|
1001
|
-
async function resolveSlotRoute(
|
|
1002
|
-
record: SlotRecord,
|
|
1003
|
-
matched: RoutingRouteMatch<SlotRouteRecord>,
|
|
1004
|
-
above: number,
|
|
1005
|
-
pathname: string,
|
|
1006
|
-
intercepted: ?InterceptedUrl,
|
|
1007
|
-
): Promise<?ResolvedSlot> {
|
|
1008
|
-
const route = matched.route;
|
|
1009
|
-
// Started together and awaited apart, so the two `await`s are not a
|
|
1010
|
-
// waterfall and each keeps the type its loader had.
|
|
1011
|
-
const pending = loadOrNull(route.page);
|
|
1012
|
-
const pendingLayouts = Promise.all(route.layouts.map((layout) => loadOrNull(layout)));
|
|
1013
|
-
const page = await pending;
|
|
1014
|
-
const layouts = await pendingLayouts;
|
|
1015
|
-
if (page == null) {
|
|
1016
|
-
return null;
|
|
1017
|
-
}
|
|
1018
|
-
const loaded = layouts.filter(Boolean);
|
|
1019
|
-
if (loaded.length !== layouts.length) {
|
|
1020
|
-
return null;
|
|
1021
|
-
}
|
|
1022
|
-
const loading = await resolveLoadingRecords(route.loading ?? [], loaded.length);
|
|
1023
|
-
const templates = await resolveTemplateRecords(route.templates ?? [], loaded.length);
|
|
1024
|
-
const errorBoundary = await resolveSlotErrorBoundary(route.errorBoundary ?? null, loaded.length);
|
|
1025
|
-
return {
|
|
1026
|
-
name: record.name,
|
|
1027
|
-
above,
|
|
1028
|
-
page: withoutLoader(page, route.file),
|
|
1029
|
-
params: matched.params,
|
|
1030
|
-
layouts: loaded,
|
|
1031
|
-
loading,
|
|
1032
|
-
templates,
|
|
1033
|
-
errorBoundary,
|
|
1034
|
-
// The slot's own layouts are what a nested slot is measured against, so
|
|
1035
|
-
// the count handed down is this slot's rather than the route's. A slot
|
|
1036
|
-
// nested inside an interception is matched against the intercepted URL,
|
|
1037
|
-
// and keyed on it, for the same reason the interception is.
|
|
1038
|
-
slots: await resolveSlots(route.slots, pathname, loaded.length, matched.params, intercepted),
|
|
1039
|
-
record,
|
|
1040
|
-
intercepted,
|
|
1041
|
-
};
|
|
1042
|
-
}
|
|
1043
|
-
|
|
1044
|
-
// ---------------------------------------------------------------------------
|
|
1045
|
-
// Interception
|
|
1046
|
-
// ---------------------------------------------------------------------------
|
|
1047
|
-
|
|
1048
|
-
/**
|
|
1049
|
-
* The page underneath: `resolved` itself, or what it was before an interception
|
|
1050
|
-
* put something in one of its slots.
|
|
1051
|
-
*
|
|
1052
|
-
* Every question about where a navigation *starts* is asked of this rather than
|
|
1053
|
-
* of the route on screen, because an interception is not a place a navigation
|
|
1054
|
-
* can start from. The next photo, opened from inside the modal, is intercepted
|
|
1055
|
-
* from the feed.
|
|
1056
|
-
*/
|
|
1057
|
-
function beneath(resolved: ResolvedRoute): ResolvedRoute {
|
|
1058
|
-
return resolved.interception?.base ?? resolved;
|
|
1059
|
-
}
|
|
1060
|
-
|
|
1061
|
-
/**
|
|
1062
|
-
* The intercepting routes the slots on screen have for `pathname`.
|
|
1063
|
-
*
|
|
1064
|
-
* Only the slots on screen, and that is the whole of what "a navigation from
|
|
1065
|
-
* inside `/feed`" means. A page under `app/feed/$layout.js` renders the layout
|
|
1066
|
-
* that declares `@modal`, so the slot is in its tree and so are the slot's
|
|
1067
|
-
* `intercepts`. A page outside that segment has no such slot in its tree —
|
|
1068
|
-
* which is why a link to `/feed/photo/1` from `/about` is an ordinary
|
|
1069
|
-
* navigation to the photo page, not an interception with nowhere to render.
|
|
1070
|
-
*
|
|
1071
|
-
* A slot that intercepts `pathname` is not looked inside: what it holds is
|
|
1072
|
-
* about to be replaced, nested slots and all.
|
|
1073
|
-
*/
|
|
1074
|
-
function interceptingRoutes(
|
|
1075
|
-
slots: $ReadOnlyArray<ResolvedSlot>,
|
|
1076
|
-
pathname: string,
|
|
1077
|
-
): $ReadOnlyArray<SlotRouteRecord> {
|
|
1078
|
-
const found: Array<SlotRouteRecord> = [];
|
|
1079
|
-
for (const slot of slots) {
|
|
1080
|
-
const matched = matchIn(slot.record?.intercepts ?? [], pathname);
|
|
1081
|
-
if (matched != null) {
|
|
1082
|
-
found.push(matched.route);
|
|
1083
|
-
} else {
|
|
1084
|
-
found.push(...interceptingRoutes(slot.slots, pathname));
|
|
1085
|
-
}
|
|
1086
|
-
}
|
|
1087
|
-
return found;
|
|
1088
|
-
}
|
|
1089
|
-
|
|
1090
|
-
/**
|
|
1091
|
-
* `base`, with every slot on it that intercepts `url` rendering what it
|
|
1092
|
-
* intercepts — or `null` when none of them does.
|
|
1093
|
-
*
|
|
1094
|
-
* # What stays, and what does not
|
|
1095
|
-
*
|
|
1096
|
-
* Everything that is not an intercepting slot stays exactly as it was, and that
|
|
1097
|
-
* is the feature rather than a shortcut. `children` goes on rendering the page
|
|
1098
|
-
* the reader navigated *from* — its data, its scroll position, whatever state
|
|
1099
|
-
* its components are holding — and every other slot keeps what it was showing.
|
|
1100
|
-
* An intercepted navigation changes the address bar and the slots that
|
|
1101
|
-
* intercept it, and nothing else. Matching the rest against the new URL would
|
|
1102
|
-
* be an ordinary navigation with a modal on top of it: the page underneath
|
|
1103
|
-
* swapped for the page the URL names, which is precisely what interception
|
|
1104
|
-
* exists not to do.
|
|
1105
|
-
*
|
|
1106
|
-
* Every slot that intercepts the URL renders it, not only the first, because
|
|
1107
|
-
* slots are independent of each other: two named places may each have
|
|
1108
|
-
* something to show for one URL, the way two slots each match one URL by their
|
|
1109
|
-
* own routes.
|
|
1110
|
-
*
|
|
1111
|
-
* # Never on a server
|
|
1112
|
-
*
|
|
1113
|
-
* Nothing on the server calls this. A document request for an intercepted URL
|
|
1114
|
-
* resolves the ordinary page, because a request carries where it is going and
|
|
1115
|
-
* not what was on screen when it was made — which is what a reload, a shared
|
|
1116
|
-
* link and a crawler all are.
|
|
1117
|
-
*
|
|
1118
|
-
* # When the interception cannot render
|
|
1119
|
-
*
|
|
1120
|
-
* A slot whose intercepting page will not import keeps what it had, and when no
|
|
1121
|
-
* slot could render the interception this answers `null`: the navigation goes
|
|
1122
|
-
* ahead as an ordinary one, and the reader gets the page the URL names — what a
|
|
1123
|
-
* reload would have given them — rather than a click that did nothing. An
|
|
1124
|
-
* intercepting page that exports a `loader`, which no slot page may, is the
|
|
1125
|
-
* error boundary for the URL, the way any other slot page's is.
|
|
1126
|
-
*/
|
|
1127
|
-
async function resolveInterception(
|
|
1128
|
-
table: RouteTable,
|
|
1129
|
-
base: ResolvedRoute,
|
|
1130
|
-
url: string,
|
|
1131
|
-
): Promise<?ResolvedRoute> {
|
|
1132
|
-
const { pathname, search } = splitUrl(url);
|
|
1133
|
-
const intercepted: InterceptedUrl = { pathname, searchParams: parseSearch(search) };
|
|
1134
|
-
// The first slot that renders the interception, for the transition's name.
|
|
1135
|
-
let first: ?ResolvedSlot = null;
|
|
1136
|
-
const visit = (slots: $ReadOnlyArray<ResolvedSlot>): Promise<$ReadOnlyArray<ResolvedSlot>> =>
|
|
1137
|
-
Promise.all(
|
|
1138
|
-
slots.map(async (slot): Promise<ResolvedSlot> => {
|
|
1139
|
-
const record = slot.record;
|
|
1140
|
-
const matched = record == null ? null : matchIn(record.intercepts ?? [], pathname);
|
|
1141
|
-
if (record == null || matched == null) {
|
|
1142
|
-
return slot.slots.length === 0 ? slot : { ...slot, slots: await visit(slot.slots) };
|
|
1143
|
-
}
|
|
1144
|
-
const rendered = await resolveSlotRoute(record, matched, slot.above, pathname, intercepted);
|
|
1145
|
-
if (rendered == null) {
|
|
1146
|
-
return slot;
|
|
1147
|
-
}
|
|
1148
|
-
first = first ?? rendered;
|
|
1149
|
-
return rendered;
|
|
1150
|
-
}),
|
|
1151
|
-
);
|
|
1152
|
-
|
|
1153
|
-
let slots: $ReadOnlyArray<ResolvedSlot>;
|
|
1154
|
-
try {
|
|
1155
|
-
slots = await visit(base.slots);
|
|
1156
|
-
} catch (error) {
|
|
1157
|
-
return resolveFailure(table, url, error);
|
|
1158
|
-
}
|
|
1159
|
-
if (first == null) {
|
|
1160
|
-
return null;
|
|
1161
|
-
}
|
|
1162
|
-
return {
|
|
1163
|
-
...base,
|
|
1164
|
-
slots,
|
|
1165
|
-
// The intercepting page's own name, where it or a layout inside the slot
|
|
1166
|
-
// declares one, so a stylesheet can tell a modal opening from a page
|
|
1167
|
-
// arriving. The page underneath has not moved, so its name would say
|
|
1168
|
-
// nothing about this arrival.
|
|
1169
|
-
viewTransition: resolveViewTransition(first.page ?? {}, first.layouts),
|
|
1170
|
-
interception: { pathname, search, base },
|
|
1171
|
-
};
|
|
1172
|
-
}
|
|
1173
|
-
|
|
1174
|
-
/**
|
|
1175
|
-
* The key an intercepted navigation writes into its history entry.
|
|
1176
|
-
*
|
|
1177
|
-
* One string in `history.state` rather than the resolved route, because the
|
|
1178
|
-
* browser structured-clones the state and keeps it across a reload: it can hold
|
|
1179
|
-
* a URL and nothing with a module in it. A URL is also all the entry needs —
|
|
1180
|
-
* where the navigation came from, resolved again when that page is not the one
|
|
1181
|
-
* on screen, and the entry's own URL for what intercepted it.
|
|
1182
|
-
*/
|
|
1183
|
-
const INTERCEPTED_FROM = "uf:intercepted-from";
|
|
1184
|
-
|
|
1185
|
-
/**
|
|
1186
|
-
* The state a history entry for `resolved` is written with.
|
|
1187
|
-
*
|
|
1188
|
-
* `null` for a navigation nothing intercepted, which is what every entry this
|
|
1189
|
-
* router wrote was before interception existed.
|
|
1190
|
-
*/
|
|
1191
|
-
function historyStateFor(resolved: ResolvedRoute): mixed {
|
|
1192
|
-
const interception = resolved.interception;
|
|
1193
|
-
if (interception == null) {
|
|
1194
|
-
return null;
|
|
1195
|
-
}
|
|
1196
|
-
return { [INTERCEPTED_FROM]: interception.base.pathname + interception.base.search };
|
|
1197
|
-
}
|
|
1198
|
-
|
|
1199
|
-
/** Where the history entry holding `state` was intercepted from, if it was. */
|
|
1200
|
-
function interceptedFrom(state: mixed): ?string {
|
|
1201
|
-
if (state == null || typeof state !== "object" || Array.isArray(state)) {
|
|
1202
|
-
return null;
|
|
1203
|
-
}
|
|
1204
|
-
const from = state[INTERCEPTED_FROM];
|
|
1205
|
-
return typeof from === "string" ? from : null;
|
|
1206
|
-
}
|
|
1207
|
-
|
|
1208
|
-
/**
|
|
1209
|
-
* `state` without the interception in it.
|
|
1210
|
-
*
|
|
1211
|
-
* What is left is handed back rather than cleared, because an entry's state is
|
|
1212
|
-
* not only this router's to write: another library may have put something
|
|
1213
|
-
* beside it.
|
|
1214
|
-
*/
|
|
1215
|
-
function withoutInterception(state: mixed): mixed {
|
|
1216
|
-
if (state == null || typeof state !== "object" || Array.isArray(state)) {
|
|
1217
|
-
return state;
|
|
1218
|
-
}
|
|
1219
|
-
const rest: { [string]: mixed } = {};
|
|
1220
|
-
for (const key of Object.keys(state)) {
|
|
1221
|
-
if (key !== INTERCEPTED_FROM) {
|
|
1222
|
-
rest[key] = state[key];
|
|
1223
|
-
}
|
|
1224
|
-
}
|
|
1225
|
-
return Object.keys(rest).length === 0 ? null : rest;
|
|
1226
|
-
}
|
|
1227
|
-
|
|
1228
|
-
async function resolveSlotErrorBoundary(
|
|
1229
|
-
boundary: ?SlotErrorBoundaryLoader,
|
|
1230
|
-
layoutCount: number,
|
|
1231
|
-
): Promise<?ResolvedSlotErrorBoundary> {
|
|
1232
|
-
if (boundary == null) {
|
|
1233
|
-
return null;
|
|
1234
|
-
}
|
|
1235
|
-
const above = Math.min(boundary.above, layoutCount);
|
|
1236
|
-
try {
|
|
1237
|
-
return { module: await loadOnce(boundary.module), above };
|
|
1238
|
-
} catch {
|
|
1239
|
-
// Keep the declared depth even when the custom file fails to import. The
|
|
1240
|
-
// framework fallback still contains the slot instead of escalating the
|
|
1241
|
-
// page beside it.
|
|
1242
|
-
return { module: null, above };
|
|
1243
|
-
}
|
|
1244
|
-
}
|
|
1245
|
-
|
|
1246
|
-
/**
|
|
1247
|
-
* A module, or `null` when it would not import.
|
|
1248
|
-
*
|
|
1249
|
-
* The judgement [`resolveTemplates`] and [`resolveLoading`] already make, at
|
|
1250
|
-
* the granularity a slot needs it: a slot is a second thing beside the page, so
|
|
1251
|
-
* a slot whose module is missing renders nothing rather than taking the route
|
|
1252
|
-
* down with it — and the import error surfaces where it belongs, the next time
|
|
1253
|
-
* the module is asked for.
|
|
1254
|
-
*/
|
|
1255
|
-
async function loadOrNull<TModule>(load: () => Promise<TModule>): Promise<?TModule> {
|
|
1256
|
-
try {
|
|
1257
|
-
return await loadOnce(load);
|
|
1258
|
-
} catch {
|
|
1259
|
-
return null;
|
|
1260
|
-
}
|
|
1261
|
-
}
|
|
1262
|
-
|
|
1263
|
-
/**
|
|
1264
|
-
* The same page module, having said out loud that a slot's loader does not run.
|
|
1265
|
-
*
|
|
1266
|
-
* A slot page is a component. It is *not* handed data, and this throws rather
|
|
1267
|
-
* than passing `undefined` to a page that asked for some, because a slot whose
|
|
1268
|
-
* loader is quietly skipped is exactly the failure ubugeeei-prod/uf#267 is
|
|
1269
|
-
* about — a file written to a convention, and nothing that reads it.
|
|
1270
|
-
*
|
|
1271
|
-
* Why not run it. A page's loader answer is embedded in the document for the
|
|
1272
|
-
* browser to hydrate from, once, under one id; a slot's would have nowhere to
|
|
1273
|
-
* go, so it would run on the server and again in the browser on the way in.
|
|
1274
|
-
* That is not merely two fetches: a loader that reads `cookies()` succeeds on
|
|
1275
|
-
* the server and throws in the browser, and the slot would render on one side
|
|
1276
|
-
* and not the other — a hydration mismatch produced by the router. So the rule
|
|
1277
|
-
* is the narrow one, and lifting it means embedding per-slot data, which is
|
|
1278
|
-
* named in the issue as what is left.
|
|
1279
|
-
*/
|
|
1280
|
-
function withoutLoader(module: PageModule, file: string): PageModule {
|
|
1281
|
-
if (typeof module.loader === "function") {
|
|
1282
|
-
throw new Error(
|
|
1283
|
-
`@uniflowed/router: ${file} is inside a \`@slot\` and exports a \`loader\`, which the ` +
|
|
1284
|
-
"router does not run — a slot's data has nowhere to be embedded for hydration, so it " +
|
|
1285
|
-
"would be fetched again in the browser and a server-only loader would render one tree " +
|
|
1286
|
-
"on the server and another in the page. Fetch inside the component, or move the data to " +
|
|
1287
|
-
"the page the URL names. https://github.com/ubugeeei-prod/uf/issues/267",
|
|
1288
|
-
);
|
|
1289
|
-
}
|
|
1290
|
-
return module;
|
|
1291
|
-
}
|
|
1292
|
-
|
|
1293
|
-
/**
|
|
1294
|
-
* The route's templates, imported.
|
|
1295
|
-
*
|
|
1296
|
-
* A template that will not load is dropped, the way a fallback is: it is a
|
|
1297
|
-
* wrapper around the page, not the page, so a broken wrapper must not become a
|
|
1298
|
-
* broken route. The tree renders without it — the page keeps the layout it was
|
|
1299
|
-
* inside, and loses only the remount — and the import error surfaces where it
|
|
1300
|
-
* belongs, when the module is next asked for.
|
|
1301
|
-
*/
|
|
1302
|
-
async function resolveTemplates(
|
|
1303
|
-
route: RouteRecord,
|
|
1304
|
-
layoutCount: number,
|
|
1305
|
-
): Promise<$ReadOnlyArray<ResolvedTemplate>> {
|
|
1306
|
-
return resolveTemplateRecords(route.templates ?? [], layoutCount);
|
|
1307
|
-
}
|
|
1308
|
-
|
|
1309
|
-
async function resolveTemplateRecords(
|
|
1310
|
-
records: $ReadOnlyArray<TemplateRecord>,
|
|
1311
|
-
layoutCount: number,
|
|
1312
|
-
): Promise<$ReadOnlyArray<ResolvedTemplate>> {
|
|
1313
|
-
if (records.length === 0) {
|
|
1314
|
-
return [];
|
|
1315
|
-
}
|
|
1316
|
-
const loaded = await Promise.all(
|
|
1317
|
-
records.map(async (record) => {
|
|
1318
|
-
try {
|
|
1319
|
-
return {
|
|
1320
|
-
// Clamped exactly as the error and loading boundaries' are: a
|
|
1321
|
-
// `(group)` directory can leave a route with fewer layouts than the
|
|
1322
|
-
// template declared above it.
|
|
1323
|
-
above: Math.min(record.above, layoutCount),
|
|
1324
|
-
module: await loadOnce(record.module),
|
|
1325
|
-
};
|
|
1326
|
-
} catch {
|
|
1327
|
-
return null;
|
|
1328
|
-
}
|
|
1329
|
-
}),
|
|
1330
|
-
);
|
|
1331
|
-
return loaded.filter(Boolean);
|
|
1332
|
-
}
|
|
1333
|
-
|
|
1334
|
-
/**
|
|
1335
|
-
* The route's loading boundaries, imported.
|
|
1336
|
-
*
|
|
1337
|
-
* A boundary whose module will not load is dropped rather than thrown for, and
|
|
1338
|
-
* this is the same judgement `resolveErrorBoundary` makes one function above: a
|
|
1339
|
-
* fallback is what the router shows while it does not yet have the page, so a
|
|
1340
|
-
* broken fallback must not become a broken page. The route renders without that
|
|
1341
|
-
* boundary — the next one out, or the shell, waits for it instead — and the
|
|
1342
|
-
* import error surfaces where it belongs, when the module is next asked for.
|
|
1343
|
-
*/
|
|
1344
|
-
async function resolveLoading(
|
|
1345
|
-
route: RouteRecord,
|
|
1346
|
-
layoutCount: number,
|
|
1347
|
-
): Promise<$ReadOnlyArray<{| readonly above: number, readonly module: LoadingModule |}>> {
|
|
1348
|
-
return resolveLoadingRecords(route.loading ?? [], layoutCount);
|
|
1349
|
-
}
|
|
1350
|
-
|
|
1351
|
-
async function resolveLoadingRecords(
|
|
1352
|
-
records: $ReadOnlyArray<LoadingRecord>,
|
|
1353
|
-
layoutCount: number,
|
|
1354
|
-
): Promise<$ReadOnlyArray<{| readonly above: number, readonly module: LoadingModule |}>> {
|
|
1355
|
-
if (records.length === 0) {
|
|
1356
|
-
return [];
|
|
1357
|
-
}
|
|
1358
|
-
const loaded = await Promise.all(
|
|
1359
|
-
records.map(async (record) => {
|
|
1360
|
-
try {
|
|
1361
|
-
return {
|
|
1362
|
-
// Clamped exactly as the error boundary's is, and for the same
|
|
1363
|
-
// reason: a `(group)` directory can leave a route with fewer layouts
|
|
1364
|
-
// than the boundary that covers it.
|
|
1365
|
-
above: Math.min(record.above, layoutCount),
|
|
1366
|
-
module: await loadOnce(record.module),
|
|
1367
|
-
};
|
|
1368
|
-
} catch {
|
|
1369
|
-
return null;
|
|
1370
|
-
}
|
|
1371
|
-
}),
|
|
1372
|
-
);
|
|
1373
|
-
return loaded.filter(Boolean);
|
|
1374
|
-
}
|
|
1375
|
-
|
|
1376
|
-
/**
|
|
1377
|
-
* The route to render after something threw.
|
|
1378
|
-
*
|
|
1379
|
-
* Two callers, one behaviour: [`resolveMatch`] when a loader or a module
|
|
1380
|
-
* import threw, and `createRenderer` when the *render* did — React's error
|
|
1381
|
-
* boundaries do not run in `renderToString`, so the server has to catch it
|
|
1382
|
-
* itself and resolve again.
|
|
1383
|
-
*/
|
|
1384
|
-
export async function resolveFailure(
|
|
1385
|
-
table: RouteTable,
|
|
1386
|
-
url: string,
|
|
1387
|
-
error: mixed,
|
|
1388
|
-
): Promise<ResolvedRoute> {
|
|
1389
|
-
const { pathname, search } = splitUrl(url);
|
|
1390
|
-
const searchParams = parseSearch(search);
|
|
1391
|
-
if (error instanceof NotFoundError) {
|
|
1392
|
-
try {
|
|
1393
|
-
return await resolveNotFound(table, pathname, search, searchParams);
|
|
1394
|
-
} catch (failure) {
|
|
1395
|
-
// The not-found page itself would not load. Falling through to the error
|
|
1396
|
-
// boundary rather than rethrowing is what keeps the promise above: the
|
|
1397
|
-
// page a project wrote to explain a 404 is not more load-bearing than
|
|
1398
|
-
// the document staying on screen.
|
|
1399
|
-
return resolveError(table, pathname, search, searchParams, routeErrorFor(failure));
|
|
1400
|
-
}
|
|
1401
|
-
}
|
|
1402
|
-
return resolveError(table, pathname, search, searchParams, routeErrorFor(error));
|
|
1403
|
-
}
|
|
1404
|
-
|
|
1405
|
-
/** What a thrown value means to the router. */
|
|
1406
|
-
function routeErrorFor(error: mixed): RouteError {
|
|
1407
|
-
if (error instanceof UnauthorizedError) {
|
|
1408
|
-
return { kind: "unauthorized" };
|
|
1409
|
-
}
|
|
1410
|
-
if (error instanceof ForbiddenError) {
|
|
1411
|
-
return { kind: "forbidden" };
|
|
1412
|
-
}
|
|
1413
|
-
return { kind: "thrown", error };
|
|
1414
|
-
}
|
|
1415
|
-
|
|
1416
|
-
/**
|
|
1417
|
-
* The error boundary a route renders inside, loaded with the route rather than
|
|
1418
|
-
* when it is needed.
|
|
1419
|
-
*
|
|
1420
|
-
* React decides to show a boundary's fallback synchronously, during the render
|
|
1421
|
-
* that threw. A module that still has to be imported is a module that is not
|
|
1422
|
-
* there at the only moment it can be used, so this is one more dynamic import
|
|
1423
|
-
* per navigation and not a lazy one.
|
|
1424
|
-
*
|
|
1425
|
-
* `above` is the boundary's own layout count, clamped to the route's. The
|
|
1426
|
-
* first attempt compared the two layout arrays for a shared prefix, which is
|
|
1427
|
-
* more precise when a `(group)` directory puts a boundary beside a route
|
|
1428
|
-
* rather than above it — and it worked by *reference identity* of the loader
|
|
1429
|
-
* functions, which holds only because `routesModuleSource` deduplicates them
|
|
1430
|
-
* by file. A rule that depends on an invisible property of the generated
|
|
1431
|
-
* module is a rule that reads as zero the moment a table is built any other
|
|
1432
|
-
* way, and it did: it put the boundary outside the layouts it was written
|
|
1433
|
-
* inside. Nesting a boundary per group needs parallel-route trees (#267);
|
|
1434
|
-
* until then this is the honest approximation, and it is stated rather than
|
|
1435
|
-
* inferred.
|
|
1436
|
-
*/
|
|
1437
|
-
async function resolveErrorBoundary(
|
|
1438
|
-
table: RouteTable,
|
|
1439
|
-
pathname: string,
|
|
1440
|
-
layoutCount: number,
|
|
1441
|
-
): Promise<{| readonly module: ?ErrorModule, readonly above: number |}> {
|
|
1442
|
-
const boundary = nearestBoundary(table.errors, pathname);
|
|
1443
|
-
if (boundary == null) {
|
|
1444
|
-
return { module: null, above: 0 };
|
|
1445
|
-
}
|
|
1446
|
-
// Clamped, because a route group can leave a route with fewer layouts than
|
|
1447
|
-
// the boundary covering it, and an `above` past the end would compose the
|
|
1448
|
-
// layouts out of nothing.
|
|
1449
|
-
const above = Math.min(boundary.layouts.length, layoutCount);
|
|
1450
|
-
const load = boundary.module;
|
|
1451
|
-
// The synthesised root record, which names layouts and no module: the
|
|
1452
|
-
// framework's page renders, and `above` still says where — inside the site's
|
|
1453
|
-
// own layouts rather than outside everything. See [`NotFoundBoundary`]`.page`.
|
|
1454
|
-
if (load == null) {
|
|
1455
|
-
return { module: null, above };
|
|
1456
|
-
}
|
|
1457
|
-
try {
|
|
1458
|
-
return { module: await loadOnce(load), above };
|
|
1459
|
-
} catch {
|
|
1460
|
-
// A boundary whose module will not load cannot be the answer to a throw,
|
|
1461
|
-
// and this is why the field is nullable: containment must not itself
|
|
1462
|
-
// depend on an import working. The depth is kept, because the layouts the
|
|
1463
|
-
// boundary named are still there and the framework's page is better inside
|
|
1464
|
-
// them than outside them.
|
|
1465
|
-
return { module: null, above };
|
|
1466
|
-
}
|
|
1467
|
-
}
|
|
1468
|
-
|
|
1469
|
-
/**
|
|
1470
|
-
* The error page for `pathname`, inside the layouts above the boundary that
|
|
1471
|
-
* answers it.
|
|
1472
|
-
*
|
|
1473
|
-
* The layouts are the boundary's, for the same reason [`resolveNotFound`]
|
|
1474
|
-
* gives: they are what stays mounted around the error, and the layouts below
|
|
1475
|
-
* the boundary belong to the subtree that just stopped.
|
|
1476
|
-
*/
|
|
1477
|
-
async function resolveError(
|
|
1478
|
-
table: RouteTable,
|
|
1479
|
-
pathname: string,
|
|
1480
|
-
search: string,
|
|
1481
|
-
searchParams: SearchParams,
|
|
1482
|
-
routeError: RouteError,
|
|
1483
|
-
): Promise<ResolvedRoute> {
|
|
1484
|
-
const boundary = nearestBoundary(table.errors, pathname);
|
|
1485
|
-
let module: ?ErrorModule = null;
|
|
1486
|
-
let layouts: $ReadOnlyArray<LayoutModule> = [];
|
|
1487
|
-
if (boundary != null) {
|
|
1488
|
-
const load = boundary.module;
|
|
1489
|
-
try {
|
|
1490
|
-
// The layouts whether or not there is a module, because the synthesised
|
|
1491
|
-
// root record has layouts and no module and its whole purpose is that
|
|
1492
|
-
// the framework's error page renders inside them: a site whose root
|
|
1493
|
-
// layout owns the masthead and the stylesheet answered a 500 with
|
|
1494
|
-
// neither. See ubugeeei-prod/uf#351.
|
|
1495
|
-
layouts = await Promise.all(boundary.layouts.map((layout) => loadOnce(layout)));
|
|
1496
|
-
module = load == null ? null : await loadOnce(load);
|
|
1497
|
-
} catch {
|
|
1498
|
-
// See `resolveErrorBoundary`: the framework's own page answers instead.
|
|
1499
|
-
module = null;
|
|
1500
|
-
layouts = [];
|
|
1501
|
-
}
|
|
1502
|
-
}
|
|
1503
|
-
|
|
1504
|
-
const declared = await resolveMetadata(
|
|
1505
|
-
module?.metadata != null ? { metadata: module.metadata } : {},
|
|
1506
|
-
layouts,
|
|
1507
|
-
{ params: {}, searchParams, data: undefined },
|
|
1508
|
-
);
|
|
1509
|
-
return {
|
|
1510
|
-
pathname,
|
|
1511
|
-
search,
|
|
1512
|
-
path: "*",
|
|
1513
|
-
params: {},
|
|
1514
|
-
searchParams,
|
|
1515
|
-
page: { default: ResolvedErrorPage },
|
|
1516
|
-
layouts,
|
|
1517
|
-
data: undefined,
|
|
1518
|
-
deferred: null,
|
|
1519
|
-
metadata: declared.title != null ? declared : { ...declared, title: errorTitle(routeError) },
|
|
1520
|
-
// The boundary's own layouts may name one; the page cannot, because the
|
|
1521
|
-
// page here is this module's. An error arriving under the section's
|
|
1522
|
-
// transition is the same answer as a page arriving under it.
|
|
1523
|
-
viewTransition: resolveViewTransition({}, layouts),
|
|
1524
|
-
status: routeErrorStatus(routeError),
|
|
1525
|
-
error: routeError,
|
|
1526
|
-
// All of the boundary's layouts are above it, and no inner boundary is
|
|
1527
|
-
// inserted around a page that already is one; see `RouteView`.
|
|
1528
|
-
errorBoundary: { module, above: layouts.length },
|
|
1529
|
-
// An error page has nothing left to wait for: it renders the value it was
|
|
1530
|
-
// resolved with. A fallback around it would be a boundary that can never
|
|
1531
|
-
// show, which is worse than none.
|
|
1532
|
-
loading: [],
|
|
1533
|
-
templates: [],
|
|
1534
|
-
// And slots for the third time: a slot belongs to the segment the walk went
|
|
1535
|
-
// through, and an error page is matched rather than walked to. A layout
|
|
1536
|
-
// that declares one is still mounted above the boundary, holding the slot
|
|
1537
|
-
// it was rendered with — the boundary replaces what is under it.
|
|
1538
|
-
slots: [],
|
|
1539
|
-
};
|
|
1540
|
-
}
|
|
1541
|
-
|
|
1542
|
-
/**
|
|
1543
|
-
* The not-found page for `pathname`, inside the layouts above the boundary
|
|
1544
|
-
* that answers it.
|
|
1545
|
-
*
|
|
1546
|
-
* The layouts are the *boundary's*, not the ones the URL had already matched.
|
|
1547
|
-
* Taking the matched route's layouts was the other candidate and it is wrong
|
|
1548
|
-
* in both directions: for an unmatched URL there is no matched route to take
|
|
1549
|
-
* them from, and for `notFound()` thrown from a page they would keep the
|
|
1550
|
-
* layouts *below* the boundary — so `app/guide/[slug]/$layout.js` would
|
|
1551
|
-
* wrap a 404 that `app/guide/$not-found.js` answered, which is the layout
|
|
1552
|
-
* of the page that just said it does not exist.
|
|
1553
|
-
*
|
|
1554
|
-
* # The record with no page
|
|
1555
|
-
*
|
|
1556
|
-
* A project that declares no `$not-found.js` anywhere still has a record —
|
|
1557
|
-
* the one the build synthesises for the router root — and it names the root's
|
|
1558
|
-
* layouts and no module. Before that record existed this function answered
|
|
1559
|
-
* with `layouts: []`, so a site whose root layout owns the masthead, the
|
|
1560
|
-
* stylesheet and often `<html>` itself answered an unmatched URL with a white
|
|
1561
|
-
* page carrying `404` and no way to leave it. That was not the nearest-ancestor
|
|
1562
|
-
* rule failing; it was the fallback having no record to take layouts from, and
|
|
1563
|
-
* giving it one is the whole of ubugeeei-prod/uf#351.
|
|
1564
|
-
*
|
|
1565
|
-
* The framework's page then merges its title over the layouts' metadata like
|
|
1566
|
-
* any page would, so a `metadataBase` or an `og:site_name` declared on the root
|
|
1567
|
-
* layout still applies to the 404.
|
|
1568
|
-
*/
|
|
1569
|
-
async function resolveNotFound(
|
|
1570
|
-
table: RouteTable,
|
|
1571
|
-
pathname: string,
|
|
1572
|
-
search: string,
|
|
1573
|
-
searchParams: SearchParams,
|
|
1574
|
-
): Promise<ResolvedRoute> {
|
|
1575
|
-
const record = nearestBoundary(table.notFound, pathname);
|
|
1576
|
-
const load = record?.page;
|
|
1577
|
-
const [page, ...layouts] = await Promise.all([
|
|
1578
|
-
load == null
|
|
1579
|
-
? Promise.resolve<PageModule>({ default: DefaultNotFound, metadata: { title: "Not found" } })
|
|
1580
|
-
: loadOnce(load),
|
|
1581
|
-
...(record?.layouts ?? []).map((layout) => loadOnce(layout)),
|
|
1582
|
-
]);
|
|
1583
|
-
const metadata = await resolveMetadata(page, layouts, {
|
|
1584
|
-
params: {},
|
|
1585
|
-
searchParams,
|
|
1586
|
-
data: undefined,
|
|
1587
|
-
});
|
|
1588
|
-
return {
|
|
1589
|
-
pathname,
|
|
1590
|
-
search,
|
|
1591
|
-
path: "*",
|
|
1592
|
-
params: {},
|
|
1593
|
-
searchParams,
|
|
1594
|
-
page,
|
|
1595
|
-
layouts,
|
|
1596
|
-
data: undefined,
|
|
1597
|
-
deferred: null,
|
|
1598
|
-
metadata,
|
|
1599
|
-
viewTransition: resolveViewTransition(page, layouts),
|
|
1600
|
-
status: 404,
|
|
1601
|
-
error: null,
|
|
1602
|
-
// A not-found page is a page: one that throws is contained like any other.
|
|
1603
|
-
errorBoundary: await resolveErrorBoundary(table, pathname, layouts.length),
|
|
1604
|
-
// A not-found boundary is matched, not nested: `nearestBoundary` picked one
|
|
1605
|
-
// record and the loading files are a property of the route that was walked
|
|
1606
|
-
// to, which this URL never reached. Nothing to wait for, so no boundary.
|
|
1607
|
-
loading: [],
|
|
1608
|
-
// Templates are accumulated on that same walk, and for the same reason.
|
|
1609
|
-
templates: [],
|
|
1610
|
-
// Slots too: a URL that matched no route addressed no slot either.
|
|
1611
|
-
slots: [],
|
|
1612
|
-
};
|
|
1613
|
-
}
|
|
1614
|
-
|
|
1615
|
-
/**
|
|
1616
|
-
* The route's metadata: each declaration merged over the ones outside it.
|
|
1617
|
-
*
|
|
1618
|
-
* Per key, so a page that declares only `canonical` keeps the title its layout
|
|
1619
|
-
* set — with one exception, and it is deliberate. `jsonLd` is gathered along
|
|
1620
|
-
* the way instead of merged, because a nearer declaration of it is an addition
|
|
1621
|
-
* rather than a correction; [`Metadata`] has the argument.
|
|
1622
|
-
*/
|
|
1623
|
-
async function resolveMetadata(
|
|
1624
|
-
page: PageModule,
|
|
1625
|
-
layouts: $ReadOnlyArray<LayoutModule>,
|
|
1626
|
-
args: MetadataArgs,
|
|
1627
|
-
): Promise<Metadata> {
|
|
1628
|
-
let merged: Metadata = {};
|
|
1629
|
-
let structured: $ReadOnlyArray<JsonLd> = [];
|
|
1630
|
-
const take = (declared: Metadata) => {
|
|
1631
|
-
if (declared.jsonLd != null) {
|
|
1632
|
-
structured = [...structured, ...declared.jsonLd];
|
|
1633
|
-
}
|
|
1634
|
-
merged = { ...merged, ...declared };
|
|
1635
|
-
};
|
|
1636
|
-
|
|
1637
|
-
for (const layout of layouts) {
|
|
1638
|
-
if (layout.metadata != null) {
|
|
1639
|
-
take(layout.metadata);
|
|
1640
|
-
}
|
|
1641
|
-
}
|
|
1642
|
-
if (page.frontmatter != null) {
|
|
1643
|
-
const { title, description } = page.frontmatter;
|
|
1644
|
-
merged = {
|
|
1645
|
-
...merged,
|
|
1646
|
-
...(title != null ? { title } : {}),
|
|
1647
|
-
...(description != null ? { description } : {}),
|
|
1648
|
-
};
|
|
1649
|
-
}
|
|
1650
|
-
if (page.metadata != null) {
|
|
1651
|
-
take(page.metadata);
|
|
1652
|
-
}
|
|
1653
|
-
if (typeof page.generateMetadata === "function") {
|
|
1654
|
-
take(await page.generateMetadata(args));
|
|
1655
|
-
}
|
|
1656
|
-
return structured.length === 0 ? merged : { ...merged, jsonLd: structured };
|
|
1657
|
-
}
|
|
1658
|
-
|
|
1659
|
-
/** A module that may name the transition its route arrives under. */
|
|
1660
|
-
type Transitioning = { readonly viewTransition?: string, ... };
|
|
1661
|
-
|
|
1662
|
-
/**
|
|
1663
|
-
* What a stylesheet calls this route's arrival: the nearest declaration wins.
|
|
1664
|
-
*
|
|
1665
|
-
* The same walk `resolveMetadata` does one function above, and stated as its
|
|
1666
|
-
* own function rather than folded into that one because the two answer
|
|
1667
|
-
* different questions and only one of them is a document. Layouts are root
|
|
1668
|
-
* first, so overwriting as it descends leaves the innermost, and the page has
|
|
1669
|
-
* the last word.
|
|
1670
|
-
*
|
|
1671
|
-
* The parameters say what is read rather than naming `PageModule` and
|
|
1672
|
-
* `LayoutModule`, which is the shape `nearestBoundary` already takes for the
|
|
1673
|
-
* same reason: this reads one optional field, so requiring the whole of either
|
|
1674
|
-
* type would be a claim it does not need and cannot use.
|
|
1675
|
-
*/
|
|
1676
|
-
function resolveViewTransition(
|
|
1677
|
-
page: Transitioning,
|
|
1678
|
-
layouts: $ReadOnlyArray<Transitioning>,
|
|
1679
|
-
): ?string {
|
|
1680
|
-
let name: ?string = null;
|
|
1681
|
-
for (const layout of layouts) {
|
|
1682
|
-
if (layout.viewTransition != null) {
|
|
1683
|
-
name = layout.viewTransition;
|
|
1684
|
-
}
|
|
1685
|
-
}
|
|
1686
|
-
return page.viewTransition ?? name;
|
|
1687
|
-
}
|
|
1688
|
-
|
|
1689
|
-
component DefaultNotFound() {
|
|
1690
|
-
return (
|
|
1691
|
-
<main>
|
|
1692
|
-
<title>Not found</title>
|
|
1693
|
-
<h1>404</h1>
|
|
1694
|
-
<p>This page does not exist.</p>
|
|
1695
|
-
</main>
|
|
1696
|
-
);
|
|
1697
|
-
}
|
|
1698
|
-
|
|
1699
|
-
/** The document title an error page gets when nothing declared one. */
|
|
1700
|
-
function errorTitle(error: RouteError): string {
|
|
1701
|
-
return match (error) {
|
|
1702
|
-
{kind: "unauthorized"} => "Sign in required",
|
|
1703
|
-
{kind: "forbidden"} => "Not allowed",
|
|
1704
|
-
{kind: "thrown"} => "Something went wrong",
|
|
1705
|
-
};
|
|
1706
|
-
}
|
|
1707
|
-
|
|
1708
|
-
/**
|
|
1709
|
-
* The framework's error page, for a project that declares no `$error.js`.
|
|
1710
|
-
*
|
|
1711
|
-
* It says which of the three happened and offers the reset, and it does *not*
|
|
1712
|
-
* print the thrown error: on the server that message is written for whoever
|
|
1713
|
-
* deployed the application — a query, a path, a token in a stack — and this
|
|
1714
|
-
* markup is sent to whoever asked for the page. `uf dev` reports the throw in
|
|
1715
|
-
* the terminal and `uf build` fails the route, which are the places the person
|
|
1716
|
-
* who can act on it is looking.
|
|
1717
|
-
*/
|
|
1718
|
-
component DefaultRouteError(error: RouteError, reset: () => void) {
|
|
1719
|
-
const title = errorTitle(error);
|
|
1720
|
-
const detail = match (error) {
|
|
1721
|
-
{kind: "unauthorized"} => "This page needs you to be signed in.",
|
|
1722
|
-
{kind: "forbidden"} => "You do not have access to this page.",
|
|
1723
|
-
{kind: "thrown"} => "This page could not be rendered.",
|
|
1724
|
-
};
|
|
1725
|
-
return (
|
|
1726
|
-
<main>
|
|
1727
|
-
<title>{title}</title>
|
|
1728
|
-
<h1>{title}</h1>
|
|
1729
|
-
<p>{detail}</p>
|
|
1730
|
-
<button type="button" onClick={reset}>
|
|
1731
|
-
Try again
|
|
1732
|
-
</button>
|
|
1733
|
-
</main>
|
|
1734
|
-
);
|
|
1735
|
-
}
|
|
1736
|
-
|
|
1737
|
-
/** The component an error module renders: `default`, or the named `Error`. */
|
|
1738
|
-
function errorComponent(module: ErrorModule): React.ComponentType<ErrorRenderProps> {
|
|
1739
|
-
const component = module.default ?? module.Error;
|
|
1740
|
-
if (component == null) {
|
|
1741
|
-
throw new Error(
|
|
1742
|
-
"@uniflowed/router: an error module must export a component as `default` or `Error`",
|
|
1743
|
-
);
|
|
1744
|
-
}
|
|
1745
|
-
return renderable(component);
|
|
1746
|
-
}
|
|
5
|
+
// A client module, and the directive is load-bearing rather than descriptive:
|
|
6
|
+
// in the module graph React Server Components render in, every export of this
|
|
7
|
+
// file is a client reference — `Link` renders as markup on the server and runs
|
|
8
|
+
// in the browser — and `../server-components.js` is what that graph gets for
|
|
9
|
+
// the hooks instead. Everywhere else the directive changes nothing.
|
|
10
|
+
//
|
|
11
|
+
// A route table is data — the virtual module `virtual:uf/routes` that
|
|
12
|
+
// `@uniflowed/vite` generates from the `app/` directory — and this module is
|
|
13
|
+
// what turns it into a running application in a page: the provider that holds
|
|
14
|
+
// the current route, the hooks that read it, navigation, view transitions and
|
|
15
|
+
// `Link`. What a URL resolves to is `./resolve.js`, the tree a resolved route
|
|
16
|
+
// renders is `./compose.js`, and the metadata elements are `./head.js`; the
|
|
17
|
+
// three are split out because none of them may reach a hook, a context or a
|
|
18
|
+
// class component, which is what lets a server graph resolved under React's
|
|
19
|
+
// `react-server` condition import them (ubugeeei-prod/uf#519).
|
|
1747
20
|
|
|
1748
|
-
|
|
1749
|
-
type ErrorRenderProps = {|
|
|
1750
|
-
readonly error: RouteError,
|
|
1751
|
-
readonly reset: () => void,
|
|
1752
|
-
|};
|
|
21
|
+
"use client";
|
|
1753
22
|
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
23
|
+
import * as React from "react";
|
|
24
|
+
import {
|
|
25
|
+
Suspense,
|
|
26
|
+
createContext,
|
|
27
|
+
startTransition,
|
|
28
|
+
use,
|
|
29
|
+
useContext,
|
|
30
|
+
useEffect,
|
|
31
|
+
useState,
|
|
32
|
+
useSyncExternalStore,
|
|
33
|
+
} from "react";
|
|
34
|
+
// The one thing in this module that only a browser can do, and the reason it
|
|
35
|
+
// is imported here rather than from `../client.js`: a view transition needs
|
|
36
|
+
// the DOM updated inside the callback it was handed, and `startTransition`
|
|
37
|
+
// schedules. "View transitions", below, is the argument. Importing `react-dom`
|
|
38
|
+
// costs the server bundle nothing it did not already have — `internal/stream.js`
|
|
39
|
+
// imports `react-dom/server` — and this entry touches no document while it is
|
|
40
|
+
// being evaluated.
|
|
41
|
+
import { flushSync } from "react-dom";
|
|
1769
42
|
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
* `RouteView` composes it in its layouts exactly like a page, which is what
|
|
1776
|
-
* makes "inside the layouts above the boundary" one code path and not two.
|
|
1777
|
-
*
|
|
1778
|
-
* `reset()` here is `router.refresh()` — this route resolved to an error
|
|
1779
|
-
* because a loader or an import threw, so re-running the resolution is what
|
|
1780
|
-
* trying again means. On the server `refresh` does nothing, which is correct:
|
|
1781
|
-
* a static render has nothing to re-run.
|
|
1782
|
-
*/
|
|
1783
|
-
component ResolvedErrorPage() {
|
|
1784
|
-
const { resolved, router } = useRouterState();
|
|
1785
|
-
const reset = () => {
|
|
1786
|
-
router.refresh().catch(() => {});
|
|
1787
|
-
};
|
|
43
|
+
// The two things a render has to fix — its instant and its random seed — and
|
|
44
|
+
// the provider that fixes them. Imported here rather than left to the
|
|
45
|
+
// application, because a hydration guarantee nobody wires is not a guarantee:
|
|
46
|
+
// see [`routerView`] and ubugeeei-prod/uf#559.
|
|
47
|
+
import { RenderProvider } from "@uniflowed/hooks/render";
|
|
1788
48
|
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
return (
|
|
1794
|
-
<RouteErrorView module={resolved.errorBoundary.module} error={resolved.error} reset={reset} />
|
|
1795
|
-
);
|
|
1796
|
-
}
|
|
49
|
+
// The id of the script the loader data is embedded in. It moved out of the
|
|
50
|
+
// head and into the tree with ubugeeei-prod/uf#373 — see [`payloadElements`]
|
|
51
|
+
// — so the module that renders it is this one rather than `../server.js`.
|
|
52
|
+
import { DATA_ID } from "./document.js";
|
|
1797
53
|
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
54
|
+
// The payload the loader's answer is written as, and the rows it defers. Row 0
|
|
55
|
+
// is the element `DATA_ID` names and is byte-identical to what this file wrote
|
|
56
|
+
// inline before the payload existed whenever nothing is deferred; a promise
|
|
57
|
+
// anywhere in the data turns into a reference and a row of its own. See
|
|
58
|
+
// `./payload.js` for the format and ubugeeei-prod/uf#519 for the half of it
|
|
59
|
+
// that is still an element payload rather than a data one.
|
|
60
|
+
import {
|
|
61
|
+
type PayloadRowMessage,
|
|
62
|
+
PayloadRowError,
|
|
63
|
+
encodePayload,
|
|
64
|
+
encodeRowValue,
|
|
65
|
+
payloadJson,
|
|
66
|
+
} from "./payload.js";
|
|
1803
67
|
|
|
1804
|
-
|
|
68
|
+
// The development-only half of [`RouteView`]: the marks that say which DOM
|
|
69
|
+
// subtree each boundary owns, and the report that reads them. Every reference
|
|
70
|
+
// to it is inside a `BOUNDARY_MARKS` branch, which is why a static import is
|
|
71
|
+
// safe here where `../client.js` needs a dynamic one — a component cannot be
|
|
72
|
+
// awaited in the middle of a render, and `false` folds the references away
|
|
73
|
+
// before the bundler is asked to keep the module. See [`BOUNDARY_MARKS`].
|
|
74
|
+
import { BoundaryReporter } from "./boundaries.js";
|
|
75
|
+
import { routeBoundaries } from "./boundary-data.js";
|
|
76
|
+
import { composeRoute, pageComponent } from "./compose.js";
|
|
77
|
+
import { type FetchedFlight, fetchFlight } from "./flight-browser.js";
|
|
78
|
+
import { type FlightRoot, type RouteState, routeState } from "./flight.js";
|
|
79
|
+
import { Head } from "./head.js";
|
|
80
|
+
import { hasClientPage, matchRoute, nearestBoundary } from "./routing.js";
|
|
81
|
+
import type { RouteParams, SearchParams } from "./routing.js";
|
|
82
|
+
import {
|
|
83
|
+
beneath,
|
|
84
|
+
interceptingRoutes,
|
|
85
|
+
loadOnce,
|
|
86
|
+
resolveInterception,
|
|
87
|
+
resolveMatch,
|
|
88
|
+
} from "./resolve.js";
|
|
89
|
+
import type { Metadata, ResolvedRoute, RouteTable } from "./resolve.js";
|
|
1805
90
|
|
|
1806
|
-
|
|
1807
|
-
* The boundary that catches a throw while the browser renders the subtree.
|
|
1808
|
-
*
|
|
1809
|
-
* A class, because `getDerivedStateFromError` is React's contract for this and
|
|
1810
|
-
* there is no hook that does it — this is the one place in the router where
|
|
1811
|
-
* following React's public contract means not using a function component.
|
|
1812
|
-
*
|
|
1813
|
-
* Recovering on navigation is `componentDidUpdate` watching `resetKey`, not
|
|
1814
|
-
* `key={pathname}` on the boundary. Keying it remounts the subtree on *every*
|
|
1815
|
-
* navigation, error or not, and everything below the boundary goes with it —
|
|
1816
|
-
* which is the layouts, whose whole purpose is to survive navigation with
|
|
1817
|
-
* their scroll position and their open sections intact.
|
|
1818
|
-
*/
|
|
1819
|
-
class RouteErrorBoundary extends React.Component<RouteErrorBoundaryProps, RouteErrorBoundaryState> {
|
|
1820
|
-
constructor(props: RouteErrorBoundaryProps) {
|
|
1821
|
-
super(props);
|
|
1822
|
-
this.state = { error: null };
|
|
1823
|
-
}
|
|
91
|
+
export type { RouteError, RouteParamSpec, RouteParams, SearchParams } from "./routing.js";
|
|
1824
92
|
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
93
|
+
export {
|
|
94
|
+
ForbiddenError,
|
|
95
|
+
NotFoundError,
|
|
96
|
+
RedirectError,
|
|
97
|
+
UnauthorizedError,
|
|
98
|
+
buildRoute,
|
|
99
|
+
forbidden,
|
|
100
|
+
hasClientPage,
|
|
101
|
+
matchRoute,
|
|
102
|
+
notFound,
|
|
103
|
+
parseSearch,
|
|
104
|
+
permanentRedirect,
|
|
105
|
+
redirect,
|
|
106
|
+
routeErrorStatus,
|
|
107
|
+
splitUrl,
|
|
108
|
+
unauthorized,
|
|
109
|
+
} from "./routing.js";
|
|
1828
110
|
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
111
|
+
export type {
|
|
112
|
+
ErrorBoundary,
|
|
113
|
+
ErrorModule,
|
|
114
|
+
Interception,
|
|
115
|
+
JsonLd,
|
|
116
|
+
LayoutModule,
|
|
117
|
+
LoaderArgs,
|
|
118
|
+
LoadingModule,
|
|
119
|
+
LoadingRecord,
|
|
120
|
+
Metadata,
|
|
121
|
+
MetadataArgs,
|
|
122
|
+
NotFoundBoundary,
|
|
123
|
+
PageModule,
|
|
124
|
+
ResolveOptions,
|
|
125
|
+
ResolvedRoute,
|
|
126
|
+
ResolvedSlot,
|
|
127
|
+
Robots,
|
|
128
|
+
RouteMatch,
|
|
129
|
+
RouteRecord,
|
|
130
|
+
RouteTable,
|
|
131
|
+
SlotRecord,
|
|
132
|
+
SlotRouteRecord,
|
|
133
|
+
TemplateModule,
|
|
134
|
+
TemplateRecord,
|
|
135
|
+
TwitterCard,
|
|
136
|
+
} from "./resolve.js";
|
|
1834
137
|
|
|
1835
|
-
|
|
1836
|
-
const { error } = this.state;
|
|
1837
|
-
if (error == null) {
|
|
1838
|
-
return this.props.children;
|
|
1839
|
-
}
|
|
1840
|
-
return (
|
|
1841
|
-
<RouteErrorView
|
|
1842
|
-
module={this.props.module}
|
|
1843
|
-
error={error}
|
|
1844
|
-
reset={() => this.setState({ error: null })}
|
|
1845
|
-
/>
|
|
1846
|
-
);
|
|
1847
|
-
}
|
|
1848
|
-
}
|
|
138
|
+
export { resolveFailure, resolveMatch } from "./resolve.js";
|
|
1849
139
|
|
|
1850
140
|
// ---------------------------------------------------------------------------
|
|
1851
141
|
// View transitions
|
|
@@ -2048,13 +338,28 @@ export type RouteInfo = {|
|
|
|
2048
338
|
*/
|
|
2049
339
|
export type Navigation = "client" | "document";
|
|
2050
340
|
|
|
341
|
+
/**
|
|
342
|
+
* What the router holds, and what every hook and `RouteView` read.
|
|
343
|
+
*
|
|
344
|
+
* Two halves, because a route arrives two ways. `route` is what a hook reads —
|
|
345
|
+
* the path, the parameters, the loader's answer — and it is the same shape
|
|
346
|
+
* whichever way the route was rendered. `view` is what `RouteView` renders:
|
|
347
|
+
* the tree a server composed for React Server Components, or a route resolved
|
|
348
|
+
* from its modules, which the browser composes itself.
|
|
349
|
+
*/
|
|
2051
350
|
type RouterState = {|
|
|
2052
|
-
readonly
|
|
351
|
+
readonly route: RouteState,
|
|
352
|
+
readonly view: RouteViewState,
|
|
2053
353
|
readonly router: Router,
|
|
2054
354
|
readonly pending: boolean,
|
|
2055
355
|
readonly navigation: Navigation,
|
|
2056
356
|
|};
|
|
2057
357
|
|
|
358
|
+
/** What `RouteView` renders: a server's tree, or a route to compose. */
|
|
359
|
+
type RouteViewState =
|
|
360
|
+
| {| readonly kind: "flight", readonly tree: React.Node |}
|
|
361
|
+
| {| readonly kind: "modules", readonly resolved: ResolvedRoute |};
|
|
362
|
+
|
|
2058
363
|
const RouterContext: React.Context<?RouterState> = createContext(null);
|
|
2059
364
|
|
|
2060
365
|
/** The route table the application was started with. */
|
|
@@ -2107,10 +412,19 @@ export function routeTable(): RouteTable {
|
|
|
2107
412
|
return installedTable;
|
|
2108
413
|
}
|
|
2109
414
|
|
|
2110
|
-
/**
|
|
415
|
+
/**
|
|
416
|
+
* Props the app root receives from the client and server entries.
|
|
417
|
+
*
|
|
418
|
+
* One of `flight` and `initial`. A document React Server Components rendered
|
|
419
|
+
* hands the root its payload, on the server and again in the browser, so both
|
|
420
|
+
* sides render the same tree from the same bytes. A single-page application —
|
|
421
|
+
* and a project that turned `app.rsc` off — hands it a route resolved from its
|
|
422
|
+
* modules instead. See ubugeeei-prod/uf#519.
|
|
423
|
+
*/
|
|
2111
424
|
export type AppProps = {|
|
|
2112
425
|
readonly url: string,
|
|
2113
|
-
readonly initial
|
|
426
|
+
readonly initial?: ResolvedRoute,
|
|
427
|
+
readonly flight?: Promise<FlightRoot>,
|
|
2114
428
|
|};
|
|
2115
429
|
|
|
2116
430
|
/**
|
|
@@ -2135,9 +449,13 @@ function isBrowser(): boolean {
|
|
|
2135
449
|
* Provides the current route to the tree and performs navigation.
|
|
2136
450
|
*
|
|
2137
451
|
* On the server the route is fixed for the request. In the browser the
|
|
2138
|
-
* provider listens to history and to `Link` clicks; a navigation
|
|
2139
|
-
* next route
|
|
2140
|
-
* inside a transition, so the
|
|
452
|
+
* provider listens to history and to `Link` clicks; a navigation fetches the
|
|
453
|
+
* next route's payload — or, for a route resolved from its modules, loads its
|
|
454
|
+
* chunks and runs its loader — *before* committing, inside a transition, so the
|
|
455
|
+
* previous page stays interactive meanwhile.
|
|
456
|
+
*
|
|
457
|
+
* Which of the two it does is decided by what it was started with: a Flight
|
|
458
|
+
* payload is [`FlightRouter`], and a resolved route is [`ModuleRouter`].
|
|
2141
459
|
*
|
|
2142
460
|
* # Unless the application asked the browser to do it
|
|
2143
461
|
*
|
|
@@ -2155,7 +473,92 @@ function isBrowser(): boolean {
|
|
|
2155
473
|
* reads it in step with this one, which is four things to keep in step for one
|
|
2156
474
|
* that actually differs.
|
|
2157
475
|
*/
|
|
2158
|
-
export component RouterProvider(
|
|
476
|
+
export component RouterProvider(
|
|
477
|
+
url: string,
|
|
478
|
+
initial?: ResolvedRoute,
|
|
479
|
+
flight?: Promise<FlightRoot>,
|
|
480
|
+
children: React.Node,
|
|
481
|
+
) {
|
|
482
|
+
if (flight != null) {
|
|
483
|
+
return <FlightRouter flight={flight}>{children}</FlightRouter>;
|
|
484
|
+
}
|
|
485
|
+
if (initial == null) {
|
|
486
|
+
throw new Error(
|
|
487
|
+
"@uniflowed/router: RouterProvider was given neither a Flight payload nor a resolved route " +
|
|
488
|
+
"to start from. `virtual:uf/client` and `virtual:uf/server` hand it one of the two.",
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
return (
|
|
492
|
+
<ModuleRouter url={url} initial={initial}>
|
|
493
|
+
{children}
|
|
494
|
+
</ModuleRouter>
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* The key an intercepted navigation writes into its history entry.
|
|
500
|
+
*
|
|
501
|
+
* One string in `history.state` rather than the resolved route, because the
|
|
502
|
+
* browser structured-clones the state and keeps it across a reload: it can hold
|
|
503
|
+
* a URL and nothing with a module in it. A URL is also all the entry needs —
|
|
504
|
+
* where the navigation came from, resolved again when that page is not the one
|
|
505
|
+
* on screen, and the entry's own URL for what intercepted it.
|
|
506
|
+
*/
|
|
507
|
+
const INTERCEPTED_FROM = "uf:intercepted-from";
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* The state a history entry for `resolved` is written with.
|
|
511
|
+
*
|
|
512
|
+
* `null` for a navigation nothing intercepted, which is what every entry this
|
|
513
|
+
* router wrote was before interception existed.
|
|
514
|
+
*/
|
|
515
|
+
function historyStateFor(resolved: ResolvedRoute): mixed {
|
|
516
|
+
const interception = resolved.interception;
|
|
517
|
+
if (interception == null) {
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
return { [INTERCEPTED_FROM]: interception.base.pathname + interception.base.search };
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** Where the history entry holding `state` was intercepted from, if it was. */
|
|
524
|
+
function interceptedFrom(state: mixed): ?string {
|
|
525
|
+
if (state == null || typeof state !== "object" || Array.isArray(state)) {
|
|
526
|
+
return null;
|
|
527
|
+
}
|
|
528
|
+
const from = state[INTERCEPTED_FROM];
|
|
529
|
+
return typeof from === "string" ? from : null;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* `state` without the interception in it.
|
|
534
|
+
*
|
|
535
|
+
* What is left is handed back rather than cleared, because an entry's state is
|
|
536
|
+
* not only this router's to write: another library may have put something
|
|
537
|
+
* beside it.
|
|
538
|
+
*/
|
|
539
|
+
function withoutInterception(state: mixed): mixed {
|
|
540
|
+
if (state == null || typeof state !== "object" || Array.isArray(state)) {
|
|
541
|
+
return state;
|
|
542
|
+
}
|
|
543
|
+
const rest: { [string]: mixed } = {};
|
|
544
|
+
for (const key of Object.keys(state)) {
|
|
545
|
+
if (key !== INTERCEPTED_FROM) {
|
|
546
|
+
rest[key] = state[key];
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
return Object.keys(rest).length === 0 ? null : rest;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* The provider for a route resolved from its modules: a single-page
|
|
554
|
+
* application, and a project that turned `app.rsc` off.
|
|
555
|
+
*
|
|
556
|
+
* It is also the provider that intercepts. Whether a navigation is intercepted
|
|
557
|
+
* is a question about the slots on screen, and only a router holding a route
|
|
558
|
+
* resolved from its modules has them to ask; a payload holds a rendered tree.
|
|
559
|
+
* See [`resolveInterception`].
|
|
560
|
+
*/
|
|
561
|
+
component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node) {
|
|
2159
562
|
const [resolved, setResolved] = useState<ResolvedRoute>(initial);
|
|
2160
563
|
const [pending, setPending] = useState<boolean>(false);
|
|
2161
564
|
// Read once per render rather than per navigation: it is installed by the
|
|
@@ -2417,11 +820,246 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
|
|
|
2417
820
|
},
|
|
2418
821
|
};
|
|
2419
822
|
|
|
2420
|
-
const value: RouterState = {
|
|
823
|
+
const value: RouterState = {
|
|
824
|
+
route: routeState(resolved),
|
|
825
|
+
view: { kind: "modules", resolved },
|
|
826
|
+
router,
|
|
827
|
+
pending,
|
|
828
|
+
navigation,
|
|
829
|
+
};
|
|
830
|
+
return <RouterContext.Provider value={value}>{children}</RouterContext.Provider>;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* The provider for a route React Server Components rendered.
|
|
835
|
+
*
|
|
836
|
+
* What it holds is the payload rather than a resolved route: `use` reads its
|
|
837
|
+
* root — the route a hook reads and the tree `RouteView` renders — and a
|
|
838
|
+
* navigation fetches the next route's payload and swaps the promise. The
|
|
839
|
+
* browser resolves nothing and imports no page, layout or loader; the server
|
|
840
|
+
* did all three, and a component that needs the browser arrived as a client
|
|
841
|
+
* reference inside the tree.
|
|
842
|
+
*
|
|
843
|
+
* A navigation reads the next payload's root before it commits, for the reason
|
|
844
|
+
* [`ModuleRouter`] resolves the next route before it commits: the page on
|
|
845
|
+
* screen stays interactive while the next one is on its way, and a commit
|
|
846
|
+
* inside a view transition is synchronous, so a root that had not arrived would
|
|
847
|
+
* show nothing rather than the page being left. What may still suspend after
|
|
848
|
+
* the commit is a `$loading.js` boundary inside the new tree, which is what that
|
|
849
|
+
* file is for.
|
|
850
|
+
*/
|
|
851
|
+
component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
|
|
852
|
+
const [current, setCurrent] = useState<Promise<FlightRoot>>(flight);
|
|
853
|
+
const [pending, setPending] = useState<boolean>(false);
|
|
854
|
+
const root = use(current);
|
|
855
|
+
// Read once per render, for the reason `ModuleRouter` reads it once.
|
|
856
|
+
const navigation = navigationMode();
|
|
857
|
+
|
|
858
|
+
const navigate = async (to: string, options?: NavigateOptions): Promise<void> => {
|
|
859
|
+
if (!isBrowser()) {
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
const target = new URL(to, window.location.href);
|
|
863
|
+
const next = target.pathname + target.search;
|
|
864
|
+
// The browser's job in this application; `ModuleRouter` has the argument.
|
|
865
|
+
if (navigation === "document") {
|
|
866
|
+
if (options?.replace === true) {
|
|
867
|
+
window.location.replace(target.href);
|
|
868
|
+
} else {
|
|
869
|
+
window.location.assign(target.href);
|
|
870
|
+
}
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
setPending(true);
|
|
874
|
+
try {
|
|
875
|
+
const fetched = await (takePrefetched(next) ?? fetchFlight(next));
|
|
876
|
+
// Not a payload: a redirect off this origin, or a host that has no payload
|
|
877
|
+
// for this URL. The browser loads it as a document, which is what the
|
|
878
|
+
// anchor would have done.
|
|
879
|
+
if (fetched.kind === "document") {
|
|
880
|
+
window.location.assign(fetched.url);
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
const payload = fetched.root;
|
|
884
|
+
const nextRoot = await payload;
|
|
885
|
+
// The URL the payload came from, which is a redirect's target when the
|
|
886
|
+
// route redirected: the history entry is where the visitor ended up.
|
|
887
|
+
const landed = fetched.url + target.hash;
|
|
888
|
+
if (options?.replace === true) {
|
|
889
|
+
window.history.replaceState(null, "", landed);
|
|
890
|
+
} else {
|
|
891
|
+
window.history.pushState(null, "", landed);
|
|
892
|
+
}
|
|
893
|
+
const commit = () => {
|
|
894
|
+
setCurrent(payload);
|
|
895
|
+
setPending(false);
|
|
896
|
+
};
|
|
897
|
+
if (options?.transition === false) {
|
|
898
|
+
startTransition(commit);
|
|
899
|
+
} else {
|
|
900
|
+
withViewTransition(nextRoot.route.viewTransition, commit);
|
|
901
|
+
}
|
|
902
|
+
if (options?.scroll !== false) {
|
|
903
|
+
if (target.hash !== "") {
|
|
904
|
+
const element = document.getElementById(target.hash.slice(1));
|
|
905
|
+
if (element != null) {
|
|
906
|
+
element.scrollIntoView();
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
window.scrollTo(0, 0);
|
|
911
|
+
}
|
|
912
|
+
} catch (error) {
|
|
913
|
+
setPending(false);
|
|
914
|
+
throw error;
|
|
915
|
+
}
|
|
916
|
+
};
|
|
917
|
+
|
|
918
|
+
useEffect(() => {
|
|
919
|
+
if (!isBrowser()) {
|
|
920
|
+
return undefined;
|
|
921
|
+
}
|
|
922
|
+
// No history entry was pushed, so there is nothing to pop back into; see
|
|
923
|
+
// `ModuleRouter`.
|
|
924
|
+
if (navigation === "document") {
|
|
925
|
+
return undefined;
|
|
926
|
+
}
|
|
927
|
+
const onPopState = () => {
|
|
928
|
+
const next = window.location.pathname + window.location.search;
|
|
929
|
+
// The history entry already moved; a payload that cannot be had for it is
|
|
930
|
+
// a document to load, and a reload is the browser's way to load it.
|
|
931
|
+
fetchFlight(next).then(
|
|
932
|
+
(fetched) => {
|
|
933
|
+
if (fetched.kind === "document") {
|
|
934
|
+
window.location.reload();
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
const payload = fetched.root;
|
|
938
|
+
payload.then(
|
|
939
|
+
(nextRoot) => {
|
|
940
|
+
withViewTransition(nextRoot.route.viewTransition, () => {
|
|
941
|
+
setCurrent(payload);
|
|
942
|
+
});
|
|
943
|
+
},
|
|
944
|
+
() => {
|
|
945
|
+
window.location.reload();
|
|
946
|
+
},
|
|
947
|
+
);
|
|
948
|
+
},
|
|
949
|
+
() => {
|
|
950
|
+
window.location.reload();
|
|
951
|
+
},
|
|
952
|
+
);
|
|
953
|
+
};
|
|
954
|
+
window.addEventListener("popstate", onPopState);
|
|
955
|
+
return () => {
|
|
956
|
+
window.removeEventListener("popstate", onPopState);
|
|
957
|
+
};
|
|
958
|
+
}, []);
|
|
959
|
+
|
|
960
|
+
const router: Router = {
|
|
961
|
+
push: (to, options) => navigate(to, options),
|
|
962
|
+
replace: (to) => navigate(to, { replace: true }),
|
|
963
|
+
prefetch: async (to) => {
|
|
964
|
+
// Under document navigation there is no next render in this page to
|
|
965
|
+
// fetch a payload for; see `ModuleRouter`'s prefetch.
|
|
966
|
+
if (!isBrowser() || navigation === "document") {
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
const target = new URL(to, window.location.href);
|
|
970
|
+
if (target.origin !== window.location.origin) {
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
await prefetchFlight(target.pathname + target.search);
|
|
974
|
+
},
|
|
975
|
+
refresh: async () => {
|
|
976
|
+
if (!isBrowser()) {
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
979
|
+
if (navigation === "document") {
|
|
980
|
+
window.location.reload();
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
const fetched = await fetchFlight(window.location.pathname + window.location.search);
|
|
984
|
+
if (fetched.kind === "document") {
|
|
985
|
+
window.location.reload();
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
const payload = fetched.root;
|
|
989
|
+
await payload;
|
|
990
|
+
// No view transition: a refresh is the same URL rendered again. See
|
|
991
|
+
// `ModuleRouter`'s refresh.
|
|
992
|
+
startTransition(() => {
|
|
993
|
+
setCurrent(payload);
|
|
994
|
+
});
|
|
995
|
+
},
|
|
996
|
+
back: () => {
|
|
997
|
+
if (isBrowser()) {
|
|
998
|
+
window.history.back();
|
|
999
|
+
}
|
|
1000
|
+
},
|
|
1001
|
+
forward: () => {
|
|
1002
|
+
if (isBrowser()) {
|
|
1003
|
+
window.history.forward();
|
|
1004
|
+
}
|
|
1005
|
+
},
|
|
1006
|
+
};
|
|
1007
|
+
|
|
1008
|
+
const value: RouterState = {
|
|
1009
|
+
route: root.route,
|
|
1010
|
+
view: { kind: "flight", tree: root.tree },
|
|
1011
|
+
router,
|
|
1012
|
+
pending,
|
|
1013
|
+
navigation,
|
|
1014
|
+
};
|
|
2421
1015
|
return <RouterContext.Provider value={value}>{children}</RouterContext.Provider>;
|
|
2422
1016
|
}
|
|
2423
1017
|
|
|
2424
|
-
|
|
1018
|
+
/**
|
|
1019
|
+
* Payloads a `Link` fetched on intent, kept for the navigation that follows it.
|
|
1020
|
+
*
|
|
1021
|
+
* Bounded and short-lived, because a payload is the rendering of a route at one
|
|
1022
|
+
* moment: one old enough to disagree with the server is one a navigation should
|
|
1023
|
+
* not show, and a page with a hundred links hovered over must not hold a hundred
|
|
1024
|
+
* renderings. Taken rather than read, so a prefetched payload serves exactly one
|
|
1025
|
+
* navigation and the next visit to the same URL asks again.
|
|
1026
|
+
*/
|
|
1027
|
+
const PREFETCH_LIMIT = 32;
|
|
1028
|
+
const PREFETCH_LIFETIME_MS = 30000;
|
|
1029
|
+
const prefetchedFlights: Map<
|
|
1030
|
+
string,
|
|
1031
|
+
{| readonly fetched: Promise<FetchedFlight>, readonly at: number |},
|
|
1032
|
+
> = new Map();
|
|
1033
|
+
|
|
1034
|
+
function prefetchFlight(url: string): Promise<FetchedFlight> {
|
|
1035
|
+
const existing = prefetchedFlights.get(url);
|
|
1036
|
+
if (existing != null && Date.now() - existing.at < PREFETCH_LIFETIME_MS) {
|
|
1037
|
+
return existing.fetched;
|
|
1038
|
+
}
|
|
1039
|
+
if (existing == null && prefetchedFlights.size >= PREFETCH_LIMIT) {
|
|
1040
|
+
const oldest = prefetchedFlights.keys().next();
|
|
1041
|
+
if (oldest.done !== true) {
|
|
1042
|
+
prefetchedFlights.delete(oldest.value);
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
const fetched = fetchFlight(url);
|
|
1046
|
+
prefetchedFlights.set(url, { fetched, at: Date.now() });
|
|
1047
|
+
fetched.catch(() => {
|
|
1048
|
+
prefetchedFlights.delete(url);
|
|
1049
|
+
});
|
|
1050
|
+
return fetched;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function takePrefetched(url: string): Promise<FetchedFlight> | null {
|
|
1054
|
+
const entry = prefetchedFlights.get(url);
|
|
1055
|
+
prefetchedFlights.delete(url);
|
|
1056
|
+
if (entry == null || Date.now() - entry.at >= PREFETCH_LIFETIME_MS) {
|
|
1057
|
+
return null;
|
|
1058
|
+
}
|
|
1059
|
+
return entry.fetched;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
export hook useRouterState(): RouterState {
|
|
2425
1063
|
const state = useContext(RouterContext);
|
|
2426
1064
|
if (state == null) {
|
|
2427
1065
|
throw new Error(
|
|
@@ -2433,13 +1071,13 @@ hook useRouterState(): RouterState {
|
|
|
2433
1071
|
|
|
2434
1072
|
/** The current route. */
|
|
2435
1073
|
export hook useRoute(): RouteInfo {
|
|
2436
|
-
const {
|
|
1074
|
+
const { route, pending } = useRouterState();
|
|
2437
1075
|
return {
|
|
2438
|
-
path:
|
|
2439
|
-
pathname:
|
|
2440
|
-
params:
|
|
2441
|
-
searchParams:
|
|
2442
|
-
data: useResolvedData(
|
|
1076
|
+
path: route.path,
|
|
1077
|
+
pathname: route.pathname,
|
|
1078
|
+
params: route.params,
|
|
1079
|
+
searchParams: route.searchParams,
|
|
1080
|
+
data: useResolvedData(route),
|
|
2443
1081
|
pending,
|
|
2444
1082
|
};
|
|
2445
1083
|
}
|
|
@@ -2460,9 +1098,9 @@ export hook useRoute(): RouteInfo {
|
|
|
2460
1098
|
* it is a benefit not taken rather than a regression, and it is visible: the
|
|
2461
1099
|
* fallback does not appear.
|
|
2462
1100
|
*/
|
|
2463
|
-
hook useResolvedData(
|
|
2464
|
-
const loader =
|
|
2465
|
-
return loader == null ?
|
|
1101
|
+
hook useResolvedData(route: RouteState): mixed {
|
|
1102
|
+
const loader = route.deferred;
|
|
1103
|
+
return loader == null ? route.data : use(loader);
|
|
2466
1104
|
}
|
|
2467
1105
|
|
|
2468
1106
|
/** Navigation. */
|
|
@@ -2491,7 +1129,7 @@ export hook useRouter(): Router {
|
|
|
2491
1129
|
* same file, keyed by route. Until it is there, this says what is true.
|
|
2492
1130
|
*/
|
|
2493
1131
|
export hook useLoaderData(): mixed {
|
|
2494
|
-
return useResolvedData(useRouterState().
|
|
1132
|
+
return useResolvedData(useRouterState().route);
|
|
2495
1133
|
}
|
|
2496
1134
|
|
|
2497
1135
|
/**
|
|
@@ -2517,150 +1155,38 @@ const BOUNDARY_MARKS: boolean = import.meta.hot != null;
|
|
|
2517
1155
|
* Renders the matched page inside its layouts, innermost last, with the
|
|
2518
1156
|
* document metadata as hoistable head elements.
|
|
2519
1157
|
*
|
|
2520
|
-
*
|
|
2521
|
-
*
|
|
2522
|
-
*
|
|
2523
|
-
*
|
|
2524
|
-
*
|
|
2525
|
-
* counts the layouts still *outside* the element built so far, which is what
|
|
2526
|
-
* `above` means on both a route's `errorBoundary` and each of its `loading`
|
|
2527
|
-
* entries — one number, one meaning, one place it is compared.
|
|
2528
|
-
*
|
|
2529
|
-
* # Where the error boundaries go
|
|
2530
|
-
*
|
|
2531
|
-
* Two, and they are not the same thing twice. The inner one is the project's
|
|
2532
|
-
* `$error.js`, placed at the depth the file sits at, so the layouts above
|
|
2533
|
-
* it stay mounted and interactive while the subtree below is replaced — that
|
|
2534
|
-
* placement *is* the feature. The outer one has no module and so renders the
|
|
2535
|
-
* framework's page; it is what stands between a throw in a root layout, or in
|
|
2536
|
-
* the error component itself, and an unmounted document. A single boundary
|
|
2537
|
-
* cannot be both: put it outside and a page's throw takes the navigation down
|
|
2538
|
-
* with it; put it inside and nothing catches the layout above.
|
|
2539
|
-
*
|
|
2540
|
-
* # Where the loading boundaries go
|
|
2541
|
-
*
|
|
2542
|
-
* Inside the layout of the segment that declared the file and outside
|
|
2543
|
-
* everything under it, which is what makes the shell arrive first: a renderer
|
|
2544
|
-
* streaming this tree can send every layout down to the boundary, and the
|
|
2545
|
-
* fallback, before whatever the page is waiting for has resolved. A segment
|
|
2546
|
-
* with no `$loading.js` contributes no boundary at all — it is not wrapped
|
|
2547
|
-
* in a `<Suspense fallback={null}>` on the way past — so a project that
|
|
2548
|
-
* declares none renders the tree it rendered before this existed, and a page
|
|
2549
|
-
* that suspends without a boundary above it still fails the way React says it
|
|
2550
|
-
* should rather than silently rendering nothing.
|
|
2551
|
-
*
|
|
2552
|
-
* The error boundary goes *outside* the fallback at the same depth. A throw
|
|
2553
|
-
* while the page is resolving has to reach a boundary that is still mounted,
|
|
2554
|
-
* and the `<Suspense>` is part of what the throw came out of.
|
|
2555
|
-
*
|
|
2556
|
-
* # Where the templates go
|
|
2557
|
-
*
|
|
2558
|
-
* Inside their own segment's layout and outside everything else at that depth
|
|
2559
|
-
* — the error boundary, the fallback and the page — which is what makes a
|
|
2560
|
-
* template's remount mean "this segment and what is under it" and a layout's
|
|
2561
|
-
* persistence mean "this segment's frame". The two files are the same wrapper
|
|
2562
|
-
* with opposite answers to one question, so they are one line apart here, and
|
|
2563
|
-
* the whole of the difference is the `key` — see [`insideTemplates`], which is
|
|
2564
|
-
* that line's other half.
|
|
2565
|
-
*
|
|
2566
|
-
* # Where the boundary marks go
|
|
2567
|
-
*
|
|
2568
|
-
* Inside each boundary and around nothing else, under `uf dev` only. A
|
|
2569
|
-
* `<Suspense>` and a class boundary each render no element of their own, so the
|
|
2570
|
-
* run of nodes one owns is indistinguishable on the page from the layout's own
|
|
2571
|
-
* nodes beside it — the marks are what distinguish it, and this loop is the
|
|
2572
|
-
* only place that knows which boundary is which. `./boundaries.js` has the
|
|
2573
|
-
* mechanism and the argument; every reference to it here is inside a
|
|
2574
|
-
* [`BOUNDARY_MARKS`] branch, so a build has none of it. See
|
|
2575
|
-
* ubugeeei-prod/uf#520.
|
|
1158
|
+
* The walk itself is `composeRoute` in `./compose.js`, which has the whole
|
|
1159
|
+
* argument for where each boundary goes. What is left here is the half that
|
|
1160
|
+
* reads the router: which route, the page element that carries the loader's
|
|
1161
|
+
* answer and the payload the browser hydrates it from, and — under `uf dev` —
|
|
1162
|
+
* the boundary marks and the report that watches them. See ubugeeei-prod/uf#520.
|
|
2576
1163
|
*/
|
|
2577
1164
|
export component RouteView() {
|
|
2578
|
-
const {
|
|
2579
|
-
|
|
1165
|
+
const { view } = useRouterState();
|
|
1166
|
+
// A tree a server composed for React Server Components is the whole of it:
|
|
1167
|
+
// the boundaries, the fallbacks, the marks and the head were placed by
|
|
1168
|
+
// `composeRoute` on the server, before any of it was written into the payload.
|
|
1169
|
+
if (view.kind === "flight") {
|
|
1170
|
+
return view.tree;
|
|
1171
|
+
}
|
|
1172
|
+
const resolved = view.resolved;
|
|
2580
1173
|
const loader = resolved.deferred;
|
|
2581
|
-
// The route's boundaries, named once and read by both the marks
|
|
2582
|
-
// report that watches them. `installedTable`
|
|
2583
|
-
// which throws: a test may render this view
|
|
2584
|
-
// a table, and an error boundary named by
|
|
2585
|
-
// one named by its file rather than wrong.
|
|
1174
|
+
// The route's boundaries, named once and read by both the marks the
|
|
1175
|
+
// composition places and the report that watches them. `installedTable`
|
|
1176
|
+
// rather than [`routeTable`], which throws: a test may render this view
|
|
1177
|
+
// without an entry having installed a table, and an error boundary named by
|
|
1178
|
+
// its depth alone is worth less than one named by its file rather than wrong.
|
|
2586
1179
|
const marks = BOUNDARY_MARKS
|
|
2587
1180
|
? routeBoundaries(
|
|
2588
1181
|
resolved,
|
|
2589
1182
|
nearestBoundary(installedTable?.errors ?? [], resolved.pathname)?.file,
|
|
2590
1183
|
)
|
|
2591
1184
|
: null;
|
|
2592
|
-
|
|
2593
|
-
// every boundary the loop below adds — which is what makes the layouts and
|
|
2594
|
-
// the fallback the shell rather than something waiting behind the loader.
|
|
2595
|
-
let element: React.Node =
|
|
1185
|
+
const page =
|
|
2596
1186
|
loader == null ? <RenderedPage data={resolved.data} /> : <AwaitedPage loader={loader} />;
|
|
2597
|
-
|
|
2598
|
-
for (let depth = resolved.layouts.length; depth >= 0; depth -= 1) {
|
|
2599
|
-
// Backwards over a root-first list, so the deepest segment's fallback ends
|
|
2600
|
-
// up closest to the page. Two segments land on the same depth whenever the
|
|
2601
|
-
// inner one declares no layout of its own, and then this order is the only
|
|
2602
|
-
// thing that keeps them nested the way the directories are.
|
|
2603
|
-
for (let index = resolved.loading.length - 1; index >= 0; index -= 1) {
|
|
2604
|
-
const boundary = resolved.loading[index];
|
|
2605
|
-
if (boundary.above !== depth) {
|
|
2606
|
-
continue;
|
|
2607
|
-
}
|
|
2608
|
-
const Fallback = loadingComponent(boundary.module);
|
|
2609
|
-
element = (
|
|
2610
|
-
<Suspense fallback={<Fallback />}>
|
|
2611
|
-
{BOUNDARY_MARKS ? insideBoundary(marks?.get(suspenseId(index)), element) : element}
|
|
2612
|
-
</Suspense>
|
|
2613
|
-
);
|
|
2614
|
-
}
|
|
2615
|
-
// Placed on `above` alone, and not on there being a module: a `null` one is
|
|
2616
|
-
// the framework's own error page, and where it renders is exactly the
|
|
2617
|
-
// question ubugeeei-prod/uf#351 asks. A project that declares no
|
|
2618
|
-
// `$error.js` has the record the build synthesises for the router root,
|
|
2619
|
-
// whose `above` is the root's layouts — so the framework's page appears
|
|
2620
|
-
// inside the masthead rather than in place of the document. A table with no
|
|
2621
|
-
// record at all answers 0, which puts this boundary outside every layout,
|
|
2622
|
-
// where the outer one below already stood.
|
|
2623
|
-
//
|
|
2624
|
-
// Not around a route that already resolved to its error page: that page is
|
|
2625
|
-
// the boundary's own component, and wrapping it in the same boundary would
|
|
2626
|
-
// answer a throw inside it with itself.
|
|
2627
|
-
if (depth === above && resolved.error == null) {
|
|
2628
|
-
element = (
|
|
2629
|
-
<RouteErrorBoundary module={module} resetKey={resolved.pathname}>
|
|
2630
|
-
{BOUNDARY_MARKS ? insideBoundary(marks?.get(ROUTE_ERROR_ID), element) : element}
|
|
2631
|
-
</RouteErrorBoundary>
|
|
2632
|
-
);
|
|
2633
|
-
}
|
|
2634
|
-
element = insideTemplates(element, resolved, depth);
|
|
2635
|
-
if (depth > 0) {
|
|
2636
|
-
const Layout = layoutComponent(resolved.layouts[depth - 1]);
|
|
2637
|
-
// The slots declared on this layout's own segment, beside `children`.
|
|
2638
|
-
// Spread rather than passed as one `slots` object, because a slot is a
|
|
2639
|
-
// prop a layout declares by name — `component Dashboard(children, team)`
|
|
2640
|
-
// — and a bag would make every layout destructure a map to find out
|
|
2641
|
-
// whether the router had anything for it.
|
|
2642
|
-
// The spread first and `params` after it, so that a slot named after a
|
|
2643
|
-
// prop the layout already has loses rather than wins. `@params` and
|
|
2644
|
-
// `@children` are refused by the scan, and this is the second line of
|
|
2645
|
-
// that defence for a table written by hand: losing a slot is a hole in
|
|
2646
|
-
// the page, and overwriting `params` is every route in the segment
|
|
2647
|
-
// rendering against the wrong parameters.
|
|
2648
|
-
element = (
|
|
2649
|
-
<Layout {...slotsAt(resolved.slots, depth)} params={resolved.params}>
|
|
2650
|
-
{element}
|
|
2651
|
-
</Layout>
|
|
2652
|
-
);
|
|
2653
|
-
}
|
|
2654
|
-
}
|
|
2655
|
-
if (needsRootStreamFrame(resolved)) {
|
|
2656
|
-
element = <RootStreamFrame>{element}</RootStreamFrame>;
|
|
2657
|
-
}
|
|
2658
1187
|
return (
|
|
2659
1188
|
<>
|
|
2660
|
-
|
|
2661
|
-
<RouteErrorBoundary module={null} resetKey={resolved.pathname}>
|
|
2662
|
-
{BOUNDARY_MARKS ? insideBoundary(marks?.get(ROOT_ERROR_ID), element) : element}
|
|
2663
|
-
</RouteErrorBoundary>
|
|
1189
|
+
{composeRoute(resolved, { page, marks })}
|
|
2664
1190
|
{/* After the tree rather than before it, so its effect runs once every
|
|
2665
1191
|
mark below has had its own — which is the commit the marks are in. */}
|
|
2666
1192
|
{BOUNDARY_MARKS && marks != null ? (
|
|
@@ -2670,28 +1196,6 @@ export component RouteView() {
|
|
|
2670
1196
|
);
|
|
2671
1197
|
}
|
|
2672
1198
|
|
|
2673
|
-
/**
|
|
2674
|
-
* Whether the outermost route fallback needs one host element above it.
|
|
2675
|
-
*
|
|
2676
|
-
* React can flush a shell whose suspended boundary is inside any host element,
|
|
2677
|
-
* but not one whose boundary is a direct child of the render root. A route with
|
|
2678
|
-
* no layout and a root `$loading.js` is exactly that second tree: every
|
|
2679
|
-
* framework component above it renders no element, so the fallback waits for
|
|
2680
|
-
* the page it was meant to stand in for. A root layout is already the element
|
|
2681
|
-
* that can carry it, and deeper fallbacks sit inside a layout by construction.
|
|
2682
|
-
*/
|
|
2683
|
-
function needsRootStreamFrame(resolved: ResolvedRoute): boolean {
|
|
2684
|
-
return resolved.layouts.length === 0 && resolved.loading.some((boundary) => boundary.above === 0);
|
|
2685
|
-
}
|
|
2686
|
-
|
|
2687
|
-
component RootStreamFrame(children: React.Node) {
|
|
2688
|
-
return (
|
|
2689
|
-
<div data-uf-stream-root="" style={{ display: "contents" }}>
|
|
2690
|
-
{children}
|
|
2691
|
-
</div>
|
|
2692
|
-
);
|
|
2693
|
-
}
|
|
2694
|
-
|
|
2695
1199
|
/**
|
|
2696
1200
|
* The page, with the loader's answer and the copy of it the browser hydrates
|
|
2697
1201
|
* from.
|
|
@@ -2711,7 +1215,12 @@ component RootStreamFrame(children: React.Node) {
|
|
|
2711
1215
|
* read out of those very elements.
|
|
2712
1216
|
*/
|
|
2713
1217
|
component RenderedPage(data: mixed) {
|
|
2714
|
-
const {
|
|
1218
|
+
const { view } = useRouterState();
|
|
1219
|
+
// Only ever rendered by `RouteView` for a route resolved from its modules.
|
|
1220
|
+
if (view.kind !== "modules") {
|
|
1221
|
+
return null;
|
|
1222
|
+
}
|
|
1223
|
+
const resolved = view.resolved;
|
|
2715
1224
|
const Page = pageComponent(resolved.page);
|
|
2716
1225
|
return (
|
|
2717
1226
|
<>
|
|
@@ -2908,445 +1417,6 @@ function rowFailure(error: mixed): string {
|
|
|
2908
1417
|
return error instanceof Error ? `${ROW_FAILURE} ${error.message}` : ROW_FAILURE;
|
|
2909
1418
|
}
|
|
2910
1419
|
|
|
2911
|
-
/**
|
|
2912
|
-
* The component a page module renders: its default export, or the named
|
|
2913
|
-
* `Page` that `uf create` scaffolds. An MDX page always has a default export.
|
|
2914
|
-
*/
|
|
2915
|
-
function pageComponent(module: PageModule): React.ComponentType<PageRenderProps> {
|
|
2916
|
-
const component = module.default ?? module.Page;
|
|
2917
|
-
if (component == null) {
|
|
2918
|
-
throw new Error(
|
|
2919
|
-
"@uniflowed/router: a page module must export a component as `default` or `Page`",
|
|
2920
|
-
);
|
|
2921
|
-
}
|
|
2922
|
-
return renderable(component);
|
|
2923
|
-
}
|
|
2924
|
-
|
|
2925
|
-
/**
|
|
2926
|
-
* The component a loading module renders: `default`, or the named `Loading`.
|
|
2927
|
-
*
|
|
2928
|
-
* No props, unlike a page or a layout. A fallback is what the router shows
|
|
2929
|
-
* when it does not have the route's answer yet, so there is nothing it could
|
|
2930
|
-
* be handed that would be true — not `data`, which is the thing being waited
|
|
2931
|
-
* for, and not `children`, because it renders instead of them.
|
|
2932
|
-
*/
|
|
2933
|
-
function loadingComponent(module: LoadingModule): React.ComponentType<{||}> {
|
|
2934
|
-
const component = module.default ?? module.Loading;
|
|
2935
|
-
if (component == null) {
|
|
2936
|
-
throw new Error(
|
|
2937
|
-
"@uniflowed/router: a loading module must export a component as `default` or `Loading`",
|
|
2938
|
-
);
|
|
2939
|
-
}
|
|
2940
|
-
return renderable(component);
|
|
2941
|
-
}
|
|
2942
|
-
|
|
2943
|
-
/**
|
|
2944
|
-
* `element`, wrapped in every template declared at `depth`.
|
|
2945
|
-
*
|
|
2946
|
-
* Outside the boundaries at that depth and inside the layout below it, and
|
|
2947
|
-
* backwards over a root-first list for the reason the fallbacks are: two
|
|
2948
|
-
* segments share a depth whenever the inner one declares no layout, and this
|
|
2949
|
-
* order is what keeps them nested the way the directories are.
|
|
2950
|
-
*
|
|
2951
|
-
* A function beside `RouteView` rather than a third loop inside it, and that
|
|
2952
|
-
* is not only for reading: a third nested loop assigning to `element` is what
|
|
2953
|
-
* the React Compiler's aliasing inference gave up on, and a component it
|
|
2954
|
-
* cannot compile is a component it does not memoise.
|
|
2955
|
-
*/
|
|
2956
|
-
function insideTemplates(
|
|
2957
|
-
element: React.Node,
|
|
2958
|
-
resolved: {
|
|
2959
|
-
readonly pathname: string,
|
|
2960
|
-
readonly params: RouteParams,
|
|
2961
|
-
readonly templates: $ReadOnlyArray<ResolvedTemplate>,
|
|
2962
|
-
...
|
|
2963
|
-
},
|
|
2964
|
-
depth: number,
|
|
2965
|
-
): React.Node {
|
|
2966
|
-
let out = element;
|
|
2967
|
-
for (let index = resolved.templates.length - 1; index >= 0; index -= 1) {
|
|
2968
|
-
const entry = resolved.templates[index];
|
|
2969
|
-
if (entry.above !== depth) {
|
|
2970
|
-
continue;
|
|
2971
|
-
}
|
|
2972
|
-
const Template = templateComponent(entry.module);
|
|
2973
|
-
// Keyed on the pathname, which is the whole difference between this file
|
|
2974
|
-
// and `$layout.js`: React throws the subtree away and builds it again
|
|
2975
|
-
// whenever the key changes, and a navigation that changes only the query
|
|
2976
|
-
// string leaves it alone.
|
|
2977
|
-
out = (
|
|
2978
|
-
<Template key={resolved.pathname} params={resolved.params}>
|
|
2979
|
-
{out}
|
|
2980
|
-
</Template>
|
|
2981
|
-
);
|
|
2982
|
-
}
|
|
2983
|
-
return out;
|
|
2984
|
-
}
|
|
2985
|
-
|
|
2986
|
-
/**
|
|
2987
|
-
* The slots declared at `depth`, as the props the layout there receives.
|
|
2988
|
-
*
|
|
2989
|
-
* One object per layout rather than one lookup per slot, so the common case —
|
|
2990
|
-
* a project with no slots at all — allocates nothing and spreads nothing.
|
|
2991
|
-
*
|
|
2992
|
-
* A slot the URL addressed and that has no `$default.js` is `null` rather
|
|
2993
|
-
* than absent: a layout that declares `team` receives `team` on every route,
|
|
2994
|
-
* so `{team ?? <Empty />}` is a thing a project can write and rely on.
|
|
2995
|
-
*/
|
|
2996
|
-
function slotsAt(
|
|
2997
|
-
slots: $ReadOnlyArray<ResolvedSlot>,
|
|
2998
|
-
depth: number,
|
|
2999
|
-
): { readonly [string]: React.Node } {
|
|
3000
|
-
if (slots.length === 0) {
|
|
3001
|
-
return EMPTY_SLOTS;
|
|
3002
|
-
}
|
|
3003
|
-
const props: { [string]: React.Node } = {};
|
|
3004
|
-
for (const slot of slots) {
|
|
3005
|
-
if (slot.above === depth) {
|
|
3006
|
-
// `null` rather than an element that renders nothing, and the difference
|
|
3007
|
-
// is the whole of what the prop is for: `{team ?? <Empty />}` has to be
|
|
3008
|
-
// able to tell "this slot has nothing in it" from "this slot rendered
|
|
3009
|
-
// something empty", and an element is never `null`.
|
|
3010
|
-
props[slot.name] = slot.page == null ? null : <SlotView slot={slot} />;
|
|
3011
|
-
}
|
|
3012
|
-
}
|
|
3013
|
-
return props;
|
|
3014
|
-
}
|
|
3015
|
-
|
|
3016
|
-
/** One object for every layout on a project that declares no slot. */
|
|
3017
|
-
const EMPTY_SLOTS: { readonly [string]: React.Node } = Object.freeze({});
|
|
3018
|
-
|
|
3019
|
-
/**
|
|
3020
|
-
* One slot's tree: its page, inside the layouts declared under the slot, with
|
|
3021
|
-
* the slots those layouts declare in turn.
|
|
3022
|
-
*
|
|
3023
|
-
* The same composition [`RouteView`] does and deliberately not the same
|
|
3024
|
-
* function. A route's tree carries the things a slot does not have — the error
|
|
3025
|
-
* boundary, the `<Suspense>` fallbacks, the templates, the head — and folding
|
|
3026
|
-
* a second, simpler case into that loop would be four `if`s asking which of the
|
|
3027
|
-
* two this is. What the two share is the *order*, page innermost and layouts
|
|
3028
|
-
* backwards over a root-first list, and that is short enough to be right twice.
|
|
3029
|
-
*
|
|
3030
|
-
* A slot with no page is never rendered through this component at all —
|
|
3031
|
-
* [`slotsAt`] hands the layout `null` instead, so the layout can tell an empty
|
|
3032
|
-
* slot from one that rendered something empty. The guard below is what makes
|
|
3033
|
-
* that a fact about one place rather than a convention two places share.
|
|
3034
|
-
*/
|
|
3035
|
-
component SlotView(slot: ResolvedSlot) {
|
|
3036
|
-
// Before the early return, because a hook after one is a hook that runs on
|
|
3037
|
-
// some renders and not others. The search string is the route's — a slot
|
|
3038
|
-
// matches the path and the query belongs to the URL, not to either match.
|
|
3039
|
-
const { resolved } = useRouterState();
|
|
3040
|
-
const page = slot.page;
|
|
3041
|
-
if (page == null) {
|
|
3042
|
-
return null;
|
|
3043
|
-
}
|
|
3044
|
-
// Except in an interception, whose URL is not the one the route on screen was
|
|
3045
|
-
// resolved for. The page it renders reads the intercepted URL's query, and
|
|
3046
|
-
// its templates and error boundary are keyed on the intercepted pathname — so
|
|
3047
|
-
// a second photo opened in the modal remounts what the first one mounted, the
|
|
3048
|
-
// way a navigation between two pages does.
|
|
3049
|
-
const pathname = slot.intercepted?.pathname ?? resolved.pathname;
|
|
3050
|
-
const searchParams = slot.intercepted?.searchParams ?? resolved.searchParams;
|
|
3051
|
-
const Page = pageComponent(page);
|
|
3052
|
-
let element: React.Node = (
|
|
3053
|
-
<Page params={slot.params} searchParams={searchParams} data={undefined} />
|
|
3054
|
-
);
|
|
3055
|
-
const templateContext = {
|
|
3056
|
-
pathname,
|
|
3057
|
-
params: slot.params,
|
|
3058
|
-
templates: slot.templates,
|
|
3059
|
-
};
|
|
3060
|
-
for (let depth = slot.layouts.length; depth >= 0; depth -= 1) {
|
|
3061
|
-
for (let index = slot.loading.length - 1; index >= 0; index -= 1) {
|
|
3062
|
-
const boundary = slot.loading[index];
|
|
3063
|
-
if (boundary.above !== depth) {
|
|
3064
|
-
continue;
|
|
3065
|
-
}
|
|
3066
|
-
const Fallback = loadingComponent(boundary.module);
|
|
3067
|
-
element = <Suspense fallback={<Fallback />}>{element}</Suspense>;
|
|
3068
|
-
}
|
|
3069
|
-
const errorBoundary = slot.errorBoundary;
|
|
3070
|
-
if (errorBoundary != null && errorBoundary.above === depth) {
|
|
3071
|
-
element = (
|
|
3072
|
-
<RouteErrorBoundary module={errorBoundary.module} resetKey={`${pathname}:${slot.name}`}>
|
|
3073
|
-
{element}
|
|
3074
|
-
</RouteErrorBoundary>
|
|
3075
|
-
);
|
|
3076
|
-
}
|
|
3077
|
-
element = insideTemplates(element, templateContext, depth);
|
|
3078
|
-
if (depth > 0) {
|
|
3079
|
-
const Layout = layoutComponent(slot.layouts[depth - 1]);
|
|
3080
|
-
element = (
|
|
3081
|
-
<Layout {...slotsAt(slot.slots, depth)} params={slot.params}>
|
|
3082
|
-
{element}
|
|
3083
|
-
</Layout>
|
|
3084
|
-
);
|
|
3085
|
-
}
|
|
3086
|
-
}
|
|
3087
|
-
return element;
|
|
3088
|
-
}
|
|
3089
|
-
|
|
3090
|
-
/**
|
|
3091
|
-
* The component a template module renders: `default`, or the named `Template`.
|
|
3092
|
-
*
|
|
3093
|
-
* The same props a layout receives, because it is a layout in every way but
|
|
3094
|
-
* one: it wraps `children`, it may read the route's parameters, and the only
|
|
3095
|
-
* difference is that `RouteView` gives the element a `key` so React builds it
|
|
3096
|
-
* again on every navigation.
|
|
3097
|
-
*/
|
|
3098
|
-
function templateComponent(module: TemplateModule): React.ComponentType<LayoutRenderProps> {
|
|
3099
|
-
const component = module.default ?? module.Template;
|
|
3100
|
-
if (component == null) {
|
|
3101
|
-
throw new Error(
|
|
3102
|
-
"@uniflowed/router: a template module must export a component as `default` or `Template`",
|
|
3103
|
-
);
|
|
3104
|
-
}
|
|
3105
|
-
return renderable(component);
|
|
3106
|
-
}
|
|
3107
|
-
|
|
3108
|
-
/** The component a layout module renders: `default`, or the named `Layout`. */
|
|
3109
|
-
function layoutComponent(module: LayoutModule): React.ComponentType<LayoutRenderProps> {
|
|
3110
|
-
const component = module.default ?? module.Layout;
|
|
3111
|
-
if (component == null) {
|
|
3112
|
-
throw new Error(
|
|
3113
|
-
"@uniflowed/router: a layout module must export a component as `default` or `Layout`",
|
|
3114
|
-
);
|
|
3115
|
-
}
|
|
3116
|
-
return renderable(component);
|
|
3117
|
-
}
|
|
3118
|
-
|
|
3119
|
-
/**
|
|
3120
|
-
* A route module's component, as the router is about to render it.
|
|
3121
|
-
*
|
|
3122
|
-
* # The one cast in this file, and why it is here rather than in six places
|
|
3123
|
-
*
|
|
3124
|
-
* A `RouteComponent` is a component about whose props nothing was claimed, and
|
|
3125
|
-
* `RouteView` is about to pass it three. React allows that — a component
|
|
3126
|
-
* receives the props its parent wrote and ignores the ones it did not declare
|
|
3127
|
-
* — but Flow cannot be told it: a page's props are exact, so no props type but
|
|
3128
|
-
* that page's own is assignable, and the router does not know which page it
|
|
3129
|
-
* has. `React.ComponentType<any>` on the module types was this same
|
|
3130
|
-
* unsoundness spread over six declarations, where it also stopped anyone from
|
|
3131
|
-
* checking that `RouteView` passes the props a page is documented to receive.
|
|
3132
|
-
* Here it is one line, and everything on either side of it is checked: what a
|
|
3133
|
-
* module may export, and what a page is handed. Suppressed by name so that
|
|
3134
|
-
* `check:lib` can gate CI without this file being the thing that stops it; the
|
|
3135
|
-
* directive names the rule, and this is the argument for escaping it.
|
|
3136
|
-
*/
|
|
3137
|
-
function renderable<TProps extends { ... }>(
|
|
3138
|
-
component: RouteComponent,
|
|
3139
|
-
): React.ComponentType<TProps> {
|
|
3140
|
-
// uf-lint-disable-next-line flow/unclear-type
|
|
3141
|
-
return component as any;
|
|
3142
|
-
}
|
|
3143
|
-
|
|
3144
|
-
/**
|
|
3145
|
-
* One URL from a route's metadata, made absolute if it can be.
|
|
3146
|
-
*
|
|
3147
|
-
* Open Graph, Twitter and `rel="canonical"` all want an absolute URL, and a
|
|
3148
|
-
* route module cannot know the host it is served from — so `metadataBase` is
|
|
3149
|
-
* how a site says it once, and this is where it is applied.
|
|
3150
|
-
*
|
|
3151
|
-
* Three things it deliberately does not do. It does not resolve against the
|
|
3152
|
-
* *page's* URL: `Head` renders inside the route and does not know it, and a
|
|
3153
|
-
* `metadataBase` is a site-wide fact rather than a per-page one. It does not
|
|
3154
|
-
* invent a base: with none declared the value is emitted exactly as written,
|
|
3155
|
-
* which is what every page that predates this field already gets. And it does
|
|
3156
|
-
* not throw — a `metadataBase` that is not a URL is a mistake in one field,
|
|
3157
|
-
* and turning it into a blank page would be a worse answer than an unresolved
|
|
3158
|
-
* `og:image`.
|
|
3159
|
-
*/
|
|
3160
|
-
function absoluteUrl(value: string, base: void | string): string {
|
|
3161
|
-
if (base == null) return value;
|
|
3162
|
-
try {
|
|
3163
|
-
return new URL(value, base).href;
|
|
3164
|
-
} catch {
|
|
3165
|
-
return value;
|
|
3166
|
-
}
|
|
3167
|
-
}
|
|
3168
|
-
|
|
3169
|
-
/**
|
|
3170
|
-
* The `robots` directives, as one `content` string, or `null` for none.
|
|
3171
|
-
*
|
|
3172
|
-
* `null` rather than an empty string, so a page that declared nothing gets no
|
|
3173
|
-
* tag at all: "index, follow" is what a document with no `robots` meta already
|
|
3174
|
-
* means, and writing it out tells a crawler what it had already assumed.
|
|
3175
|
-
*
|
|
3176
|
-
* Each declared field contributes its directive and no field implies another.
|
|
3177
|
-
* `index: true` therefore emits `index` rather than nothing — the value is
|
|
3178
|
-
* there to overrule a section that said otherwise, and a directive that
|
|
3179
|
-
* disappeared because it agreed with the default would be a page saying
|
|
3180
|
-
* something and no evidence of it in the markup.
|
|
3181
|
-
*/
|
|
3182
|
-
function robotsContent(robots: void | Robots): ?string {
|
|
3183
|
-
if (robots == null) {
|
|
3184
|
-
return null;
|
|
3185
|
-
}
|
|
3186
|
-
const directives: Array<string> = [];
|
|
3187
|
-
if (robots.index != null) {
|
|
3188
|
-
directives.push(robots.index ? "index" : "noindex");
|
|
3189
|
-
}
|
|
3190
|
-
if (robots.follow != null) {
|
|
3191
|
-
directives.push(robots.follow ? "follow" : "nofollow");
|
|
3192
|
-
}
|
|
3193
|
-
if (robots.maxSnippet != null) {
|
|
3194
|
-
directives.push(`max-snippet:${robots.maxSnippet}`);
|
|
3195
|
-
}
|
|
3196
|
-
if (robots.maxImagePreview != null) {
|
|
3197
|
-
directives.push(`max-image-preview:${robots.maxImagePreview}`);
|
|
3198
|
-
}
|
|
3199
|
-
return directives.length === 0 ? null : directives.join(", ");
|
|
3200
|
-
}
|
|
3201
|
-
|
|
3202
|
-
/**
|
|
3203
|
-
* One JSON-LD object as the text of a `<script>`.
|
|
3204
|
-
*
|
|
3205
|
-
* `<` is escaped so a string inside the data holding `</script>` cannot end
|
|
3206
|
-
* the element early — the same escape `server.js` applies to the embedded
|
|
3207
|
-
* loader data, and for the same reason: the text is the application's and the
|
|
3208
|
-
* element it lands in is terminated by a character sequence rather than by a
|
|
3209
|
-
* length. `dataScript` also escapes U+2028 and U+2029; those are about a
|
|
3210
|
-
* string being parsed as JavaScript source, and this one never is.
|
|
3211
|
-
*/
|
|
3212
|
-
function jsonLdText(entry: JsonLd): string {
|
|
3213
|
-
return JSON.stringify(entry).replace(/</g, "\\u003c");
|
|
3214
|
-
}
|
|
3215
|
-
|
|
3216
|
-
/**
|
|
3217
|
-
* One JSON-LD object, as the element that carries it.
|
|
3218
|
-
*
|
|
3219
|
-
* A function rather than an element written inline, because the suppression
|
|
3220
|
-
* needs a line of its own; `docs/app/$layout.js` has the same shape for the
|
|
3221
|
-
* same reason. `security/no-dangerously-set-inner-html` is about markup that
|
|
3222
|
-
* came from somewhere and has to be sanitized before a browser parses it as
|
|
3223
|
-
* HTML, and its escape hatch is a `@uniflowed/markdown` sanitizer — the right
|
|
3224
|
-
* answer for markup and no answer at all for JSON. This string is
|
|
3225
|
-
* `JSON.stringify`'s output with `<` escaped, so nothing in it can close the
|
|
3226
|
-
* element, and it is never parsed as HTML. There is also no other spelling:
|
|
3227
|
-
* React escapes a text child, so `{"@type":"Article"}` would reach the page as
|
|
3228
|
-
* `"@type"`, which is not JSON-LD any more.
|
|
3229
|
-
*/
|
|
3230
|
-
function jsonLdScript(entry: JsonLd): React.Node {
|
|
3231
|
-
const text = jsonLdText(entry);
|
|
3232
|
-
const html = { __html: text };
|
|
3233
|
-
// uf-lint-disable-next-line security/no-dangerously-set-inner-html
|
|
3234
|
-
return <script key={text} type="application/ld+json" dangerouslySetInnerHTML={html} />;
|
|
3235
|
-
}
|
|
3236
|
-
|
|
3237
|
-
component Head(metadata: Metadata) {
|
|
3238
|
-
const { title, description, metadataBase, canonical, robots } = metadata;
|
|
3239
|
-
const { alternates, pagination, jsonLd, openGraph, twitter } = metadata;
|
|
3240
|
-
const href = canonical != null ? absoluteUrl(canonical, metadataBase) : null;
|
|
3241
|
-
const crawler = robotsContent(robots);
|
|
3242
|
-
// Read out of `alternates` once rather than through it at every use: the map
|
|
3243
|
-
// is read inside a callback, and a refinement of `alternates.languages` does
|
|
3244
|
-
// not survive being carried into one.
|
|
3245
|
-
const languages = alternates?.languages;
|
|
3246
|
-
// A page that said what it is called has said what its card is called. Every
|
|
3247
|
-
// site that had to write both wrote the same string twice, and the second
|
|
3248
|
-
// one is the one that goes stale — the docs site shipped thirty pages whose
|
|
3249
|
-
// share cards carried an image and no title at all.
|
|
3250
|
-
//
|
|
3251
|
-
// `??`, not `||`: an empty string is a decision, and a page that deliberately
|
|
3252
|
-
// has no card title should get none rather than the document's.
|
|
3253
|
-
const cardTitle = openGraph?.title ?? title;
|
|
3254
|
-
const cardDescription = openGraph?.description ?? description;
|
|
3255
|
-
// `og:type` is one of the four properties Open Graph requires. A default is
|
|
3256
|
-
// the difference between a document with a card and a document without one,
|
|
3257
|
-
// and `website` is right for everything that is not an article or a video.
|
|
3258
|
-
const cardType = openGraph?.type ?? "website";
|
|
3259
|
-
// Only when the card was asked for. A page with no `twitter.card` gets no
|
|
3260
|
-
// Twitter tags at all, which is what a site that never wanted one meant.
|
|
3261
|
-
const twitterTitle = twitter != null ? (twitter.title ?? cardTitle) : null;
|
|
3262
|
-
const twitterDescription = twitter != null ? (twitter.description ?? cardDescription) : null;
|
|
3263
|
-
const twitterImageAlt = twitter != null ? (twitter.imageAlt ?? openGraph?.imageAlt) : null;
|
|
3264
|
-
return (
|
|
3265
|
-
<>
|
|
3266
|
-
{title != null ? <title>{title}</title> : null}
|
|
3267
|
-
{description != null ? <meta name="description" content={description} /> : null}
|
|
3268
|
-
{crawler != null ? <meta name="robots" content={crawler} /> : null}
|
|
3269
|
-
{href != null ? <link rel="canonical" href={href} /> : null}
|
|
3270
|
-
{/* The set is reciprocal and includes this page, so a `hreflang` list is
|
|
3271
|
-
usually the same list on every page of it — which is why it belongs
|
|
3272
|
-
on the layout they share rather than on each of them.
|
|
3273
|
-
|
|
3274
|
-
`hrefLang` is React's spelling and it reaches the markup unchanged,
|
|
3275
|
-
which is worth knowing before grepping a document for `hreflang` and
|
|
3276
|
-
concluding it is missing. HTML attribute names are case-insensitive,
|
|
3277
|
-
so the parser every crawler runs reads it as the same attribute; the
|
|
3278
|
-
lowercase spelling is the one React warns about. */}
|
|
3279
|
-
{languages != null
|
|
3280
|
-
? Object.keys(languages).map((language) => (
|
|
3281
|
-
<link
|
|
3282
|
-
key={language}
|
|
3283
|
-
rel="alternate"
|
|
3284
|
-
hrefLang={language}
|
|
3285
|
-
href={absoluteUrl(languages[language], metadataBase)}
|
|
3286
|
-
/>
|
|
3287
|
-
))
|
|
3288
|
-
: null}
|
|
3289
|
-
{pagination?.prev != null ? (
|
|
3290
|
-
<link rel="prev" href={absoluteUrl(pagination.prev, metadataBase)} />
|
|
3291
|
-
) : null}
|
|
3292
|
-
{pagination?.next != null ? (
|
|
3293
|
-
<link rel="next" href={absoluteUrl(pagination.next, metadataBase)} />
|
|
3294
|
-
) : null}
|
|
3295
|
-
{/* `og:url` *is* the canonical URL of the page, in Open Graph's own
|
|
3296
|
-
words, so one declaration answers both rather than asking a project
|
|
3297
|
-
to write the same URL twice and keep them in step. */}
|
|
3298
|
-
{href != null ? <meta property="og:url" content={href} /> : null}
|
|
3299
|
-
{cardTitle != null ? <meta property="og:title" content={cardTitle} /> : null}
|
|
3300
|
-
{cardDescription != null ? (
|
|
3301
|
-
<meta property="og:description" content={cardDescription} />
|
|
3302
|
-
) : null}
|
|
3303
|
-
{/* Only alongside something else. A document with `og:type` and nothing
|
|
3304
|
-
more is not a card; it is one meta tag saying the page is a page. */}
|
|
3305
|
-
{cardTitle != null || cardDescription != null || openGraph?.images != null ? (
|
|
3306
|
-
<meta property="og:type" content={cardType} />
|
|
3307
|
-
) : null}
|
|
3308
|
-
{openGraph?.siteName != null ? (
|
|
3309
|
-
<meta property="og:site_name" content={openGraph.siteName} />
|
|
3310
|
-
) : null}
|
|
3311
|
-
{openGraph?.images != null
|
|
3312
|
-
? openGraph.images.map((image) => (
|
|
3313
|
-
<meta key={image} property="og:image" content={absoluteUrl(image, metadataBase)} />
|
|
3314
|
-
))
|
|
3315
|
-
: null}
|
|
3316
|
-
{openGraph?.imageAlt != null && openGraph?.images != null ? (
|
|
3317
|
-
<meta property="og:image:alt" content={openGraph.imageAlt} />
|
|
3318
|
-
) : null}
|
|
3319
|
-
{/* `name`, not `property`: Open Graph is RDFa and Twitter's cards are
|
|
3320
|
-
not, and a `property="twitter:card"` is ignored by the crawler that
|
|
3321
|
-
reads it. */}
|
|
3322
|
-
{twitter?.card != null ? <meta name="twitter:card" content={twitter.card} /> : null}
|
|
3323
|
-
{twitter?.site != null ? <meta name="twitter:site" content={twitter.site} /> : null}
|
|
3324
|
-
{twitter?.creator != null ? <meta name="twitter:creator" content={twitter.creator} /> : null}
|
|
3325
|
-
{/* X reads the `og:` tags when these are absent, so these are not
|
|
3326
|
-
required — and every validator asks for them anyway, which is a good
|
|
3327
|
-
enough reason when the value is one the page has already given. They
|
|
3328
|
-
fall back through the card's title to the document's. */}
|
|
3329
|
-
{twitterTitle != null ? <meta name="twitter:title" content={twitterTitle} /> : null}
|
|
3330
|
-
{twitterDescription != null ? (
|
|
3331
|
-
<meta name="twitter:description" content={twitterDescription} />
|
|
3332
|
-
) : null}
|
|
3333
|
-
{twitter?.images != null
|
|
3334
|
-
? twitter.images.map((image) => (
|
|
3335
|
-
<meta key={image} name="twitter:image" content={absoluteUrl(image, metadataBase)} />
|
|
3336
|
-
))
|
|
3337
|
-
: null}
|
|
3338
|
-
{twitterImageAlt != null && twitter?.images != null ? (
|
|
3339
|
-
<meta name="twitter:image:alt" content={twitterImageAlt} />
|
|
3340
|
-
) : null}
|
|
3341
|
-
{/* Last, and not hoisted into `<head>` with the rest: React hoists a
|
|
3342
|
-
`<title>`, a `<meta>` and a `<link>`, and not a script whose body it
|
|
3343
|
-
would have to carry. JSON-LD is read from anywhere in the document,
|
|
3344
|
-
so these render where the route does. */}
|
|
3345
|
-
{jsonLd != null ? jsonLd.map(jsonLdScript) : null}
|
|
3346
|
-
</>
|
|
3347
|
-
);
|
|
3348
|
-
}
|
|
3349
|
-
|
|
3350
1420
|
/**
|
|
3351
1421
|
* Head elements a component contributes while it is rendering.
|
|
3352
1422
|
*
|
|
@@ -3382,8 +1452,8 @@ component Head(metadata: Metadata) {
|
|
|
3382
1452
|
* relative URLs written here are resolved.
|
|
3383
1453
|
*/
|
|
3384
1454
|
export hook useSeo(seo: Metadata): React.Node {
|
|
3385
|
-
const {
|
|
3386
|
-
const base = seo.metadataBase ??
|
|
1455
|
+
const { route } = useRouterState();
|
|
1456
|
+
const base = seo.metadataBase ?? route.metadata.metadataBase;
|
|
3387
1457
|
return <Head metadata={base == null ? seo : { ...seo, metadataBase: base }} />;
|
|
3388
1458
|
}
|
|
3389
1459
|
|
|
@@ -3518,10 +1588,10 @@ function isExternal(to: string): boolean {
|
|
|
3518
1588
|
*/
|
|
3519
1589
|
export function routerView(root: string): React.ComponentType<AppProps> {
|
|
3520
1590
|
void root;
|
|
3521
|
-
component App(url: string, initial
|
|
1591
|
+
component App(url: string, initial?: ResolvedRoute, flight?: Promise<FlightRoot>) {
|
|
3522
1592
|
return (
|
|
3523
1593
|
<RenderProvider>
|
|
3524
|
-
<RouterProvider url={url} initial={initial}>
|
|
1594
|
+
<RouterProvider url={url} initial={initial} flight={flight}>
|
|
3525
1595
|
<RouteView />
|
|
3526
1596
|
</RouterProvider>
|
|
3527
1597
|
</RenderProvider>
|