@uniflowed/router 0.0.0-alpha.1 → 0.0.0-alpha.11
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 +39 -6
- package/handler.js +217 -0
- package/index.js +35 -7
- package/internal/document.js +24 -0
- package/internal/request.js +43 -0
- package/internal/runtime.js +1121 -130
- package/internal/stream.js +627 -0
- package/middleware.js +211 -0
- package/package.json +12 -5
- package/server.js +324 -40
package/internal/runtime.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import * as React from "react";
|
|
13
13
|
import {
|
|
14
|
+
Suspense,
|
|
14
15
|
createContext,
|
|
15
16
|
startTransition,
|
|
16
17
|
useCallback,
|
|
@@ -22,101 +23,350 @@ import {
|
|
|
22
23
|
} from "react";
|
|
23
24
|
|
|
24
25
|
/** One parameter a route path captures. */
|
|
25
|
-
export type RouteParamSpec = {|
|
|
26
|
+
export type RouteParamSpec = {| readonly name: string, readonly catchAll: boolean |};
|
|
26
27
|
|
|
27
28
|
/** The parameters captured from a URL. A catch-all captures the rest as a list. */
|
|
28
|
-
export type RouteParams = {
|
|
29
|
+
export type RouteParams = { readonly [string]: string | $ReadOnlyArray<string> };
|
|
29
30
|
|
|
30
31
|
/** The query string, as a read-only map. */
|
|
31
|
-
export type SearchParams = {
|
|
32
|
+
export type SearchParams = { readonly [string]: string };
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A component found in a route module.
|
|
36
|
+
*
|
|
37
|
+
* `React.ComponentType<empty>` is "some React component", and it is a claim
|
|
38
|
+
* rather than a shrug. `ComponentType` is contravariant in its props — Flow's
|
|
39
|
+
* library definition writes it `component(...P)` with `in P` — so `empty` is
|
|
40
|
+
* the *top* of the component types: every component is one, and nothing may be
|
|
41
|
+
* passed to one until a caller has said which props it is passing. That is
|
|
42
|
+
* exactly what is known here. The router finds these by dynamic import, and
|
|
43
|
+
* nobody has told it what a page's props are.
|
|
44
|
+
*
|
|
45
|
+
* It cannot be `React.ComponentType<PageRenderProps>`, the props the router
|
|
46
|
+
* actually passes, because Flow's `component` syntax gives a component *exact*
|
|
47
|
+
* props and a page is free to want none of them. This repository's own pages
|
|
48
|
+
* and layouts are `component NotFound()` and
|
|
49
|
+
* `component Layout(children: React.Node)`, and against the props the router
|
|
50
|
+
* hands them that reads:
|
|
51
|
+
*
|
|
52
|
+
* error[incompatible-type]: property `data`, property `params`, and
|
|
53
|
+
* property `searchParams` are extra in `PageRenderProps` but missing in
|
|
54
|
+
* `props of component NotFound`. Exact objects do not accept extra props.
|
|
55
|
+
*
|
|
56
|
+
* React passing a component a prop it did not declare is allowed and always
|
|
57
|
+
* has been. `renderable` is the one line that says so.
|
|
58
|
+
*/
|
|
59
|
+
type RouteComponent = React.ComponentType<empty>;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The props `RouteView` gives the page it renders.
|
|
63
|
+
*
|
|
64
|
+
* The same three as the public `PageProps` in `../index.js`, at the arguments
|
|
65
|
+
* the runtime instantiates it with: the runtime knows the parameters as
|
|
66
|
+
* strings and the loader's data as `mixed`, and a page narrows both by
|
|
67
|
+
* annotating its own props.
|
|
68
|
+
*/
|
|
69
|
+
type PageRenderProps = {|
|
|
70
|
+
readonly params: RouteParams,
|
|
71
|
+
readonly searchParams: SearchParams,
|
|
72
|
+
readonly data: mixed,
|
|
73
|
+
|};
|
|
74
|
+
|
|
75
|
+
/** The props `RouteView` gives each layout, outermost first. */
|
|
76
|
+
type LayoutRenderProps = {|
|
|
77
|
+
readonly params: RouteParams,
|
|
78
|
+
readonly children: React.Node,
|
|
79
|
+
|};
|
|
32
80
|
|
|
33
81
|
/** What a page module may export. The component is `default` or `Page`. */
|
|
34
82
|
export type PageModule = {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
83
|
+
readonly default?: RouteComponent,
|
|
84
|
+
readonly Page?: RouteComponent,
|
|
85
|
+
readonly loader?: (args: LoaderArgs) => mixed | Promise<mixed>,
|
|
86
|
+
readonly metadata?: Metadata,
|
|
87
|
+
readonly generateMetadata?: (args: MetadataArgs) => Metadata | Promise<Metadata>,
|
|
88
|
+
readonly generateStaticParams?: () =>
|
|
89
|
+
| $ReadOnlyArray<RouteParams>
|
|
90
|
+
| Promise<$ReadOnlyArray<RouteParams>>,
|
|
91
|
+
readonly frontmatter?: { readonly title?: string, readonly description?: string, ... },
|
|
42
92
|
...
|
|
43
93
|
};
|
|
44
94
|
|
|
45
95
|
/** What a layout module may export. The component is `default` or `Layout`. */
|
|
46
96
|
export type LayoutModule = {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
97
|
+
readonly default?: RouteComponent,
|
|
98
|
+
readonly Layout?: RouteComponent,
|
|
99
|
+
readonly metadata?: Metadata,
|
|
100
|
+
...
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* What an error module may export. The component is `default` or `Error`.
|
|
105
|
+
*
|
|
106
|
+
* `Error` shadows the global inside the file that writes it, which is the
|
|
107
|
+
* cost of naming the export after what it is; a file that needs the
|
|
108
|
+
* constructor still has `globalThis.Error`. The alternative was a name the
|
|
109
|
+
* convention would have to explain — `ErrorPage`, `Boundary` — for a file
|
|
110
|
+
* whose whole job is already in its name.
|
|
111
|
+
*/
|
|
112
|
+
export type ErrorModule = {
|
|
113
|
+
readonly default?: RouteComponent,
|
|
114
|
+
readonly Error?: RouteComponent,
|
|
115
|
+
readonly metadata?: Metadata,
|
|
116
|
+
...
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* What a loading module may export. The component is `default` or `Loading`.
|
|
121
|
+
*
|
|
122
|
+
* No `metadata`, and that is the type saying something true rather than an
|
|
123
|
+
* omission. A fallback renders while the route is still resolving, and the
|
|
124
|
+
* route's metadata was decided before the first byte — a title on a file that
|
|
125
|
+
* renders after the head has gone could never be used. `packages/web/head.js`
|
|
126
|
+
* documents the same constraint from the other side.
|
|
127
|
+
*/
|
|
128
|
+
export type LoadingModule = {
|
|
129
|
+
readonly default?: RouteComponent,
|
|
130
|
+
readonly Loading?: RouteComponent,
|
|
50
131
|
...
|
|
51
132
|
};
|
|
52
133
|
|
|
134
|
+
/**
|
|
135
|
+
* How a Twitter card is laid out, which is the whole of what `card` may be.
|
|
136
|
+
*
|
|
137
|
+
* A union rather than a string: every one of the four is spelled exactly this
|
|
138
|
+
* way and a fifth value is silently ignored by the crawler, so a typo in it
|
|
139
|
+
* costs a card and produces no error anywhere.
|
|
140
|
+
*/
|
|
141
|
+
export type TwitterCard = "summary" | "summary_large_image" | "app" | "player";
|
|
142
|
+
|
|
53
143
|
/** Document metadata a page or layout declares. */
|
|
54
144
|
export type Metadata = {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
145
|
+
readonly title?: string,
|
|
146
|
+
readonly description?: string,
|
|
147
|
+
/**
|
|
148
|
+
* The absolute URL every other URL here is resolved against.
|
|
149
|
+
*
|
|
150
|
+
* Open Graph and Twitter both require absolute image URLs, and a route
|
|
151
|
+
* module has no way to know the host it will be served from — so without
|
|
152
|
+
* this, `openGraph.images: ["/og.png"]` ships exactly as written and is not
|
|
153
|
+
* a valid `og:image`. Declare it once on the root layout and every
|
|
154
|
+
* descendant inherits it through the same merge as everything else.
|
|
155
|
+
*
|
|
156
|
+
* Resolution is the URL standard's, so `"/og.png"` is resolved against the
|
|
157
|
+
* *origin* and `"og.png"` against the base's own path — not against the
|
|
158
|
+
* page's URL, which `Head` does not know.
|
|
159
|
+
*/
|
|
160
|
+
readonly metadataBase?: string,
|
|
161
|
+
/**
|
|
162
|
+
* This page's canonical URL, for `<link rel="canonical">` and `og:url`.
|
|
163
|
+
*
|
|
164
|
+
* Relative to `metadataBase` when it is not absolute. A page
|
|
165
|
+
* reachable at more than one path — a query a filter added, a duplicate
|
|
166
|
+
* under a second section — is one page, and this is how it says so.
|
|
167
|
+
*/
|
|
168
|
+
readonly canonical?: string,
|
|
169
|
+
readonly openGraph?: {
|
|
170
|
+
readonly title?: string,
|
|
171
|
+
readonly description?: string,
|
|
172
|
+
readonly images?: $ReadOnlyArray<string>,
|
|
173
|
+
},
|
|
174
|
+
readonly twitter?: {
|
|
175
|
+
readonly card?: TwitterCard,
|
|
176
|
+
readonly site?: string,
|
|
177
|
+
readonly creator?: string,
|
|
178
|
+
readonly title?: string,
|
|
179
|
+
readonly description?: string,
|
|
180
|
+
readonly images?: $ReadOnlyArray<string>,
|
|
61
181
|
},
|
|
62
182
|
};
|
|
63
183
|
|
|
64
184
|
/** Arguments a loader receives. */
|
|
65
185
|
export type LoaderArgs = {|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
186
|
+
readonly params: RouteParams,
|
|
187
|
+
readonly searchParams: SearchParams,
|
|
188
|
+
readonly pathname: string,
|
|
69
189
|
|};
|
|
70
190
|
|
|
71
191
|
/** Arguments `generateMetadata` receives. */
|
|
72
192
|
export type MetadataArgs = {|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
193
|
+
readonly params: RouteParams,
|
|
194
|
+
readonly searchParams: SearchParams,
|
|
195
|
+
readonly data: mixed,
|
|
76
196
|
|};
|
|
77
197
|
|
|
78
198
|
/** One entry of the generated route table. */
|
|
79
199
|
export type RouteRecord = {|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
200
|
+
readonly path: string,
|
|
201
|
+
readonly params: $ReadOnlyArray<RouteParamSpec>,
|
|
202
|
+
readonly mdx: boolean,
|
|
203
|
+
readonly file: string,
|
|
204
|
+
/**
|
|
205
|
+
* The page module — absent when this table cannot render the route.
|
|
206
|
+
*
|
|
207
|
+
* The server's table always has one: the server renders every route. The
|
|
208
|
+
* browser's may not. `@uniflowed/vite` leaves the page out of the client
|
|
209
|
+
* route table when uf's server-component analysis finds no `"use client"`
|
|
210
|
+
* boundary reachable from the page, its layouts or its fallbacks, and with
|
|
211
|
+
* the `import()` gone so is the whole subtree it reached — which is the
|
|
212
|
+
* point of leaving it out.
|
|
213
|
+
*
|
|
214
|
+
* The route stays in the table because the router still has to *match* the
|
|
215
|
+
* URL. Matching is what tells a `Link` that the destination is a document
|
|
216
|
+
* the browser must fetch rather than a page this bundle can render; a route
|
|
217
|
+
* missing from the table entirely would be a 404 instead. See
|
|
218
|
+
* [`hasClientPage`], which is the question every caller asks.
|
|
219
|
+
*/
|
|
220
|
+
readonly page?: () => Promise<PageModule>,
|
|
221
|
+
readonly layouts: $ReadOnlyArray<() => Promise<LayoutModule>>,
|
|
222
|
+
/**
|
|
223
|
+
* The `<Suspense>` boundaries this route renders inside, root first.
|
|
224
|
+
*
|
|
225
|
+
* Optional because a table written before `_uf.loading.js` existed — a
|
|
226
|
+
* hand-written one in a test, a server bundle built by an older `uf` —
|
|
227
|
+
* is still a table this router can render, and a route with no boundary is
|
|
228
|
+
* exactly what it had before.
|
|
229
|
+
*/
|
|
230
|
+
readonly loading?: $ReadOnlyArray<LoadingRecord>,
|
|
86
231
|
|};
|
|
87
232
|
|
|
88
|
-
/**
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
233
|
+
/**
|
|
234
|
+
* One `_uf.loading.js`, as the route table carries it.
|
|
235
|
+
*
|
|
236
|
+
* `above` is how many of the route's `layouts` are outside the boundary, which
|
|
237
|
+
* is the same number `ResolvedRoute["errorBoundary"].above` means and is
|
|
238
|
+
* spelled the same way on purpose: both answer "where in the stack of layouts
|
|
239
|
+
* does this thing sit", and there is no second vocabulary for it.
|
|
240
|
+
*/
|
|
241
|
+
export type LoadingRecord = {|
|
|
242
|
+
readonly above: number,
|
|
243
|
+
readonly module: () => Promise<LoadingModule>,
|
|
94
244
|
|};
|
|
95
245
|
|
|
96
|
-
/**
|
|
246
|
+
/**
|
|
247
|
+
* One not-found boundary: the page for a path under `path` that matched
|
|
248
|
+
* nothing.
|
|
249
|
+
*
|
|
250
|
+
* `_uf.not-found.js` is a segment file, so `path` is the route path of the
|
|
251
|
+
* directory that declares it and `layouts` are the layouts in scope *there* —
|
|
252
|
+
* which is what the boundary renders inside. A project with one at the router
|
|
253
|
+
* root has one of these; a project whose manual answers its own 404 has two.
|
|
254
|
+
*/
|
|
255
|
+
export type NotFoundBoundary = {|
|
|
256
|
+
readonly path: string,
|
|
257
|
+
readonly mdx: boolean,
|
|
258
|
+
readonly file: string,
|
|
259
|
+
readonly page: () => Promise<PageModule>,
|
|
260
|
+
readonly layouts: $ReadOnlyArray<() => Promise<LayoutModule>>,
|
|
261
|
+
|};
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* One error boundary: what renders in place of the subtree under `path` when
|
|
265
|
+
* something in it throws.
|
|
266
|
+
*
|
|
267
|
+
* The same nearest-ancestor shape as [`NotFoundBoundary`], and `layouts` means
|
|
268
|
+
* the same thing — the layouts in scope where the file is, which stay mounted
|
|
269
|
+
* around the error and are why the rest of the document is still there.
|
|
270
|
+
*/
|
|
271
|
+
export type ErrorBoundary = {|
|
|
272
|
+
readonly path: string,
|
|
273
|
+
readonly file: string,
|
|
274
|
+
readonly module: () => Promise<ErrorModule>,
|
|
275
|
+
readonly layouts: $ReadOnlyArray<() => Promise<LayoutModule>>,
|
|
276
|
+
|};
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* A route table plus the boundaries declared under it.
|
|
280
|
+
*
|
|
281
|
+
* `errors` is the error boundaries a project declared, not failures that
|
|
282
|
+
* happened.
|
|
283
|
+
*/
|
|
97
284
|
export type RouteTable = {|
|
|
98
|
-
|
|
99
|
-
|
|
285
|
+
readonly routes: $ReadOnlyArray<RouteRecord>,
|
|
286
|
+
readonly notFound: $ReadOnlyArray<NotFoundBoundary>,
|
|
287
|
+
readonly errors: $ReadOnlyArray<ErrorBoundary>,
|
|
100
288
|
|};
|
|
101
289
|
|
|
102
290
|
/** A URL matched against the table. */
|
|
103
291
|
export type RouteMatch = {|
|
|
104
|
-
|
|
105
|
-
|
|
292
|
+
readonly route: RouteRecord,
|
|
293
|
+
readonly params: RouteParams,
|
|
106
294
|
|};
|
|
107
295
|
|
|
108
|
-
/**
|
|
296
|
+
/**
|
|
297
|
+
* Why the router is rendering an error boundary instead of a page.
|
|
298
|
+
*
|
|
299
|
+
* One union rather than one file convention per status. `forbidden()` and
|
|
300
|
+
* `unauthorized()` are not different *kinds* of file to write; they are
|
|
301
|
+
* different sentences an error page says, and `match` over this is where a
|
|
302
|
+
* page says all three and the checker confirms it covered them. Deciding it
|
|
303
|
+
* the other way — `_uf.forbidden.js` and `_uf.unauthorized.js` beside
|
|
304
|
+
* `_uf.error.js`, which is what Next.js does — is three files per segment to
|
|
305
|
+
* express one thing, and nothing would check that any of them handled the
|
|
306
|
+
* case it was named for.
|
|
307
|
+
*
|
|
308
|
+
* The thrown value is carried but deliberately not rendered by the default
|
|
309
|
+
* boundary: a server exception's message is written for the person who
|
|
310
|
+
* deployed the application, not for whoever asks for the page.
|
|
311
|
+
*/
|
|
312
|
+
export type RouteError =
|
|
313
|
+
| {| readonly kind: "thrown", readonly error: mixed |}
|
|
314
|
+
| {| readonly kind: "unauthorized" |}
|
|
315
|
+
| {| readonly kind: "forbidden" |};
|
|
316
|
+
|
|
317
|
+
/** The status a `RouteError` answers with. */
|
|
318
|
+
export function routeErrorStatus(error: RouteError): 401 | 403 | 500 {
|
|
319
|
+
return match (error) {
|
|
320
|
+
{kind: "unauthorized"} => 401,
|
|
321
|
+
{kind: "forbidden"} => 403,
|
|
322
|
+
{kind: "thrown"} => 500,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* A match whose modules are loaded and whose loader has run — or, when `error`
|
|
328
|
+
* is set, the error page that stands in for it.
|
|
329
|
+
*/
|
|
109
330
|
export type ResolvedRoute = {|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
331
|
+
readonly pathname: string,
|
|
332
|
+
readonly search: string,
|
|
333
|
+
readonly path: string,
|
|
334
|
+
readonly params: RouteParams,
|
|
335
|
+
readonly searchParams: SearchParams,
|
|
336
|
+
readonly page: PageModule,
|
|
337
|
+
readonly layouts: $ReadOnlyArray<LayoutModule>,
|
|
338
|
+
readonly data: mixed,
|
|
339
|
+
readonly metadata: Metadata,
|
|
340
|
+
readonly status: 200 | 401 | 403 | 404 | 500,
|
|
341
|
+
/**
|
|
342
|
+
* Set when this resolution *is* the error page: the loader threw, or the
|
|
343
|
+
* server render did and the renderer resolved again. `null` on the ordinary
|
|
344
|
+
* path.
|
|
345
|
+
*/
|
|
346
|
+
readonly error: ?RouteError,
|
|
347
|
+
/**
|
|
348
|
+
* The boundary that would catch a throw while rendering this route.
|
|
349
|
+
*
|
|
350
|
+
* Always present, because every route has an answer for a throw: `module`
|
|
351
|
+
* is `null` when the project declares no `_uf.error.js` above the path, and
|
|
352
|
+
* the framework's own error page renders instead. `above` is how many of
|
|
353
|
+
* `layouts` are outside the boundary — the ones that stay mounted, which is
|
|
354
|
+
* what "the rest of the document is still interactive" means.
|
|
355
|
+
*/
|
|
356
|
+
readonly errorBoundary: {|
|
|
357
|
+
readonly module: ?ErrorModule,
|
|
358
|
+
readonly above: number,
|
|
359
|
+
|},
|
|
360
|
+
/**
|
|
361
|
+
* The loading boundaries around this route, root first, already imported.
|
|
362
|
+
*
|
|
363
|
+
* Imported rather than lazy: React decides to render a fallback
|
|
364
|
+
* synchronously, during the render that suspended, so a module that is still
|
|
365
|
+
* being fetched is a module that is not there at the only moment it is
|
|
366
|
+
* wanted. Empty for a route with no `_uf.loading.js` above it, which is the
|
|
367
|
+
* ordinary case and renders exactly the tree it did before.
|
|
368
|
+
*/
|
|
369
|
+
readonly loading: $ReadOnlyArray<{| readonly above: number, readonly module: LoadingModule |}>,
|
|
120
370
|
|};
|
|
121
371
|
|
|
122
372
|
/** Thrown by `notFound()`; the renderer answers with the not-found page. */
|
|
@@ -127,6 +377,22 @@ export class NotFoundError extends Error {
|
|
|
127
377
|
}
|
|
128
378
|
}
|
|
129
379
|
|
|
380
|
+
/** Thrown by `unauthorized()`; the renderer answers with the error boundary. */
|
|
381
|
+
export class UnauthorizedError extends Error {
|
|
382
|
+
constructor() {
|
|
383
|
+
super("unauthorized");
|
|
384
|
+
this.name = "UnauthorizedError";
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Thrown by `forbidden()`; the renderer answers with the error boundary. */
|
|
389
|
+
export class ForbiddenError extends Error {
|
|
390
|
+
constructor() {
|
|
391
|
+
super("forbidden");
|
|
392
|
+
this.name = "ForbiddenError";
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
130
396
|
/** Thrown by `redirect()`; the renderer answers with a redirect. */
|
|
131
397
|
export class RedirectError extends Error {
|
|
132
398
|
to: string;
|
|
@@ -145,9 +411,9 @@ export class RedirectError extends Error {
|
|
|
145
411
|
// ---------------------------------------------------------------------------
|
|
146
412
|
|
|
147
413
|
type Segment =
|
|
148
|
-
| {|
|
|
149
|
-
| {|
|
|
150
|
-
| {|
|
|
414
|
+
| {| readonly kind: "static", readonly value: string |}
|
|
415
|
+
| {| readonly kind: "param", readonly name: string |}
|
|
416
|
+
| {| readonly kind: "catchAll", readonly name: string |};
|
|
151
417
|
|
|
152
418
|
function compile(routePath: string): $ReadOnlyArray<Segment> {
|
|
153
419
|
return routePath
|
|
@@ -171,35 +437,37 @@ function compile(routePath: string): $ReadOnlyArray<Segment> {
|
|
|
171
437
|
function specificity(segments: $ReadOnlyArray<Segment>): number {
|
|
172
438
|
let score = 0;
|
|
173
439
|
for (const segment of segments) {
|
|
174
|
-
score +=
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
};
|
|
440
|
+
score += match (segment) {
|
|
441
|
+
{kind: "static"} => 3,
|
|
442
|
+
{kind: "param"} => 2,
|
|
443
|
+
{kind: "catchAll"} => 1,
|
|
444
|
+
};
|
|
180
445
|
}
|
|
181
446
|
return score;
|
|
182
447
|
}
|
|
183
448
|
|
|
184
|
-
function matchSegments(
|
|
449
|
+
function matchSegments(
|
|
450
|
+
segments: $ReadOnlyArray<Segment>,
|
|
451
|
+
parts: $ReadOnlyArray<string>,
|
|
452
|
+
): ?RouteParams {
|
|
185
453
|
const params: { [string]: string | $ReadOnlyArray<string> } = {};
|
|
186
454
|
let index = 0;
|
|
187
455
|
for (const segment of segments) {
|
|
188
456
|
match (segment) {
|
|
189
|
-
{
|
|
457
|
+
{kind: "static", value: const value} => {
|
|
190
458
|
if (parts[index] !== value) {
|
|
191
459
|
return null;
|
|
192
460
|
}
|
|
193
461
|
index += 1;
|
|
194
462
|
}
|
|
195
|
-
{
|
|
463
|
+
{kind: "param", name: const name} => {
|
|
196
464
|
if (index >= parts.length) {
|
|
197
465
|
return null;
|
|
198
466
|
}
|
|
199
467
|
params[name] = decodeSegment(parts[index]);
|
|
200
468
|
index += 1;
|
|
201
469
|
}
|
|
202
|
-
{
|
|
470
|
+
{kind: "catchAll", name: const name} => {
|
|
203
471
|
params[name] = parts.slice(index).map(decodeSegment);
|
|
204
472
|
index = parts.length;
|
|
205
473
|
}
|
|
@@ -216,6 +484,18 @@ function decodeSegment(segment: string): string {
|
|
|
216
484
|
}
|
|
217
485
|
}
|
|
218
486
|
|
|
487
|
+
/**
|
|
488
|
+
* Whether this table can render the route in the browser.
|
|
489
|
+
*
|
|
490
|
+
* False only in the client bundle, and only for a route uf decided ships no
|
|
491
|
+
* JavaScript. Every caller that would load a page asks this first, and the two
|
|
492
|
+
* answers are different actions rather than a success and a failure: render
|
|
493
|
+
* it, or let the browser fetch the document.
|
|
494
|
+
*/
|
|
495
|
+
export function hasClientPage(route: RouteRecord): boolean {
|
|
496
|
+
return route.page != null;
|
|
497
|
+
}
|
|
498
|
+
|
|
219
499
|
/**
|
|
220
500
|
* Match a pathname against the table, preferring the most specific route.
|
|
221
501
|
*/
|
|
@@ -238,8 +518,62 @@ export function matchRoute(routes: $ReadOnlyArray<RouteRecord>, pathname: string
|
|
|
238
518
|
return best;
|
|
239
519
|
}
|
|
240
520
|
|
|
521
|
+
/**
|
|
522
|
+
* Whether a boundary declared at `segments` is at or above `parts`.
|
|
523
|
+
*
|
|
524
|
+
* The same segment kinds as [`matchSegments`], stopping when the boundary's
|
|
525
|
+
* own segments run out instead of requiring the path to: `/guide` covers
|
|
526
|
+
* `/guide/nope`, and `/guide` covers `/guide` itself.
|
|
527
|
+
*/
|
|
528
|
+
function covers(segments: $ReadOnlyArray<Segment>, parts: $ReadOnlyArray<string>): boolean {
|
|
529
|
+
let index = 0;
|
|
530
|
+
for (const segment of segments) {
|
|
531
|
+
const next = match (segment) {
|
|
532
|
+
{kind: "static", value: const value} => parts[index] === value ? index + 1 : -1,
|
|
533
|
+
{kind: "param"} => index < parts.length ? index + 1 : -1,
|
|
534
|
+
{kind: "catchAll"} => parts.length,
|
|
535
|
+
};
|
|
536
|
+
if (next === -1) {
|
|
537
|
+
return false;
|
|
538
|
+
}
|
|
539
|
+
index = next;
|
|
540
|
+
}
|
|
541
|
+
return true;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* The nearest boundary above `pathname`, or `null` when none covers it.
|
|
546
|
+
*
|
|
547
|
+
* The one rule both `_uf.not-found.js` and `_uf.error.js` are resolved by, and
|
|
548
|
+
* the same one layouts already follow: nearest means the longest path that
|
|
549
|
+
* covers the URL. It is decided here rather than by the table's order — the
|
|
550
|
+
* table is sorted by path so the generated module is stable, and a resolver
|
|
551
|
+
* that read "nearest" as "first" would silently depend on that sort. Two
|
|
552
|
+
* boundaries can share a path (a route group's directory does not appear in
|
|
553
|
+
* the URL), and then the first in the table wins.
|
|
554
|
+
*/
|
|
555
|
+
function nearestBoundary<TBoundary: { readonly path: string, ... }>(
|
|
556
|
+
boundaries: $ReadOnlyArray<TBoundary>,
|
|
557
|
+
pathname: string,
|
|
558
|
+
): ?TBoundary {
|
|
559
|
+
const parts = pathname.split("/").filter((part) => part !== "");
|
|
560
|
+
let best: ?TBoundary = null;
|
|
561
|
+
let bestDepth = -1;
|
|
562
|
+
for (const boundary of boundaries) {
|
|
563
|
+
const segments = compile(boundary.path);
|
|
564
|
+
if (!covers(segments, parts)) {
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
if (segments.length > bestDepth) {
|
|
568
|
+
best = boundary;
|
|
569
|
+
bestDepth = segments.length;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
return best;
|
|
573
|
+
}
|
|
574
|
+
|
|
241
575
|
/** Split a URL into its pathname and search string. */
|
|
242
|
-
export function splitUrl(url: string): {|
|
|
576
|
+
export function splitUrl(url: string): {| readonly pathname: string, readonly search: string |} {
|
|
243
577
|
const hash = url.indexOf("#");
|
|
244
578
|
const withoutHash = hash === -1 ? url : url.slice(0, hash);
|
|
245
579
|
const question = withoutHash.indexOf("?");
|
|
@@ -291,11 +625,39 @@ function loadOnce<T>(load: () => Promise<T>): Promise<T> {
|
|
|
291
625
|
* `data` is what the loader returned; on the client after hydration it is the
|
|
292
626
|
* value the server embedded, so the loader does not run twice for the first
|
|
293
627
|
* page.
|
|
628
|
+
*
|
|
629
|
+
* # This resolves or redirects; it does not reject
|
|
630
|
+
*
|
|
631
|
+
* Everything a route can go wrong with is a route to render: no match and
|
|
632
|
+
* `notFound()` are the not-found boundary, a loader that threw and
|
|
633
|
+
* `forbidden()`/`unauthorized()` are the error boundary. Only `redirect()`
|
|
634
|
+
* comes back out, because a redirect is a response rather than a page and the
|
|
635
|
+
* caller is what has one to send.
|
|
636
|
+
*
|
|
637
|
+
* That guarantee is the point rather than a convenience. `hydrate` awaits this
|
|
638
|
+
* before `hydrateRoot`, so a rejection there is not an error page — it is no
|
|
639
|
+
* `hydrateRoot` call at all, and the document the server sent stays on screen
|
|
640
|
+
* with nothing attached to it.
|
|
294
641
|
*/
|
|
295
642
|
export async function resolveMatch(
|
|
296
643
|
table: RouteTable,
|
|
297
644
|
url: string,
|
|
298
|
-
options?: {|
|
|
645
|
+
options?: {| readonly data?: mixed, readonly skipLoader?: boolean |},
|
|
646
|
+
): Promise<ResolvedRoute> {
|
|
647
|
+
try {
|
|
648
|
+
return await resolveRoute(table, url, options);
|
|
649
|
+
} catch (error) {
|
|
650
|
+
if (error instanceof RedirectError) {
|
|
651
|
+
throw error;
|
|
652
|
+
}
|
|
653
|
+
return resolveFailure(table, url, error);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
async function resolveRoute(
|
|
658
|
+
table: RouteTable,
|
|
659
|
+
url: string,
|
|
660
|
+
options?: {| readonly data?: mixed, readonly skipLoader?: boolean |},
|
|
299
661
|
): Promise<ResolvedRoute> {
|
|
300
662
|
const { pathname, search } = splitUrl(url);
|
|
301
663
|
const searchParams = parseSearch(search);
|
|
@@ -305,24 +667,52 @@ export async function resolveMatch(
|
|
|
305
667
|
return resolveNotFound(table, pathname, search, searchParams);
|
|
306
668
|
}
|
|
307
669
|
|
|
670
|
+
const load = matched.route.page;
|
|
671
|
+
if (load == null) {
|
|
672
|
+
// Reachable only by asking this table to render a route it was built
|
|
673
|
+
// without. `hydrate` and every navigation check `hasClientPage` first and
|
|
674
|
+
// hand the URL to the browser instead, so arriving here means a caller
|
|
675
|
+
// went around them — and the honest answer is to say so rather than to
|
|
676
|
+
// render an empty page.
|
|
677
|
+
throw new Error(
|
|
678
|
+
`@uniflowed/router: ${matched.route.path} has no page in this route table; it ships no ` +
|
|
679
|
+
"client JavaScript, so the browser navigates to it rather than rendering it",
|
|
680
|
+
);
|
|
681
|
+
}
|
|
308
682
|
const [page, ...layouts] = await Promise.all([
|
|
309
|
-
loadOnce(
|
|
683
|
+
loadOnce(load),
|
|
310
684
|
...matched.route.layouts.map((layout) => loadOnce(layout)),
|
|
311
685
|
]);
|
|
686
|
+
// Started here and awaited at the end: the boundary's module does not depend
|
|
687
|
+
// on the loader, so importing it alongside costs a navigation nothing. It
|
|
688
|
+
// never rejects, so an early throw below leaves no unhandled rejection.
|
|
689
|
+
const boundary = resolveErrorBoundary(table, pathname, matched.route.layouts.length);
|
|
690
|
+
// Started alongside for the same reason, and awaited at the end: a fallback
|
|
691
|
+
// depends on nothing the loader produces.
|
|
692
|
+
const loading = resolveLoading(matched.route, matched.route.layouts.length);
|
|
312
693
|
|
|
313
694
|
let data: mixed = options?.data;
|
|
314
695
|
if (options?.skipLoader !== true && typeof page.loader === "function") {
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
696
|
+
// Awaited here, so a route's time to first byte is still its slowest
|
|
697
|
+
// loader. A page that suspends while *rendering* streams — that is what the
|
|
698
|
+
// `<Suspense>` boundaries below are for — but a page waiting on its loader
|
|
699
|
+
// has already waited by the time React sees the tree, so its fallback shows
|
|
700
|
+
// for no time at all.
|
|
701
|
+
//
|
|
702
|
+
// Deferring it means handing the page a promise and unwrapping it inside
|
|
703
|
+
// the boundary, and the obstacle is not the awaiting: it is that
|
|
704
|
+
// `generateMetadata` reads `data` and metadata goes in the head, and that
|
|
705
|
+
// the loader data is embedded in the head too, for hydration. Both are
|
|
706
|
+
// decisions about the document rather than about the route.
|
|
707
|
+
// ubugeeei-prod/uf#373 has the design.
|
|
708
|
+
data = await page.loader({ params: matched.params, searchParams, pathname });
|
|
323
709
|
}
|
|
324
710
|
|
|
325
|
-
const metadata = await resolveMetadata(page, layouts, {
|
|
711
|
+
const metadata = await resolveMetadata(page, layouts, {
|
|
712
|
+
params: matched.params,
|
|
713
|
+
searchParams,
|
|
714
|
+
data,
|
|
715
|
+
});
|
|
326
716
|
return {
|
|
327
717
|
pathname,
|
|
328
718
|
search,
|
|
@@ -334,16 +724,209 @@ export async function resolveMatch(
|
|
|
334
724
|
data,
|
|
335
725
|
metadata,
|
|
336
726
|
status: 200,
|
|
727
|
+
error: null,
|
|
728
|
+
errorBoundary: await boundary,
|
|
729
|
+
loading: await loading,
|
|
337
730
|
};
|
|
338
731
|
}
|
|
339
732
|
|
|
733
|
+
/**
|
|
734
|
+
* The route's loading boundaries, imported.
|
|
735
|
+
*
|
|
736
|
+
* A boundary whose module will not load is dropped rather than thrown for, and
|
|
737
|
+
* this is the same judgement `resolveErrorBoundary` makes one function above: a
|
|
738
|
+
* fallback is what the router shows while it does not yet have the page, so a
|
|
739
|
+
* broken fallback must not become a broken page. The route renders without that
|
|
740
|
+
* boundary — the next one out, or the shell, waits for it instead — and the
|
|
741
|
+
* import error surfaces where it belongs, when the module is next asked for.
|
|
742
|
+
*/
|
|
743
|
+
async function resolveLoading(
|
|
744
|
+
route: RouteRecord,
|
|
745
|
+
layoutCount: number,
|
|
746
|
+
): Promise<$ReadOnlyArray<{| readonly above: number, readonly module: LoadingModule |}>> {
|
|
747
|
+
const records = route.loading ?? [];
|
|
748
|
+
if (records.length === 0) {
|
|
749
|
+
return [];
|
|
750
|
+
}
|
|
751
|
+
const loaded = await Promise.all(
|
|
752
|
+
records.map(async (record) => {
|
|
753
|
+
try {
|
|
754
|
+
return {
|
|
755
|
+
// Clamped exactly as the error boundary's is, and for the same
|
|
756
|
+
// reason: a `(group)` directory can leave a route with fewer layouts
|
|
757
|
+
// than the boundary that covers it.
|
|
758
|
+
above: Math.min(record.above, layoutCount),
|
|
759
|
+
module: await loadOnce(record.module),
|
|
760
|
+
};
|
|
761
|
+
} catch {
|
|
762
|
+
return null;
|
|
763
|
+
}
|
|
764
|
+
}),
|
|
765
|
+
);
|
|
766
|
+
return loaded.filter(Boolean);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* The route to render after something threw.
|
|
771
|
+
*
|
|
772
|
+
* Two callers, one behaviour: [`resolveMatch`] when a loader or a module
|
|
773
|
+
* import threw, and `createRenderer` when the *render* did — React's error
|
|
774
|
+
* boundaries do not run in `renderToString`, so the server has to catch it
|
|
775
|
+
* itself and resolve again.
|
|
776
|
+
*/
|
|
777
|
+
export async function resolveFailure(
|
|
778
|
+
table: RouteTable,
|
|
779
|
+
url: string,
|
|
780
|
+
error: mixed,
|
|
781
|
+
): Promise<ResolvedRoute> {
|
|
782
|
+
const { pathname, search } = splitUrl(url);
|
|
783
|
+
const searchParams = parseSearch(search);
|
|
784
|
+
if (error instanceof NotFoundError) {
|
|
785
|
+
try {
|
|
786
|
+
return await resolveNotFound(table, pathname, search, searchParams);
|
|
787
|
+
} catch (failure) {
|
|
788
|
+
// The not-found page itself would not load. Falling through to the error
|
|
789
|
+
// boundary rather than rethrowing is what keeps the promise above: the
|
|
790
|
+
// page a project wrote to explain a 404 is not more load-bearing than
|
|
791
|
+
// the document staying on screen.
|
|
792
|
+
return resolveError(table, pathname, search, searchParams, routeErrorFor(failure));
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
return resolveError(table, pathname, search, searchParams, routeErrorFor(error));
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/** What a thrown value means to the router. */
|
|
799
|
+
function routeErrorFor(error: mixed): RouteError {
|
|
800
|
+
if (error instanceof UnauthorizedError) {
|
|
801
|
+
return { kind: "unauthorized" };
|
|
802
|
+
}
|
|
803
|
+
if (error instanceof ForbiddenError) {
|
|
804
|
+
return { kind: "forbidden" };
|
|
805
|
+
}
|
|
806
|
+
return { kind: "thrown", error };
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* The error boundary a route renders inside, loaded with the route rather than
|
|
811
|
+
* when it is needed.
|
|
812
|
+
*
|
|
813
|
+
* React decides to show a boundary's fallback synchronously, during the render
|
|
814
|
+
* that threw. A module that still has to be imported is a module that is not
|
|
815
|
+
* there at the only moment it can be used, so this is one more dynamic import
|
|
816
|
+
* per navigation and not a lazy one.
|
|
817
|
+
*
|
|
818
|
+
* `above` is the boundary's own layout count, clamped to the route's. The
|
|
819
|
+
* first attempt compared the two layout arrays for a shared prefix, which is
|
|
820
|
+
* more precise when a `(group)` directory puts a boundary beside a route
|
|
821
|
+
* rather than above it — and it worked by *reference identity* of the loader
|
|
822
|
+
* functions, which holds only because `routesModuleSource` deduplicates them
|
|
823
|
+
* by file. A rule that depends on an invisible property of the generated
|
|
824
|
+
* module is a rule that reads as zero the moment a table is built any other
|
|
825
|
+
* way, and it did: it put the boundary outside the layouts it was written
|
|
826
|
+
* inside. Nesting a boundary per group needs parallel-route trees (#267);
|
|
827
|
+
* until then this is the honest approximation, and it is stated rather than
|
|
828
|
+
* inferred.
|
|
829
|
+
*/
|
|
830
|
+
async function resolveErrorBoundary(
|
|
831
|
+
table: RouteTable,
|
|
832
|
+
pathname: string,
|
|
833
|
+
layoutCount: number,
|
|
834
|
+
): Promise<{| readonly module: ?ErrorModule, readonly above: number |}> {
|
|
835
|
+
const boundary = nearestBoundary(table.errors, pathname);
|
|
836
|
+
if (boundary == null) {
|
|
837
|
+
return { module: null, above: 0 };
|
|
838
|
+
}
|
|
839
|
+
// Clamped, because a route group can leave a route with fewer layouts than
|
|
840
|
+
// the boundary covering it, and an `above` past the end would compose the
|
|
841
|
+
// layouts out of nothing.
|
|
842
|
+
const above = Math.min(boundary.layouts.length, layoutCount);
|
|
843
|
+
try {
|
|
844
|
+
return { module: await loadOnce(boundary.module), above };
|
|
845
|
+
} catch {
|
|
846
|
+
// A boundary whose module will not load cannot be the answer to a throw,
|
|
847
|
+
// and this is why the field is nullable: containment must not itself
|
|
848
|
+
// depend on an import working.
|
|
849
|
+
return { module: null, above: 0 };
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* The error page for `pathname`, inside the layouts above the boundary that
|
|
855
|
+
* answers it.
|
|
856
|
+
*
|
|
857
|
+
* The layouts are the boundary's, for the same reason [`resolveNotFound`]
|
|
858
|
+
* gives: they are what stays mounted around the error, and the layouts below
|
|
859
|
+
* the boundary belong to the subtree that just stopped.
|
|
860
|
+
*/
|
|
861
|
+
async function resolveError(
|
|
862
|
+
table: RouteTable,
|
|
863
|
+
pathname: string,
|
|
864
|
+
search: string,
|
|
865
|
+
searchParams: SearchParams,
|
|
866
|
+
routeError: RouteError,
|
|
867
|
+
): Promise<ResolvedRoute> {
|
|
868
|
+
const boundary = nearestBoundary(table.errors, pathname);
|
|
869
|
+
let module: ?ErrorModule = null;
|
|
870
|
+
let layouts: $ReadOnlyArray<LayoutModule> = [];
|
|
871
|
+
if (boundary != null) {
|
|
872
|
+
try {
|
|
873
|
+
[module, layouts] = await Promise.all([
|
|
874
|
+
loadOnce(boundary.module),
|
|
875
|
+
Promise.all(boundary.layouts.map((layout) => loadOnce(layout))),
|
|
876
|
+
]);
|
|
877
|
+
} catch {
|
|
878
|
+
// See `resolveErrorBoundary`: the framework's own page answers instead.
|
|
879
|
+
module = null;
|
|
880
|
+
layouts = [];
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
const declared = await resolveMetadata(
|
|
885
|
+
module?.metadata != null ? { metadata: module.metadata } : {},
|
|
886
|
+
layouts,
|
|
887
|
+
{ params: {}, searchParams, data: undefined },
|
|
888
|
+
);
|
|
889
|
+
return {
|
|
890
|
+
pathname,
|
|
891
|
+
search,
|
|
892
|
+
path: "*",
|
|
893
|
+
params: {},
|
|
894
|
+
searchParams,
|
|
895
|
+
page: { default: ResolvedErrorPage },
|
|
896
|
+
layouts,
|
|
897
|
+
data: undefined,
|
|
898
|
+
metadata: declared.title != null ? declared : { ...declared, title: errorTitle(routeError) },
|
|
899
|
+
status: routeErrorStatus(routeError),
|
|
900
|
+
error: routeError,
|
|
901
|
+
// All of the boundary's layouts are above it, and no inner boundary is
|
|
902
|
+
// inserted around a page that already is one; see `RouteView`.
|
|
903
|
+
errorBoundary: { module, above: layouts.length },
|
|
904
|
+
// An error page has nothing left to wait for: it renders the value it was
|
|
905
|
+
// resolved with. A fallback around it would be a boundary that can never
|
|
906
|
+
// show, which is worse than none.
|
|
907
|
+
loading: [],
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* The not-found page for `pathname`, inside the layouts above the boundary
|
|
913
|
+
* that answers it.
|
|
914
|
+
*
|
|
915
|
+
* The layouts are the *boundary's*, not the ones the URL had already matched.
|
|
916
|
+
* Taking the matched route's layouts was the other candidate and it is wrong
|
|
917
|
+
* in both directions: for an unmatched URL there is no matched route to take
|
|
918
|
+
* them from, and for `notFound()` thrown from a page they would keep the
|
|
919
|
+
* layouts *below* the boundary — so `app/guide/[slug]/_uf.layout.js` would
|
|
920
|
+
* wrap a 404 that `app/guide/_uf.not-found.js` answered, which is the layout
|
|
921
|
+
* of the page that just said it does not exist.
|
|
922
|
+
*/
|
|
340
923
|
async function resolveNotFound(
|
|
341
924
|
table: RouteTable,
|
|
342
925
|
pathname: string,
|
|
343
926
|
search: string,
|
|
344
927
|
searchParams: SearchParams,
|
|
345
928
|
): Promise<ResolvedRoute> {
|
|
346
|
-
const record = table.notFound;
|
|
929
|
+
const record = nearestBoundary(table.notFound, pathname);
|
|
347
930
|
if (record == null) {
|
|
348
931
|
return {
|
|
349
932
|
pathname,
|
|
@@ -356,13 +939,20 @@ async function resolveNotFound(
|
|
|
356
939
|
data: undefined,
|
|
357
940
|
metadata: { title: "Not found" },
|
|
358
941
|
status: 404,
|
|
942
|
+
error: null,
|
|
943
|
+
errorBoundary: await resolveErrorBoundary(table, pathname, 0),
|
|
944
|
+
loading: [],
|
|
359
945
|
};
|
|
360
946
|
}
|
|
361
947
|
const [page, ...layouts] = await Promise.all([
|
|
362
948
|
loadOnce(record.page),
|
|
363
949
|
...record.layouts.map((layout) => loadOnce(layout)),
|
|
364
950
|
]);
|
|
365
|
-
const metadata = await resolveMetadata(page, layouts, {
|
|
951
|
+
const metadata = await resolveMetadata(page, layouts, {
|
|
952
|
+
params: {},
|
|
953
|
+
searchParams,
|
|
954
|
+
data: undefined,
|
|
955
|
+
});
|
|
366
956
|
return {
|
|
367
957
|
pathname,
|
|
368
958
|
search,
|
|
@@ -374,6 +964,13 @@ async function resolveNotFound(
|
|
|
374
964
|
data: undefined,
|
|
375
965
|
metadata,
|
|
376
966
|
status: 404,
|
|
967
|
+
error: null,
|
|
968
|
+
// A not-found page is a page: one that throws is contained like any other.
|
|
969
|
+
errorBoundary: await resolveErrorBoundary(table, pathname, layouts.length),
|
|
970
|
+
// A not-found boundary is matched, not nested: `nearestBoundary` picked one
|
|
971
|
+
// record and the loading files are a property of the route that was walked
|
|
972
|
+
// to, which this URL never reached. Nothing to wait for, so no boundary.
|
|
973
|
+
loading: [],
|
|
377
974
|
};
|
|
378
975
|
}
|
|
379
976
|
|
|
@@ -390,7 +987,11 @@ async function resolveMetadata(
|
|
|
390
987
|
}
|
|
391
988
|
if (page.frontmatter != null) {
|
|
392
989
|
const { title, description } = page.frontmatter;
|
|
393
|
-
merged = {
|
|
990
|
+
merged = {
|
|
991
|
+
...merged,
|
|
992
|
+
...(title != null ? { title } : {}),
|
|
993
|
+
...(description != null ? { description } : {}),
|
|
994
|
+
};
|
|
394
995
|
}
|
|
395
996
|
if (page.metadata != null) {
|
|
396
997
|
merged = { ...merged, ...page.metadata };
|
|
@@ -411,37 +1012,188 @@ component DefaultNotFound() {
|
|
|
411
1012
|
);
|
|
412
1013
|
}
|
|
413
1014
|
|
|
1015
|
+
/** The document title an error page gets when nothing declared one. */
|
|
1016
|
+
function errorTitle(error: RouteError): string {
|
|
1017
|
+
return match (error) {
|
|
1018
|
+
{kind: "unauthorized"} => "Sign in required",
|
|
1019
|
+
{kind: "forbidden"} => "Not allowed",
|
|
1020
|
+
{kind: "thrown"} => "Something went wrong",
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
/**
|
|
1025
|
+
* The framework's error page, for a project that declares no `_uf.error.js`.
|
|
1026
|
+
*
|
|
1027
|
+
* It says which of the three happened and offers the reset, and it does *not*
|
|
1028
|
+
* print the thrown error: on the server that message is written for whoever
|
|
1029
|
+
* deployed the application — a query, a path, a token in a stack — and this
|
|
1030
|
+
* markup is sent to whoever asked for the page. `uf dev` reports the throw in
|
|
1031
|
+
* the terminal and `uf build` fails the route, which are the places the person
|
|
1032
|
+
* who can act on it is looking.
|
|
1033
|
+
*/
|
|
1034
|
+
component DefaultRouteError(error: RouteError, reset: () => void) {
|
|
1035
|
+
const title = errorTitle(error);
|
|
1036
|
+
const detail = match (error) {
|
|
1037
|
+
{kind: "unauthorized"} => "This page needs you to be signed in.",
|
|
1038
|
+
{kind: "forbidden"} => "You do not have access to this page.",
|
|
1039
|
+
{kind: "thrown"} => "This page could not be rendered.",
|
|
1040
|
+
};
|
|
1041
|
+
return (
|
|
1042
|
+
<main>
|
|
1043
|
+
<title>{title}</title>
|
|
1044
|
+
<h1>{title}</h1>
|
|
1045
|
+
<p>{detail}</p>
|
|
1046
|
+
<button type="button" onClick={reset}>
|
|
1047
|
+
Try again
|
|
1048
|
+
</button>
|
|
1049
|
+
</main>
|
|
1050
|
+
);
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
/** The component an error module renders: `default`, or the named `Error`. */
|
|
1054
|
+
function errorComponent(module: ErrorModule): React.ComponentType<ErrorRenderProps> {
|
|
1055
|
+
const component = module.default ?? module.Error;
|
|
1056
|
+
if (component == null) {
|
|
1057
|
+
throw new Error(
|
|
1058
|
+
"@uniflowed/router: an error module must export a component as `default` or `Error`",
|
|
1059
|
+
);
|
|
1060
|
+
}
|
|
1061
|
+
return renderable(component);
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
/** The props an error boundary's component receives. */
|
|
1065
|
+
type ErrorRenderProps = {|
|
|
1066
|
+
readonly error: RouteError,
|
|
1067
|
+
readonly reset: () => void,
|
|
1068
|
+
|};
|
|
1069
|
+
|
|
1070
|
+
/**
|
|
1071
|
+
* The error UI, from whichever module is in scope.
|
|
1072
|
+
*
|
|
1073
|
+
* One component for both ways in — the class boundary below, which catches a
|
|
1074
|
+
* throw while the browser renders, and `ResolvedErrorPage`, which is what the
|
|
1075
|
+
* server renders because React's boundaries do not run in `renderToString`.
|
|
1076
|
+
* Two paths to the same screen is exactly the pair that drifts.
|
|
1077
|
+
*/
|
|
1078
|
+
component RouteErrorView(module: ?ErrorModule, error: RouteError, reset: () => void) {
|
|
1079
|
+
if (module == null) {
|
|
1080
|
+
return <DefaultRouteError error={error} reset={reset} />;
|
|
1081
|
+
}
|
|
1082
|
+
const Boundary = errorComponent(module);
|
|
1083
|
+
return <Boundary error={error} reset={reset} />;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/**
|
|
1087
|
+
* The page of a route that resolved to an error.
|
|
1088
|
+
*
|
|
1089
|
+
* A resolved error route carries the error and the module on the route itself,
|
|
1090
|
+
* so this is a static component rather than a closure the resolver builds:
|
|
1091
|
+
* `RouteView` composes it in its layouts exactly like a page, which is what
|
|
1092
|
+
* makes "inside the layouts above the boundary" one code path and not two.
|
|
1093
|
+
*
|
|
1094
|
+
* `reset()` here is `router.refresh()` — this route resolved to an error
|
|
1095
|
+
* because a loader or an import threw, so re-running the resolution is what
|
|
1096
|
+
* trying again means. On the server `refresh` does nothing, which is correct:
|
|
1097
|
+
* a static render has nothing to re-run.
|
|
1098
|
+
*/
|
|
1099
|
+
component ResolvedErrorPage() {
|
|
1100
|
+
const { resolved, router } = useRouterState();
|
|
1101
|
+
const reset = useCallback(() => {
|
|
1102
|
+
router.refresh().catch(() => {});
|
|
1103
|
+
}, [router]);
|
|
1104
|
+
|
|
1105
|
+
if (resolved.error == null) {
|
|
1106
|
+
// Unreachable: this module is only ever the page of a resolved error route.
|
|
1107
|
+
return null;
|
|
1108
|
+
}
|
|
1109
|
+
return (
|
|
1110
|
+
<RouteErrorView module={resolved.errorBoundary.module} error={resolved.error} reset={reset} />
|
|
1111
|
+
);
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
type RouteErrorBoundaryProps = {|
|
|
1115
|
+
readonly module: ?ErrorModule,
|
|
1116
|
+
readonly resetKey: string,
|
|
1117
|
+
readonly children: React.Node,
|
|
1118
|
+
|};
|
|
1119
|
+
|
|
1120
|
+
type RouteErrorBoundaryState = {| readonly error: ?RouteError |};
|
|
1121
|
+
|
|
1122
|
+
/**
|
|
1123
|
+
* The boundary that catches a throw while the browser renders the subtree.
|
|
1124
|
+
*
|
|
1125
|
+
* A class, because `getDerivedStateFromError` is React's contract for this and
|
|
1126
|
+
* there is no hook that does it — this is the one place in the router where
|
|
1127
|
+
* following React's public contract means not using a function component.
|
|
1128
|
+
*
|
|
1129
|
+
* Recovering on navigation is `componentDidUpdate` watching `resetKey`, not
|
|
1130
|
+
* `key={pathname}` on the boundary. Keying it remounts the subtree on *every*
|
|
1131
|
+
* navigation, error or not, and everything below the boundary goes with it —
|
|
1132
|
+
* which is the layouts, whose whole purpose is to survive navigation with
|
|
1133
|
+
* their scroll position and their open sections intact.
|
|
1134
|
+
*/
|
|
1135
|
+
class RouteErrorBoundary extends React.Component<RouteErrorBoundaryProps, RouteErrorBoundaryState> {
|
|
1136
|
+
constructor(props: RouteErrorBoundaryProps) {
|
|
1137
|
+
super(props);
|
|
1138
|
+
this.state = { error: null };
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
static getDerivedStateFromError(error: mixed): RouteErrorBoundaryState {
|
|
1142
|
+
return { error: routeErrorFor(error) };
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
componentDidUpdate(previous: RouteErrorBoundaryProps) {
|
|
1146
|
+
if (this.state.error != null && previous.resetKey !== this.props.resetKey) {
|
|
1147
|
+
this.setState({ error: null });
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
render(): React.Node {
|
|
1152
|
+
const { error } = this.state;
|
|
1153
|
+
if (error == null) {
|
|
1154
|
+
return this.props.children;
|
|
1155
|
+
}
|
|
1156
|
+
return (
|
|
1157
|
+
<RouteErrorView
|
|
1158
|
+
module={this.props.module}
|
|
1159
|
+
error={error}
|
|
1160
|
+
reset={() => this.setState({ error: null })}
|
|
1161
|
+
/>
|
|
1162
|
+
);
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
|
|
414
1166
|
// ---------------------------------------------------------------------------
|
|
415
1167
|
// The React binding
|
|
416
1168
|
// ---------------------------------------------------------------------------
|
|
417
1169
|
|
|
418
1170
|
/** How a navigation is performed. */
|
|
419
|
-
export type NavigateOptions = {|
|
|
1171
|
+
export type NavigateOptions = {| readonly replace?: boolean, readonly scroll?: boolean |};
|
|
420
1172
|
|
|
421
1173
|
/** What `useRouter()` returns. */
|
|
422
1174
|
export type Router = {|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
1175
|
+
readonly push: (to: string, options?: NavigateOptions) => Promise<void>,
|
|
1176
|
+
readonly replace: (to: string) => Promise<void>,
|
|
1177
|
+
readonly prefetch: (to: string) => Promise<void>,
|
|
1178
|
+
readonly refresh: () => Promise<void>,
|
|
1179
|
+
readonly back: () => void,
|
|
1180
|
+
readonly forward: () => void,
|
|
429
1181
|
|};
|
|
430
1182
|
|
|
431
1183
|
/** What `useRoute()` returns. */
|
|
432
1184
|
export type RouteInfo = {|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
1185
|
+
readonly path: string,
|
|
1186
|
+
readonly pathname: string,
|
|
1187
|
+
readonly params: RouteParams,
|
|
1188
|
+
readonly searchParams: SearchParams,
|
|
1189
|
+
readonly data: mixed,
|
|
1190
|
+
readonly pending: boolean,
|
|
439
1191
|
|};
|
|
440
1192
|
|
|
441
1193
|
type RouterState = {|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
1194
|
+
readonly resolved: ResolvedRoute,
|
|
1195
|
+
readonly router: Router,
|
|
1196
|
+
readonly pending: boolean,
|
|
445
1197
|
|};
|
|
446
1198
|
|
|
447
1199
|
const RouterContext: React.Context<?RouterState> = createContext(null);
|
|
@@ -457,18 +1209,36 @@ export function installRoutes(table: RouteTable): void {
|
|
|
457
1209
|
/** The registered table, or a clear error when the entry forgot to install it. */
|
|
458
1210
|
export function routeTable(): RouteTable {
|
|
459
1211
|
if (installedTable == null) {
|
|
460
|
-
throw new Error(
|
|
1212
|
+
throw new Error(
|
|
1213
|
+
"@uniflowed/router: no route table is installed; start the app through `uf dev` or `uf build`",
|
|
1214
|
+
);
|
|
461
1215
|
}
|
|
462
1216
|
return installedTable;
|
|
463
1217
|
}
|
|
464
1218
|
|
|
465
1219
|
/** Props the app root receives from the client and server entries. */
|
|
466
1220
|
export type AppProps = {|
|
|
467
|
-
|
|
468
|
-
|
|
1221
|
+
readonly url: string,
|
|
1222
|
+
readonly initial: ResolvedRoute,
|
|
469
1223
|
|};
|
|
470
1224
|
|
|
471
|
-
|
|
1225
|
+
/**
|
|
1226
|
+
* Whether there is a document to navigate.
|
|
1227
|
+
*
|
|
1228
|
+
* Asked every time rather than answered once at module scope, and the
|
|
1229
|
+
* difference is not a style preference. The answer is a constant inside a
|
|
1230
|
+
* browser bundle and inside a server process; it is *not* a constant inside a
|
|
1231
|
+
* test runner, where a DOM is installed on the first render and one worker
|
|
1232
|
+
* serves many files out of one module registry. Latched, the first file in a
|
|
1233
|
+
* worker to import this module decided for every file after it whether a
|
|
1234
|
+
* `Link` navigates or silently does nothing — and a server-rendering test
|
|
1235
|
+
* imports it before any document exists. See ubugeeei-prod/uf#445.
|
|
1236
|
+
*
|
|
1237
|
+
* The cost is a `typeof` per navigation, which is a navigation.
|
|
1238
|
+
*/
|
|
1239
|
+
function isBrowser(): boolean {
|
|
1240
|
+
return typeof window !== "undefined" && typeof document !== "undefined";
|
|
1241
|
+
}
|
|
472
1242
|
|
|
473
1243
|
/**
|
|
474
1244
|
* Provides the current route to the tree and performs navigation.
|
|
@@ -483,11 +1253,23 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
|
|
|
483
1253
|
const [pending, setPending] = useState<boolean>(false);
|
|
484
1254
|
|
|
485
1255
|
const navigate = useCallback(async (to: string, options?: NavigateOptions): Promise<void> => {
|
|
486
|
-
if (!isBrowser) {
|
|
1256
|
+
if (!isBrowser()) {
|
|
487
1257
|
return;
|
|
488
1258
|
}
|
|
489
1259
|
const target = new URL(to, window.location.href);
|
|
490
1260
|
const next = target.pathname + target.search;
|
|
1261
|
+
// The half of the split that is not about bytes. A route whose page is not
|
|
1262
|
+
// in this bundle is not a route this router can render, and pretending
|
|
1263
|
+
// otherwise is the silent break: the navigation would resolve to nothing
|
|
1264
|
+
// and the visitor would be left on the page they clicked from. The browser
|
|
1265
|
+
// has the document, so the browser does the navigation — which is what a
|
|
1266
|
+
// link does when there is no JavaScript at all, and what the anchor
|
|
1267
|
+
// `Link` renders would have done on its own.
|
|
1268
|
+
const matched = matchRoute(routeTable().routes, target.pathname);
|
|
1269
|
+
if (matched != null && !hasClientPage(matched.route)) {
|
|
1270
|
+
window.location.assign(target.href);
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
491
1273
|
setPending(true);
|
|
492
1274
|
try {
|
|
493
1275
|
const nextResolved = await resolveMatch(routeTable(), next);
|
|
@@ -517,11 +1299,19 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
|
|
|
517
1299
|
}, []);
|
|
518
1300
|
|
|
519
1301
|
useEffect(() => {
|
|
520
|
-
if (!isBrowser) {
|
|
1302
|
+
if (!isBrowser()) {
|
|
521
1303
|
return undefined;
|
|
522
1304
|
}
|
|
523
1305
|
const onPopState = () => {
|
|
524
1306
|
const next = window.location.pathname + window.location.search;
|
|
1307
|
+
// Back into a route this bundle has no page for. The history entry is
|
|
1308
|
+
// already the browser's — it moved before this listener ran — so the
|
|
1309
|
+
// document that belongs to it is what has to be fetched.
|
|
1310
|
+
const matched = matchRoute(routeTable().routes, window.location.pathname);
|
|
1311
|
+
if (matched != null && !hasClientPage(matched.route)) {
|
|
1312
|
+
window.location.reload();
|
|
1313
|
+
return;
|
|
1314
|
+
}
|
|
525
1315
|
resolveMatch(routeTable(), next).then((nextResolved) => {
|
|
526
1316
|
startTransition(() => {
|
|
527
1317
|
setResolved(nextResolved);
|
|
@@ -539,32 +1329,39 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
|
|
|
539
1329
|
push: (to, options) => navigate(to, options),
|
|
540
1330
|
replace: (to) => navigate(to, { replace: true }),
|
|
541
1331
|
prefetch: async (to) => {
|
|
542
|
-
if (!isBrowser) {
|
|
1332
|
+
if (!isBrowser()) {
|
|
543
1333
|
return;
|
|
544
1334
|
}
|
|
545
1335
|
const target = new URL(to, window.location.href);
|
|
546
1336
|
const matched = matchRoute(routeTable().routes, target.pathname);
|
|
547
|
-
|
|
1337
|
+
const load = matched?.route.page;
|
|
1338
|
+
if (matched == null || load == null) {
|
|
548
1339
|
return;
|
|
549
1340
|
}
|
|
550
|
-
await Promise.all([
|
|
1341
|
+
await Promise.all([
|
|
1342
|
+
loadOnce(load),
|
|
1343
|
+
...matched.route.layouts.map((layout) => loadOnce(layout)),
|
|
1344
|
+
]);
|
|
551
1345
|
},
|
|
552
1346
|
refresh: async () => {
|
|
553
|
-
if (!isBrowser) {
|
|
1347
|
+
if (!isBrowser()) {
|
|
554
1348
|
return;
|
|
555
1349
|
}
|
|
556
|
-
const nextResolved = await resolveMatch(
|
|
1350
|
+
const nextResolved = await resolveMatch(
|
|
1351
|
+
routeTable(),
|
|
1352
|
+
window.location.pathname + window.location.search,
|
|
1353
|
+
);
|
|
557
1354
|
startTransition(() => {
|
|
558
1355
|
setResolved(nextResolved);
|
|
559
1356
|
});
|
|
560
1357
|
},
|
|
561
1358
|
back: () => {
|
|
562
|
-
if (isBrowser) {
|
|
1359
|
+
if (isBrowser()) {
|
|
563
1360
|
window.history.back();
|
|
564
1361
|
}
|
|
565
1362
|
},
|
|
566
1363
|
forward: () => {
|
|
567
|
-
if (isBrowser) {
|
|
1364
|
+
if (isBrowser()) {
|
|
568
1365
|
window.history.forward();
|
|
569
1366
|
}
|
|
570
1367
|
},
|
|
@@ -572,14 +1369,19 @@ export component RouterProvider(url: string, initial: ResolvedRoute, children: R
|
|
|
572
1369
|
[navigate],
|
|
573
1370
|
);
|
|
574
1371
|
|
|
575
|
-
const value = useMemo<RouterState>(
|
|
1372
|
+
const value = useMemo<RouterState>(
|
|
1373
|
+
() => ({ resolved, router, pending }),
|
|
1374
|
+
[resolved, router, pending],
|
|
1375
|
+
);
|
|
576
1376
|
return <RouterContext.Provider value={value}>{children}</RouterContext.Provider>;
|
|
577
1377
|
}
|
|
578
1378
|
|
|
579
1379
|
hook useRouterState(): RouterState {
|
|
580
1380
|
const state = useContext(RouterContext);
|
|
581
1381
|
if (state == null) {
|
|
582
|
-
throw new Error(
|
|
1382
|
+
throw new Error(
|
|
1383
|
+
"@uniflowed/router: this hook must be used inside the app started by `routerView`",
|
|
1384
|
+
);
|
|
583
1385
|
}
|
|
584
1386
|
return state;
|
|
585
1387
|
}
|
|
@@ -602,30 +1404,112 @@ export hook useRouter(): Router {
|
|
|
602
1404
|
return useRouterState().router;
|
|
603
1405
|
}
|
|
604
1406
|
|
|
605
|
-
/**
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
1407
|
+
/**
|
|
1408
|
+
* The current page's loader data.
|
|
1409
|
+
*
|
|
1410
|
+
* `mixed`, so the page that reads it says what it is and the checker watches
|
|
1411
|
+
* it do so. This was `useLoaderData<T>(): T`, which looks like inference and
|
|
1412
|
+
* is a cast a caller writes at a distance: `useLoaderData<Post>()` asserted
|
|
1413
|
+
* that a loader three files away returned a `Post` and nothing anywhere
|
|
1414
|
+
* checked it, so a loader that changed shape produced a `Post`-shaped
|
|
1415
|
+
* `undefined` at the first property read rather than an error where the shape
|
|
1416
|
+
* was decided.
|
|
1417
|
+
*
|
|
1418
|
+
* Narrowing is a line at the top of the page — `if (typeof data !== "object"
|
|
1419
|
+
* || data == null) { … }`, or the page's own validator schema, which is what
|
|
1420
|
+
* `@uniflowed/validator` is for at exactly this boundary.
|
|
1421
|
+
*
|
|
1422
|
+
* The type that would need no narrowing is a *generated* one: the route table
|
|
1423
|
+
* already produces `RoutePath` and `RouteParams` from the `app/` directory
|
|
1424
|
+
* (`crates/uf_router/src/lib.rs`), and a loader's return type belongs in the
|
|
1425
|
+
* same file, keyed by route. Until it is there, this says what is true.
|
|
1426
|
+
*/
|
|
1427
|
+
export hook useLoaderData(): mixed {
|
|
1428
|
+
return useRouterState().resolved.data;
|
|
609
1429
|
}
|
|
610
1430
|
|
|
611
1431
|
/**
|
|
612
1432
|
* Renders the matched page inside its layouts, innermost last, with the
|
|
613
1433
|
* document metadata as hoistable head elements.
|
|
1434
|
+
*
|
|
1435
|
+
* # One walk down the layouts, not three
|
|
1436
|
+
*
|
|
1437
|
+
* The layouts, the error boundary and the `<Suspense>` boundaries all have to
|
|
1438
|
+
* be threaded into the same stack at the depth each was declared at, so this
|
|
1439
|
+
* is one descending loop over that depth rather than a pass per kind. `depth`
|
|
1440
|
+
* counts the layouts still *outside* the element built so far, which is what
|
|
1441
|
+
* `above` means on both a route's `errorBoundary` and each of its `loading`
|
|
1442
|
+
* entries — one number, one meaning, one place it is compared.
|
|
1443
|
+
*
|
|
1444
|
+
* # Where the error boundaries go
|
|
1445
|
+
*
|
|
1446
|
+
* Two, and they are not the same thing twice. The inner one is the project's
|
|
1447
|
+
* `_uf.error.js`, placed at the depth the file sits at, so the layouts above
|
|
1448
|
+
* it stay mounted and interactive while the subtree below is replaced — that
|
|
1449
|
+
* placement *is* the feature. The outer one has no module and so renders the
|
|
1450
|
+
* framework's page; it is what stands between a throw in a root layout, or in
|
|
1451
|
+
* the error component itself, and an unmounted document. A single boundary
|
|
1452
|
+
* cannot be both: put it outside and a page's throw takes the navigation down
|
|
1453
|
+
* with it; put it inside and nothing catches the layout above.
|
|
1454
|
+
*
|
|
1455
|
+
* # Where the loading boundaries go
|
|
1456
|
+
*
|
|
1457
|
+
* Inside the layout of the segment that declared the file and outside
|
|
1458
|
+
* everything under it, which is what makes the shell arrive first: a renderer
|
|
1459
|
+
* streaming this tree can send every layout down to the boundary, and the
|
|
1460
|
+
* fallback, before whatever the page is waiting for has resolved. A segment
|
|
1461
|
+
* with no `_uf.loading.js` contributes no boundary at all — it is not wrapped
|
|
1462
|
+
* in a `<Suspense fallback={null}>` on the way past — so a project that
|
|
1463
|
+
* declares none renders the tree it rendered before this existed, and a page
|
|
1464
|
+
* that suspends without a boundary above it still fails the way React says it
|
|
1465
|
+
* should rather than silently rendering nothing.
|
|
1466
|
+
*
|
|
1467
|
+
* The error boundary goes *outside* the fallback at the same depth. A throw
|
|
1468
|
+
* while the page is resolving has to reach a boundary that is still mounted,
|
|
1469
|
+
* and the `<Suspense>` is part of what the throw came out of.
|
|
614
1470
|
*/
|
|
615
1471
|
export component RouteView() {
|
|
616
1472
|
const { resolved } = useRouterState();
|
|
1473
|
+
const { module, above } = resolved.errorBoundary;
|
|
617
1474
|
const Page = pageComponent(resolved.page);
|
|
618
1475
|
let element: React.Node = (
|
|
619
1476
|
<Page params={resolved.params} searchParams={resolved.searchParams} data={resolved.data} />
|
|
620
1477
|
);
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
1478
|
+
|
|
1479
|
+
for (let depth = resolved.layouts.length; depth >= 0; depth -= 1) {
|
|
1480
|
+
// Backwards over a root-first list, so the deepest segment's fallback ends
|
|
1481
|
+
// up closest to the page. Two segments land on the same depth whenever the
|
|
1482
|
+
// inner one declares no layout of its own, and then this order is the only
|
|
1483
|
+
// thing that keeps them nested the way the directories are.
|
|
1484
|
+
for (let index = resolved.loading.length - 1; index >= 0; index -= 1) {
|
|
1485
|
+
const boundary = resolved.loading[index];
|
|
1486
|
+
if (boundary.above !== depth) {
|
|
1487
|
+
continue;
|
|
1488
|
+
}
|
|
1489
|
+
const Fallback = loadingComponent(boundary.module);
|
|
1490
|
+
element = <Suspense fallback={<Fallback />}>{element}</Suspense>;
|
|
1491
|
+
}
|
|
1492
|
+
// Not around a route that already resolved to its error page: that page is
|
|
1493
|
+
// the boundary's own component, and wrapping it in the same boundary would
|
|
1494
|
+
// answer a throw inside it with itself.
|
|
1495
|
+
if (depth === above && module != null && resolved.error == null) {
|
|
1496
|
+
element = (
|
|
1497
|
+
<RouteErrorBoundary module={module} resetKey={resolved.pathname}>
|
|
1498
|
+
{element}
|
|
1499
|
+
</RouteErrorBoundary>
|
|
1500
|
+
);
|
|
1501
|
+
}
|
|
1502
|
+
if (depth > 0) {
|
|
1503
|
+
const Layout = layoutComponent(resolved.layouts[depth - 1]);
|
|
1504
|
+
element = <Layout params={resolved.params}>{element}</Layout>;
|
|
1505
|
+
}
|
|
624
1506
|
}
|
|
625
1507
|
return (
|
|
626
1508
|
<>
|
|
627
1509
|
<Head metadata={resolved.metadata} />
|
|
628
|
-
{
|
|
1510
|
+
<RouteErrorBoundary module={null} resetKey={resolved.pathname}>
|
|
1511
|
+
{element}
|
|
1512
|
+
</RouteErrorBoundary>
|
|
629
1513
|
</>
|
|
630
1514
|
);
|
|
631
1515
|
}
|
|
@@ -634,33 +1518,130 @@ export component RouteView() {
|
|
|
634
1518
|
* The component a page module renders: its default export, or the named
|
|
635
1519
|
* `Page` that `uf create` scaffolds. An MDX page always has a default export.
|
|
636
1520
|
*/
|
|
637
|
-
function pageComponent(module: PageModule): React.ComponentType<
|
|
1521
|
+
function pageComponent(module: PageModule): React.ComponentType<PageRenderProps> {
|
|
638
1522
|
const component = module.default ?? module.Page;
|
|
639
1523
|
if (component == null) {
|
|
640
|
-
throw new Error(
|
|
1524
|
+
throw new Error(
|
|
1525
|
+
"@uniflowed/router: a page module must export a component as `default` or `Page`",
|
|
1526
|
+
);
|
|
641
1527
|
}
|
|
642
|
-
return component;
|
|
1528
|
+
return renderable(component);
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
/**
|
|
1532
|
+
* The component a loading module renders: `default`, or the named `Loading`.
|
|
1533
|
+
*
|
|
1534
|
+
* No props, unlike a page or a layout. A fallback is what the router shows
|
|
1535
|
+
* when it does not have the route's answer yet, so there is nothing it could
|
|
1536
|
+
* be handed that would be true — not `data`, which is the thing being waited
|
|
1537
|
+
* for, and not `children`, because it renders instead of them.
|
|
1538
|
+
*/
|
|
1539
|
+
function loadingComponent(module: LoadingModule): React.ComponentType<{||}> {
|
|
1540
|
+
const component = module.default ?? module.Loading;
|
|
1541
|
+
if (component == null) {
|
|
1542
|
+
throw new Error(
|
|
1543
|
+
"@uniflowed/router: a loading module must export a component as `default` or `Loading`",
|
|
1544
|
+
);
|
|
1545
|
+
}
|
|
1546
|
+
return renderable(component);
|
|
643
1547
|
}
|
|
644
1548
|
|
|
645
1549
|
/** The component a layout module renders: `default`, or the named `Layout`. */
|
|
646
|
-
function layoutComponent(module: LayoutModule): React.ComponentType<
|
|
1550
|
+
function layoutComponent(module: LayoutModule): React.ComponentType<LayoutRenderProps> {
|
|
647
1551
|
const component = module.default ?? module.Layout;
|
|
648
1552
|
if (component == null) {
|
|
649
|
-
throw new Error(
|
|
1553
|
+
throw new Error(
|
|
1554
|
+
"@uniflowed/router: a layout module must export a component as `default` or `Layout`",
|
|
1555
|
+
);
|
|
1556
|
+
}
|
|
1557
|
+
return renderable(component);
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
/**
|
|
1561
|
+
* A route module's component, as the router is about to render it.
|
|
1562
|
+
*
|
|
1563
|
+
* # The one cast in this file, and why it is here rather than in six places
|
|
1564
|
+
*
|
|
1565
|
+
* A `RouteComponent` is a component about whose props nothing was claimed, and
|
|
1566
|
+
* `RouteView` is about to pass it three. React allows that — a component
|
|
1567
|
+
* receives the props its parent wrote and ignores the ones it did not declare
|
|
1568
|
+
* — but Flow cannot be told it: a page's props are exact, so no props type but
|
|
1569
|
+
* that page's own is assignable, and the router does not know which page it
|
|
1570
|
+
* has. `React.ComponentType<any>` on the module types was this same
|
|
1571
|
+
* unsoundness spread over six declarations, where it also stopped anyone from
|
|
1572
|
+
* checking that `RouteView` passes the props a page is documented to receive.
|
|
1573
|
+
* Here it is one line, and everything on either side of it is checked: what a
|
|
1574
|
+
* module may export, and what a page is handed. Suppressed by name so that
|
|
1575
|
+
* `check:lib` can gate CI without this file being the thing that stops it; the
|
|
1576
|
+
* directive names the rule, and this is the argument for escaping it.
|
|
1577
|
+
*/
|
|
1578
|
+
function renderable<TProps extends { ... }>(
|
|
1579
|
+
component: RouteComponent,
|
|
1580
|
+
): React.ComponentType<TProps> {
|
|
1581
|
+
// uf-lint-disable-next-line flow/unclear-type
|
|
1582
|
+
return component as any;
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
/**
|
|
1586
|
+
* One URL from a route's metadata, made absolute if it can be.
|
|
1587
|
+
*
|
|
1588
|
+
* Open Graph, Twitter and `rel="canonical"` all want an absolute URL, and a
|
|
1589
|
+
* route module cannot know the host it is served from — so `metadataBase` is
|
|
1590
|
+
* how a site says it once, and this is where it is applied.
|
|
1591
|
+
*
|
|
1592
|
+
* Three things it deliberately does not do. It does not resolve against the
|
|
1593
|
+
* *page's* URL: `Head` renders inside the route and does not know it, and a
|
|
1594
|
+
* `metadataBase` is a site-wide fact rather than a per-page one. It does not
|
|
1595
|
+
* invent a base: with none declared the value is emitted exactly as written,
|
|
1596
|
+
* which is what every page that predates this field already gets. And it does
|
|
1597
|
+
* not throw — a `metadataBase` that is not a URL is a mistake in one field,
|
|
1598
|
+
* and turning it into a blank page would be a worse answer than an unresolved
|
|
1599
|
+
* `og:image`.
|
|
1600
|
+
*/
|
|
1601
|
+
function absoluteUrl(value: string, base: void | string): string {
|
|
1602
|
+
if (base == null) return value;
|
|
1603
|
+
try {
|
|
1604
|
+
return new URL(value, base).href;
|
|
1605
|
+
} catch {
|
|
1606
|
+
return value;
|
|
650
1607
|
}
|
|
651
|
-
return component;
|
|
652
1608
|
}
|
|
653
1609
|
|
|
654
1610
|
component Head(metadata: Metadata) {
|
|
655
|
-
const { title, description, openGraph } = metadata;
|
|
1611
|
+
const { title, description, metadataBase, canonical, openGraph, twitter } = metadata;
|
|
1612
|
+
const href = canonical != null ? absoluteUrl(canonical, metadataBase) : null;
|
|
656
1613
|
return (
|
|
657
1614
|
<>
|
|
658
1615
|
{title != null ? <title>{title}</title> : null}
|
|
659
1616
|
{description != null ? <meta name="description" content={description} /> : null}
|
|
1617
|
+
{href != null ? <link rel="canonical" href={href} /> : null}
|
|
1618
|
+
{/* `og:url` *is* the canonical URL of the page, in Open Graph's own
|
|
1619
|
+
words, so one declaration answers both rather than asking a project
|
|
1620
|
+
to write the same URL twice and keep them in step. */}
|
|
1621
|
+
{href != null ? <meta property="og:url" content={href} /> : null}
|
|
660
1622
|
{openGraph?.title != null ? <meta property="og:title" content={openGraph.title} /> : null}
|
|
661
|
-
{openGraph?.description != null ?
|
|
1623
|
+
{openGraph?.description != null ? (
|
|
1624
|
+
<meta property="og:description" content={openGraph.description} />
|
|
1625
|
+
) : null}
|
|
662
1626
|
{openGraph?.images != null
|
|
663
|
-
? openGraph.images.map((image) =>
|
|
1627
|
+
? openGraph.images.map((image) => (
|
|
1628
|
+
<meta key={image} property="og:image" content={absoluteUrl(image, metadataBase)} />
|
|
1629
|
+
))
|
|
1630
|
+
: null}
|
|
1631
|
+
{/* `name`, not `property`: Open Graph is RDFa and Twitter's cards are
|
|
1632
|
+
not, and a `property="twitter:card"` is ignored by the crawler that
|
|
1633
|
+
reads it. */}
|
|
1634
|
+
{twitter?.card != null ? <meta name="twitter:card" content={twitter.card} /> : null}
|
|
1635
|
+
{twitter?.site != null ? <meta name="twitter:site" content={twitter.site} /> : null}
|
|
1636
|
+
{twitter?.creator != null ? <meta name="twitter:creator" content={twitter.creator} /> : null}
|
|
1637
|
+
{twitter?.title != null ? <meta name="twitter:title" content={twitter.title} /> : null}
|
|
1638
|
+
{twitter?.description != null ? (
|
|
1639
|
+
<meta name="twitter:description" content={twitter.description} />
|
|
1640
|
+
) : null}
|
|
1641
|
+
{twitter?.images != null
|
|
1642
|
+
? twitter.images.map((image) => (
|
|
1643
|
+
<meta key={image} name="twitter:image" content={absoluteUrl(image, metadataBase)} />
|
|
1644
|
+
))
|
|
664
1645
|
: null}
|
|
665
1646
|
</>
|
|
666
1647
|
);
|
|
@@ -683,7 +1664,7 @@ export component Link(
|
|
|
683
1664
|
children?: React.Node,
|
|
684
1665
|
className?: string,
|
|
685
1666
|
onClick?: (event: SyntheticMouseEvent<HTMLAnchorElement>) => mixed,
|
|
686
|
-
...rest: {
|
|
1667
|
+
...rest: { readonly [string]: mixed }
|
|
687
1668
|
) {
|
|
688
1669
|
const router = useRouter();
|
|
689
1670
|
const prefetched = React.useRef(false);
|
|
@@ -767,6 +1748,16 @@ export function notFound(): empty {
|
|
|
767
1748
|
throw new NotFoundError();
|
|
768
1749
|
}
|
|
769
1750
|
|
|
1751
|
+
/** Stop rendering the current page and show the error boundary, as a 401. */
|
|
1752
|
+
export function unauthorized(): empty {
|
|
1753
|
+
throw new UnauthorizedError();
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
/** Stop rendering the current page and show the error boundary, as a 403. */
|
|
1757
|
+
export function forbidden(): empty {
|
|
1758
|
+
throw new ForbiddenError();
|
|
1759
|
+
}
|
|
1760
|
+
|
|
770
1761
|
/** Stop rendering the current page and send the visitor elsewhere. */
|
|
771
1762
|
export function redirect(to: string): empty {
|
|
772
1763
|
throw new RedirectError(to, false);
|