@uniflowed/router 0.0.0-alpha.1 → 0.0.0-alpha.10

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