@uniflowed/router 0.0.0-alpha.6 → 0.0.0-alpha.7

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 CHANGED
@@ -21,8 +21,13 @@ export async function hydrate(options: {|
21
21
  readonly App: React.ComponentType<AppProps>,
22
22
  readonly routes: RouteTable["routes"],
23
23
  readonly notFound: RouteTable["notFound"],
24
+ readonly errors: RouteTable["errors"],
24
25
  |}): Promise<void> {
25
- const table: RouteTable = { routes: options.routes, notFound: options.notFound };
26
+ const table: RouteTable = {
27
+ routes: options.routes,
28
+ notFound: options.notFound,
29
+ errors: options.errors,
30
+ };
26
31
  installRoutes(table);
27
32
 
28
33
  const url = window.location.pathname + window.location.search;
package/index.js CHANGED
@@ -6,17 +6,27 @@
6
6
  // `_uf.layout.js`, and `app.js` exports `routerView("./app")`. The route table
7
7
  // is generated from the directory at build time; this module is the runtime
8
8
  // that matches, loads, navigates and renders it.
9
+ //
10
+ // `_uf.not-found.js` and `_uf.error.js` are the two boundaries: the page for a
11
+ // path that matched nothing, and what renders in place of a subtree that threw.
12
+ // Both are segment files, resolved by the nearest one above the path.
13
+
14
+ import type { RouteError } from "./internal/runtime.js";
9
15
 
10
16
  export type {
11
17
  AppProps,
18
+ ErrorBoundary,
19
+ ErrorModule,
12
20
  LayoutModule,
13
21
  LinkPrefetch,
14
22
  LoaderArgs,
15
23
  Metadata,
16
24
  MetadataArgs,
17
25
  NavigateOptions,
26
+ NotFoundBoundary,
18
27
  PageModule,
19
28
  ResolvedRoute,
29
+ RouteError,
20
30
  RouteInfo,
21
31
  RouteMatch,
22
32
  RouteParamSpec,
@@ -28,19 +38,25 @@ export type {
28
38
  } from "./internal/runtime.js";
29
39
 
30
40
  export {
41
+ ForbiddenError,
31
42
  Link,
32
43
  NotFoundError,
33
44
  RedirectError,
34
45
  RouteView,
35
46
  RouterProvider,
47
+ UnauthorizedError,
48
+ forbidden,
36
49
  matchRoute,
37
50
  notFound,
38
51
  parseSearch,
39
52
  permanentRedirect,
40
53
  redirect,
54
+ resolveFailure,
41
55
  resolveMatch,
56
+ routeErrorStatus,
42
57
  routerView,
43
58
  splitUrl,
59
+ unauthorized,
44
60
  useIsServer,
45
61
  useLoaderData,
46
62
  useRoute,
@@ -57,6 +73,12 @@ export type PageProps<
57
73
  readonly data: TData,
58
74
  |};
59
75
 
76
+ /** Props an `_uf.error.js` component receives. */
77
+ export type ErrorProps = {|
78
+ readonly error: RouteError,
79
+ readonly reset: () => void,
80
+ |};
81
+
60
82
  /** Props a layout receives. */
61
83
  export type LayoutProps<
62
84
  TParams extends { readonly [string]: string | $ReadOnlyArray<string> } = {},
@@ -99,6 +99,22 @@ export type LayoutModule = {
99
99
  ...
100
100
  };
101
101
 
102
+ /**
103
+ * What an error module may export. The component is `default` or `Error`.
104
+ *
105
+ * `Error` shadows the global inside the file that writes it, which is the
106
+ * cost of naming the export after what it is; a file that needs the
107
+ * constructor still has `globalThis.Error`. The alternative was a name the
108
+ * convention would have to explain — `ErrorPage`, `Boundary` — for a file
109
+ * whose whole job is already in its name.
110
+ */
111
+ export type ErrorModule = {
112
+ readonly default?: RouteComponent,
113
+ readonly Error?: RouteComponent,
114
+ readonly metadata?: Metadata,
115
+ ...
116
+ };
117
+
102
118
  /** Document metadata a page or layout declares. */
103
119
  export type Metadata = {
104
120
  readonly title?: string,
@@ -134,18 +150,48 @@ export type RouteRecord = {|
134
150
  readonly layouts: $ReadOnlyArray<() => Promise<LayoutModule>>,
135
151
  |};
136
152
 
137
- /** The not-found page, when the app declares one. */
138
- export type NotFoundRecord = {|
153
+ /**
154
+ * One not-found boundary: the page for a path under `path` that matched
155
+ * nothing.
156
+ *
157
+ * `_uf.not-found.js` is a segment file, so `path` is the route path of the
158
+ * directory that declares it and `layouts` are the layouts in scope *there* —
159
+ * which is what the boundary renders inside. A project with one at the router
160
+ * root has one of these; a project whose manual answers its own 404 has two.
161
+ */
162
+ export type NotFoundBoundary = {|
163
+ readonly path: string,
139
164
  readonly mdx: boolean,
140
165
  readonly file: string,
141
166
  readonly page: () => Promise<PageModule>,
142
167
  readonly layouts: $ReadOnlyArray<() => Promise<LayoutModule>>,
143
168
  |};
144
169
 
145
- /** A route table plus the not-found page. */
170
+ /**
171
+ * One error boundary: what renders in place of the subtree under `path` when
172
+ * something in it throws.
173
+ *
174
+ * The same nearest-ancestor shape as [`NotFoundBoundary`], and `layouts` means
175
+ * the same thing — the layouts in scope where the file is, which stay mounted
176
+ * around the error and are why the rest of the document is still there.
177
+ */
178
+ export type ErrorBoundary = {|
179
+ readonly path: string,
180
+ readonly file: string,
181
+ readonly module: () => Promise<ErrorModule>,
182
+ readonly layouts: $ReadOnlyArray<() => Promise<LayoutModule>>,
183
+ |};
184
+
185
+ /**
186
+ * A route table plus the boundaries declared under it.
187
+ *
188
+ * `errors` is the error boundaries a project declared, not failures that
189
+ * happened.
190
+ */
146
191
  export type RouteTable = {|
147
192
  readonly routes: $ReadOnlyArray<RouteRecord>,
148
- readonly notFound: ?NotFoundRecord,
193
+ readonly notFound: $ReadOnlyArray<NotFoundBoundary>,
194
+ readonly errors: $ReadOnlyArray<ErrorBoundary>,
149
195
  |};
150
196
 
151
197
  /** A URL matched against the table. */
@@ -154,7 +200,40 @@ export type RouteMatch = {|
154
200
  readonly params: RouteParams,
155
201
  |};
156
202
 
157
- /** A match whose modules are loaded and whose loader has run. */
203
+ /**
204
+ * Why the router is rendering an error boundary instead of a page.
205
+ *
206
+ * One union rather than one file convention per status. `forbidden()` and
207
+ * `unauthorized()` are not different *kinds* of file to write; they are
208
+ * different sentences an error page says, and `match` over this is where a
209
+ * page says all three and the checker confirms it covered them. Deciding it
210
+ * the other way — `_uf.forbidden.js` and `_uf.unauthorized.js` beside
211
+ * `_uf.error.js`, which is what Next.js does — is three files per segment to
212
+ * express one thing, and nothing would check that any of them handled the
213
+ * case it was named for.
214
+ *
215
+ * The thrown value is carried but deliberately not rendered by the default
216
+ * boundary: a server exception's message is written for the person who
217
+ * deployed the application, not for whoever asks for the page.
218
+ */
219
+ export type RouteError =
220
+ | {| readonly kind: "thrown", readonly error: mixed |}
221
+ | {| readonly kind: "unauthorized" |}
222
+ | {| readonly kind: "forbidden" |};
223
+
224
+ /** The status a `RouteError` answers with. */
225
+ export function routeErrorStatus(error: RouteError): 401 | 403 | 500 {
226
+ return match (error) {
227
+ {kind: "unauthorized"} => 401,
228
+ {kind: "forbidden"} => 403,
229
+ {kind: "thrown"} => 500,
230
+ };
231
+ }
232
+
233
+ /**
234
+ * A match whose modules are loaded and whose loader has run — or, when `error`
235
+ * is set, the error page that stands in for it.
236
+ */
158
237
  export type ResolvedRoute = {|
159
238
  readonly pathname: string,
160
239
  readonly search: string,
@@ -165,7 +244,26 @@ export type ResolvedRoute = {|
165
244
  readonly layouts: $ReadOnlyArray<LayoutModule>,
166
245
  readonly data: mixed,
167
246
  readonly metadata: Metadata,
168
- readonly status: 200 | 404,
247
+ readonly status: 200 | 401 | 403 | 404 | 500,
248
+ /**
249
+ * Set when this resolution *is* the error page: the loader threw, or the
250
+ * server render did and the renderer resolved again. `null` on the ordinary
251
+ * path.
252
+ */
253
+ readonly error: ?RouteError,
254
+ /**
255
+ * The boundary that would catch a throw while rendering this route.
256
+ *
257
+ * Always present, because every route has an answer for a throw: `module`
258
+ * is `null` when the project declares no `_uf.error.js` above the path, and
259
+ * the framework's own error page renders instead. `above` is how many of
260
+ * `layouts` are outside the boundary — the ones that stay mounted, which is
261
+ * what "the rest of the document is still interactive" means.
262
+ */
263
+ readonly errorBoundary: {|
264
+ readonly module: ?ErrorModule,
265
+ readonly above: number,
266
+ |},
169
267
  |};
170
268
 
171
269
  /** Thrown by `notFound()`; the renderer answers with the not-found page. */
@@ -176,6 +274,22 @@ export class NotFoundError extends Error {
176
274
  }
177
275
  }
178
276
 
277
+ /** Thrown by `unauthorized()`; the renderer answers with the error boundary. */
278
+ export class UnauthorizedError extends Error {
279
+ constructor() {
280
+ super("unauthorized");
281
+ this.name = "UnauthorizedError";
282
+ }
283
+ }
284
+
285
+ /** Thrown by `forbidden()`; the renderer answers with the error boundary. */
286
+ export class ForbiddenError extends Error {
287
+ constructor() {
288
+ super("forbidden");
289
+ this.name = "ForbiddenError";
290
+ }
291
+ }
292
+
179
293
  /** Thrown by `redirect()`; the renderer answers with a redirect. */
180
294
  export class RedirectError extends Error {
181
295
  to: string;
@@ -289,6 +403,60 @@ export function matchRoute(routes: $ReadOnlyArray<RouteRecord>, pathname: string
289
403
  return best;
290
404
  }
291
405
 
406
+ /**
407
+ * Whether a boundary declared at `segments` is at or above `parts`.
408
+ *
409
+ * The same segment kinds as [`matchSegments`], stopping when the boundary's
410
+ * own segments run out instead of requiring the path to: `/guide` covers
411
+ * `/guide/nope`, and `/guide` covers `/guide` itself.
412
+ */
413
+ function covers(segments: $ReadOnlyArray<Segment>, parts: $ReadOnlyArray<string>): boolean {
414
+ let index = 0;
415
+ for (const segment of segments) {
416
+ const next = match (segment) {
417
+ {kind: "static", value: const value} => parts[index] === value ? index + 1 : -1,
418
+ {kind: "param"} => index < parts.length ? index + 1 : -1,
419
+ {kind: "catchAll"} => parts.length,
420
+ };
421
+ if (next === -1) {
422
+ return false;
423
+ }
424
+ index = next;
425
+ }
426
+ return true;
427
+ }
428
+
429
+ /**
430
+ * The nearest boundary above `pathname`, or `null` when none covers it.
431
+ *
432
+ * The one rule both `_uf.not-found.js` and `_uf.error.js` are resolved by, and
433
+ * the same one layouts already follow: nearest means the longest path that
434
+ * covers the URL. It is decided here rather than by the table's order — the
435
+ * table is sorted by path so the generated module is stable, and a resolver
436
+ * that read "nearest" as "first" would silently depend on that sort. Two
437
+ * boundaries can share a path (a route group's directory does not appear in
438
+ * the URL), and then the first in the table wins.
439
+ */
440
+ function nearestBoundary<TBoundary: { readonly path: string, ... }>(
441
+ boundaries: $ReadOnlyArray<TBoundary>,
442
+ pathname: string,
443
+ ): ?TBoundary {
444
+ const parts = pathname.split("/").filter((part) => part !== "");
445
+ let best: ?TBoundary = null;
446
+ let bestDepth = -1;
447
+ for (const boundary of boundaries) {
448
+ const segments = compile(boundary.path);
449
+ if (!covers(segments, parts)) {
450
+ continue;
451
+ }
452
+ if (segments.length > bestDepth) {
453
+ best = boundary;
454
+ bestDepth = segments.length;
455
+ }
456
+ }
457
+ return best;
458
+ }
459
+
292
460
  /** Split a URL into its pathname and search string. */
293
461
  export function splitUrl(url: string): {| readonly pathname: string, readonly search: string |} {
294
462
  const hash = url.indexOf("#");
@@ -342,11 +510,39 @@ function loadOnce<T>(load: () => Promise<T>): Promise<T> {
342
510
  * `data` is what the loader returned; on the client after hydration it is the
343
511
  * value the server embedded, so the loader does not run twice for the first
344
512
  * page.
513
+ *
514
+ * # This resolves or redirects; it does not reject
515
+ *
516
+ * Everything a route can go wrong with is a route to render: no match and
517
+ * `notFound()` are the not-found boundary, a loader that threw and
518
+ * `forbidden()`/`unauthorized()` are the error boundary. Only `redirect()`
519
+ * comes back out, because a redirect is a response rather than a page and the
520
+ * caller is what has one to send.
521
+ *
522
+ * That guarantee is the point rather than a convenience. `hydrate` awaits this
523
+ * before `hydrateRoot`, so a rejection there is not an error page — it is no
524
+ * `hydrateRoot` call at all, and the document the server sent stays on screen
525
+ * with nothing attached to it.
345
526
  */
346
527
  export async function resolveMatch(
347
528
  table: RouteTable,
348
529
  url: string,
349
530
  options?: {| readonly data?: mixed, readonly skipLoader?: boolean |},
531
+ ): Promise<ResolvedRoute> {
532
+ try {
533
+ return await resolveRoute(table, url, options);
534
+ } catch (error) {
535
+ if (error instanceof RedirectError) {
536
+ throw error;
537
+ }
538
+ return resolveFailure(table, url, error);
539
+ }
540
+ }
541
+
542
+ async function resolveRoute(
543
+ table: RouteTable,
544
+ url: string,
545
+ options?: {| readonly data?: mixed, readonly skipLoader?: boolean |},
350
546
  ): Promise<ResolvedRoute> {
351
547
  const { pathname, search } = splitUrl(url);
352
548
  const searchParams = parseSearch(search);
@@ -360,17 +556,14 @@ export async function resolveMatch(
360
556
  loadOnce(matched.route.page),
361
557
  ...matched.route.layouts.map((layout) => loadOnce(layout)),
362
558
  ]);
559
+ // Started here and awaited at the end: the boundary's module does not depend
560
+ // on the loader, so importing it alongside costs a navigation nothing. It
561
+ // never rejects, so an early throw below leaves no unhandled rejection.
562
+ const boundary = resolveErrorBoundary(table, pathname, matched.route.layouts.length);
363
563
 
364
564
  let data: mixed = options?.data;
365
565
  if (options?.skipLoader !== true && typeof page.loader === "function") {
366
- try {
367
- data = await page.loader({ params: matched.params, searchParams, pathname });
368
- } catch (error) {
369
- if (error instanceof NotFoundError) {
370
- return resolveNotFound(table, pathname, search, searchParams);
371
- }
372
- throw error;
373
- }
566
+ data = await page.loader({ params: matched.params, searchParams, pathname });
374
567
  }
375
568
 
376
569
  const metadata = await resolveMetadata(page, layouts, {
@@ -389,16 +582,168 @@ export async function resolveMatch(
389
582
  data,
390
583
  metadata,
391
584
  status: 200,
585
+ error: null,
586
+ errorBoundary: await boundary,
587
+ };
588
+ }
589
+
590
+ /**
591
+ * The route to render after something threw.
592
+ *
593
+ * Two callers, one behaviour: [`resolveMatch`] when a loader or a module
594
+ * import threw, and `createRenderer` when the *render* did — React's error
595
+ * boundaries do not run in `renderToString`, so the server has to catch it
596
+ * itself and resolve again.
597
+ */
598
+ export async function resolveFailure(
599
+ table: RouteTable,
600
+ url: string,
601
+ error: mixed,
602
+ ): Promise<ResolvedRoute> {
603
+ const { pathname, search } = splitUrl(url);
604
+ const searchParams = parseSearch(search);
605
+ if (error instanceof NotFoundError) {
606
+ try {
607
+ return await resolveNotFound(table, pathname, search, searchParams);
608
+ } catch (failure) {
609
+ // The not-found page itself would not load. Falling through to the error
610
+ // boundary rather than rethrowing is what keeps the promise above: the
611
+ // page a project wrote to explain a 404 is not more load-bearing than
612
+ // the document staying on screen.
613
+ return resolveError(table, pathname, search, searchParams, routeErrorFor(failure));
614
+ }
615
+ }
616
+ return resolveError(table, pathname, search, searchParams, routeErrorFor(error));
617
+ }
618
+
619
+ /** What a thrown value means to the router. */
620
+ function routeErrorFor(error: mixed): RouteError {
621
+ if (error instanceof UnauthorizedError) {
622
+ return { kind: "unauthorized" };
623
+ }
624
+ if (error instanceof ForbiddenError) {
625
+ return { kind: "forbidden" };
626
+ }
627
+ return { kind: "thrown", error };
628
+ }
629
+
630
+ /**
631
+ * The error boundary a route renders inside, loaded with the route rather than
632
+ * when it is needed.
633
+ *
634
+ * React decides to show a boundary's fallback synchronously, during the render
635
+ * that threw. A module that still has to be imported is a module that is not
636
+ * there at the only moment it can be used, so this is one more dynamic import
637
+ * per navigation and not a lazy one.
638
+ *
639
+ * `above` is the boundary's own layout count, clamped to the route's. The
640
+ * first attempt compared the two layout arrays for a shared prefix, which is
641
+ * more precise when a `(group)` directory puts a boundary beside a route
642
+ * rather than above it — and it worked by *reference identity* of the loader
643
+ * functions, which holds only because `routesModuleSource` deduplicates them
644
+ * by file. A rule that depends on an invisible property of the generated
645
+ * module is a rule that reads as zero the moment a table is built any other
646
+ * way, and it did: it put the boundary outside the layouts it was written
647
+ * inside. Nesting a boundary per group needs parallel-route trees (#267);
648
+ * until then this is the honest approximation, and it is stated rather than
649
+ * inferred.
650
+ */
651
+ async function resolveErrorBoundary(
652
+ table: RouteTable,
653
+ pathname: string,
654
+ layoutCount: number,
655
+ ): Promise<{| readonly module: ?ErrorModule, readonly above: number |}> {
656
+ const boundary = nearestBoundary(table.errors, pathname);
657
+ if (boundary == null) {
658
+ return { module: null, above: 0 };
659
+ }
660
+ // Clamped, because a route group can leave a route with fewer layouts than
661
+ // the boundary covering it, and an `above` past the end would compose the
662
+ // layouts out of nothing.
663
+ const above = Math.min(boundary.layouts.length, layoutCount);
664
+ try {
665
+ return { module: await loadOnce(boundary.module), above };
666
+ } catch {
667
+ // A boundary whose module will not load cannot be the answer to a throw,
668
+ // and this is why the field is nullable: containment must not itself
669
+ // depend on an import working.
670
+ return { module: null, above: 0 };
671
+ }
672
+ }
673
+
674
+ /**
675
+ * The error page for `pathname`, inside the layouts above the boundary that
676
+ * answers it.
677
+ *
678
+ * The layouts are the boundary's, for the same reason [`resolveNotFound`]
679
+ * gives: they are what stays mounted around the error, and the layouts below
680
+ * the boundary belong to the subtree that just stopped.
681
+ */
682
+ async function resolveError(
683
+ table: RouteTable,
684
+ pathname: string,
685
+ search: string,
686
+ searchParams: SearchParams,
687
+ routeError: RouteError,
688
+ ): Promise<ResolvedRoute> {
689
+ const boundary = nearestBoundary(table.errors, pathname);
690
+ let module: ?ErrorModule = null;
691
+ let layouts: $ReadOnlyArray<LayoutModule> = [];
692
+ if (boundary != null) {
693
+ try {
694
+ [module, layouts] = await Promise.all([
695
+ loadOnce(boundary.module),
696
+ Promise.all(boundary.layouts.map((layout) => loadOnce(layout))),
697
+ ]);
698
+ } catch {
699
+ // See `resolveErrorBoundary`: the framework's own page answers instead.
700
+ module = null;
701
+ layouts = [];
702
+ }
703
+ }
704
+
705
+ const declared = await resolveMetadata(
706
+ module?.metadata != null ? { metadata: module.metadata } : {},
707
+ layouts,
708
+ { params: {}, searchParams, data: undefined },
709
+ );
710
+ return {
711
+ pathname,
712
+ search,
713
+ path: "*",
714
+ params: {},
715
+ searchParams,
716
+ page: { default: ResolvedErrorPage },
717
+ layouts,
718
+ data: undefined,
719
+ metadata: declared.title != null ? declared : { ...declared, title: errorTitle(routeError) },
720
+ status: routeErrorStatus(routeError),
721
+ error: routeError,
722
+ // All of the boundary's layouts are above it, and no inner boundary is
723
+ // inserted around a page that already is one; see `RouteView`.
724
+ errorBoundary: { module, above: layouts.length },
392
725
  };
393
726
  }
394
727
 
728
+ /**
729
+ * The not-found page for `pathname`, inside the layouts above the boundary
730
+ * that answers it.
731
+ *
732
+ * The layouts are the *boundary's*, not the ones the URL had already matched.
733
+ * Taking the matched route's layouts was the other candidate and it is wrong
734
+ * in both directions: for an unmatched URL there is no matched route to take
735
+ * them from, and for `notFound()` thrown from a page they would keep the
736
+ * layouts *below* the boundary — so `app/guide/[slug]/_uf.layout.js` would
737
+ * wrap a 404 that `app/guide/_uf.not-found.js` answered, which is the layout
738
+ * of the page that just said it does not exist.
739
+ */
395
740
  async function resolveNotFound(
396
741
  table: RouteTable,
397
742
  pathname: string,
398
743
  search: string,
399
744
  searchParams: SearchParams,
400
745
  ): Promise<ResolvedRoute> {
401
- const record = table.notFound;
746
+ const record = nearestBoundary(table.notFound, pathname);
402
747
  if (record == null) {
403
748
  return {
404
749
  pathname,
@@ -411,6 +756,8 @@ async function resolveNotFound(
411
756
  data: undefined,
412
757
  metadata: { title: "Not found" },
413
758
  status: 404,
759
+ error: null,
760
+ errorBoundary: await resolveErrorBoundary(table, pathname, 0),
414
761
  };
415
762
  }
416
763
  const [page, ...layouts] = await Promise.all([
@@ -433,6 +780,9 @@ async function resolveNotFound(
433
780
  data: undefined,
434
781
  metadata,
435
782
  status: 404,
783
+ error: null,
784
+ // A not-found page is a page: one that throws is contained like any other.
785
+ errorBoundary: await resolveErrorBoundary(table, pathname, layouts.length),
436
786
  };
437
787
  }
438
788
 
@@ -474,6 +824,157 @@ component DefaultNotFound() {
474
824
  );
475
825
  }
476
826
 
827
+ /** The document title an error page gets when nothing declared one. */
828
+ function errorTitle(error: RouteError): string {
829
+ return match (error) {
830
+ {kind: "unauthorized"} => "Sign in required",
831
+ {kind: "forbidden"} => "Not allowed",
832
+ {kind: "thrown"} => "Something went wrong",
833
+ };
834
+ }
835
+
836
+ /**
837
+ * The framework's error page, for a project that declares no `_uf.error.js`.
838
+ *
839
+ * It says which of the three happened and offers the reset, and it does *not*
840
+ * print the thrown error: on the server that message is written for whoever
841
+ * deployed the application — a query, a path, a token in a stack — and this
842
+ * markup is sent to whoever asked for the page. `uf dev` reports the throw in
843
+ * the terminal and `uf build` fails the route, which are the places the person
844
+ * who can act on it is looking.
845
+ */
846
+ component DefaultRouteError(error: RouteError, reset: () => void) {
847
+ const title = errorTitle(error);
848
+ const detail = match (error) {
849
+ {kind: "unauthorized"} => "This page needs you to be signed in.",
850
+ {kind: "forbidden"} => "You do not have access to this page.",
851
+ {kind: "thrown"} => "This page could not be rendered.",
852
+ };
853
+ return (
854
+ <main>
855
+ <title>{title}</title>
856
+ <h1>{title}</h1>
857
+ <p>{detail}</p>
858
+ <button type="button" onClick={reset}>
859
+ Try again
860
+ </button>
861
+ </main>
862
+ );
863
+ }
864
+
865
+ /** The component an error module renders: `default`, or the named `Error`. */
866
+ function errorComponent(module: ErrorModule): React.ComponentType<ErrorRenderProps> {
867
+ const component = module.default ?? module.Error;
868
+ if (component == null) {
869
+ throw new Error(
870
+ "@uniflowed/router: an error module must export a component as `default` or `Error`",
871
+ );
872
+ }
873
+ return renderable(component);
874
+ }
875
+
876
+ /** The props an error boundary's component receives. */
877
+ type ErrorRenderProps = {|
878
+ readonly error: RouteError,
879
+ readonly reset: () => void,
880
+ |};
881
+
882
+ /**
883
+ * The error UI, from whichever module is in scope.
884
+ *
885
+ * One component for both ways in — the class boundary below, which catches a
886
+ * throw while the browser renders, and `ResolvedErrorPage`, which is what the
887
+ * server renders because React's boundaries do not run in `renderToString`.
888
+ * Two paths to the same screen is exactly the pair that drifts.
889
+ */
890
+ component RouteErrorView(module: ?ErrorModule, error: RouteError, reset: () => void) {
891
+ if (module == null) {
892
+ return <DefaultRouteError error={error} reset={reset} />;
893
+ }
894
+ const Boundary = errorComponent(module);
895
+ return <Boundary error={error} reset={reset} />;
896
+ }
897
+
898
+ /**
899
+ * The page of a route that resolved to an error.
900
+ *
901
+ * A resolved error route carries the error and the module on the route itself,
902
+ * so this is a static component rather than a closure the resolver builds:
903
+ * `RouteView` composes it in its layouts exactly like a page, which is what
904
+ * makes "inside the layouts above the boundary" one code path and not two.
905
+ *
906
+ * `reset()` here is `router.refresh()` — this route resolved to an error
907
+ * because a loader or an import threw, so re-running the resolution is what
908
+ * trying again means. On the server `refresh` does nothing, which is correct:
909
+ * a static render has nothing to re-run.
910
+ */
911
+ component ResolvedErrorPage() {
912
+ const { resolved, router } = useRouterState();
913
+ const reset = useCallback(() => {
914
+ router.refresh().catch(() => {});
915
+ }, [router]);
916
+
917
+ if (resolved.error == null) {
918
+ // Unreachable: this module is only ever the page of a resolved error route.
919
+ return null;
920
+ }
921
+ return (
922
+ <RouteErrorView module={resolved.errorBoundary.module} error={resolved.error} reset={reset} />
923
+ );
924
+ }
925
+
926
+ type RouteErrorBoundaryProps = {|
927
+ readonly module: ?ErrorModule,
928
+ readonly resetKey: string,
929
+ readonly children: React.Node,
930
+ |};
931
+
932
+ type RouteErrorBoundaryState = {| readonly error: ?RouteError |};
933
+
934
+ /**
935
+ * The boundary that catches a throw while the browser renders the subtree.
936
+ *
937
+ * A class, because `getDerivedStateFromError` is React's contract for this and
938
+ * there is no hook that does it — this is the one place in the router where
939
+ * following React's public contract means not using a function component.
940
+ *
941
+ * Recovering on navigation is `componentDidUpdate` watching `resetKey`, not
942
+ * `key={pathname}` on the boundary. Keying it remounts the subtree on *every*
943
+ * navigation, error or not, and everything below the boundary goes with it —
944
+ * which is the layouts, whose whole purpose is to survive navigation with
945
+ * their scroll position and their open sections intact.
946
+ */
947
+ class RouteErrorBoundary extends React.Component<RouteErrorBoundaryProps, RouteErrorBoundaryState> {
948
+ constructor(props: RouteErrorBoundaryProps) {
949
+ super(props);
950
+ this.state = { error: null };
951
+ }
952
+
953
+ static getDerivedStateFromError(error: mixed): RouteErrorBoundaryState {
954
+ return { error: routeErrorFor(error) };
955
+ }
956
+
957
+ componentDidUpdate(previous: RouteErrorBoundaryProps) {
958
+ if (this.state.error != null && previous.resetKey !== this.props.resetKey) {
959
+ this.setState({ error: null });
960
+ }
961
+ }
962
+
963
+ render(): React.Node {
964
+ const { error } = this.state;
965
+ if (error == null) {
966
+ return this.props.children;
967
+ }
968
+ return (
969
+ <RouteErrorView
970
+ module={this.props.module}
971
+ error={error}
972
+ reset={() => this.setState({ error: null })}
973
+ />
974
+ );
975
+ }
976
+ }
977
+
477
978
  // ---------------------------------------------------------------------------
478
979
  // The React binding
479
980
  // ---------------------------------------------------------------------------
@@ -705,21 +1206,49 @@ export hook useLoaderData(): mixed {
705
1206
  /**
706
1207
  * Renders the matched page inside its layouts, innermost last, with the
707
1208
  * document metadata as hoistable head elements.
1209
+ *
1210
+ * # Where the error boundaries go
1211
+ *
1212
+ * Two, and they are not the same thing twice. The inner one is the project's
1213
+ * `_uf.error.js`, placed at the depth the file sits at, so the layouts above
1214
+ * it stay mounted and interactive while the subtree below is replaced — that
1215
+ * placement *is* the feature. The outer one has no module and so renders the
1216
+ * framework's page; it is what stands between a throw in a root layout, or in
1217
+ * the error component itself, and an unmounted document. A single boundary
1218
+ * cannot be both: put it outside and a page's throw takes the navigation down
1219
+ * with it; put it inside and nothing catches the layout above.
708
1220
  */
709
1221
  export component RouteView() {
710
1222
  const { resolved } = useRouterState();
1223
+ const { module, above } = resolved.errorBoundary;
711
1224
  const Page = pageComponent(resolved.page);
712
1225
  let element: React.Node = (
713
1226
  <Page params={resolved.params} searchParams={resolved.searchParams} data={resolved.data} />
714
1227
  );
715
- for (let index = resolved.layouts.length - 1; index >= 0; index -= 1) {
1228
+ for (let index = resolved.layouts.length - 1; index >= above; index -= 1) {
1229
+ const Layout = layoutComponent(resolved.layouts[index]);
1230
+ element = <Layout params={resolved.params}>{element}</Layout>;
1231
+ }
1232
+ // Not around a route that already resolved to its error page: that page is
1233
+ // the boundary's own component, and wrapping it in the same boundary would
1234
+ // answer a throw inside it with itself.
1235
+ if (module != null && resolved.error == null) {
1236
+ element = (
1237
+ <RouteErrorBoundary module={module} resetKey={resolved.pathname}>
1238
+ {element}
1239
+ </RouteErrorBoundary>
1240
+ );
1241
+ }
1242
+ for (let index = above - 1; index >= 0; index -= 1) {
716
1243
  const Layout = layoutComponent(resolved.layouts[index]);
717
1244
  element = <Layout params={resolved.params}>{element}</Layout>;
718
1245
  }
719
1246
  return (
720
1247
  <>
721
1248
  <Head metadata={resolved.metadata} />
722
- {element}
1249
+ <RouteErrorBoundary module={null} resetKey={resolved.pathname}>
1250
+ {element}
1251
+ </RouteErrorBoundary>
723
1252
  </>
724
1253
  );
725
1254
  }
@@ -889,6 +1418,16 @@ export function notFound(): empty {
889
1418
  throw new NotFoundError();
890
1419
  }
891
1420
 
1421
+ /** Stop rendering the current page and show the error boundary, as a 401. */
1422
+ export function unauthorized(): empty {
1423
+ throw new UnauthorizedError();
1424
+ }
1425
+
1426
+ /** Stop rendering the current page and show the error boundary, as a 403. */
1427
+ export function forbidden(): empty {
1428
+ throw new ForbiddenError();
1429
+ }
1430
+
892
1431
  /** Stop rendering the current page and send the visitor elsewhere. */
893
1432
  export function redirect(to: string): empty {
894
1433
  throw new RedirectError(to, false);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/router",
3
- "version": "0.0.0-alpha.6",
3
+ "version": "0.0.0-alpha.7",
4
4
  "description": "The file-system router for Flow React applications: matching, layouts, loaders, navigation, server rendering and hydration.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -29,6 +29,6 @@
29
29
  "react-dom": ">=19"
30
30
  },
31
31
  "dependencies": {
32
- "@uniflowed/server": "0.0.0-alpha.6"
32
+ "@uniflowed/server": "0.0.0-alpha.7"
33
33
  }
34
34
  }
package/server.js CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  type RouteTable,
18
18
  RedirectError,
19
19
  installRoutes,
20
+ resolveFailure,
20
21
  resolveMatch,
21
22
  } from "./internal/runtime.js";
22
23
 
@@ -32,6 +33,19 @@ export type RenderResult = {|
32
33
  readonly status: number,
33
34
  readonly html: string,
34
35
  readonly headers?: { readonly [string]: string },
36
+ /**
37
+ * The exception this render fell back to its error boundary for.
38
+ *
39
+ * The document is still a document — the boundary rendered — and this is how
40
+ * the caller learns that it is an error page rather than the page it asked
41
+ * for. `uf build` fails the route it names; `uf dev` reports it in the
42
+ * terminal. Without it, containment would mean a build that quietly wrote a
43
+ * directory of error pages and exited 0.
44
+ *
45
+ * `forbidden()` and `unauthorized()` do not set it: those are answers an
46
+ * application chose, and a build that prerendered one has not failed.
47
+ */
48
+ readonly error?: mixed,
35
49
  |};
36
50
 
37
51
  /**
@@ -46,8 +60,13 @@ export function createRenderer(options: {|
46
60
  readonly App: React.ComponentType<AppProps>,
47
61
  readonly routes: RouteTable["routes"],
48
62
  readonly notFound: RouteTable["notFound"],
63
+ readonly errors: RouteTable["errors"],
49
64
  |}): (url: string, assets: RenderAssets) => Promise<RenderResult> {
50
- const table: RouteTable = { routes: options.routes, notFound: options.notFound };
65
+ const table: RouteTable = {
66
+ routes: options.routes,
67
+ notFound: options.notFound,
68
+ errors: options.errors,
69
+ };
51
70
  installRoutes(table);
52
71
  const { App } = options;
53
72
 
@@ -56,15 +75,49 @@ export function createRenderer(options: {|
56
75
  try {
57
76
  resolved = await resolveMatch(table, url);
58
77
  } catch (error) {
78
+ // A redirect is the only thing `resolveMatch` lets out, because a
79
+ // redirect is a response rather than a page.
59
80
  if (error instanceof RedirectError) {
60
81
  return redirectDocument(error);
61
82
  }
62
83
  throw error;
63
84
  }
64
85
 
65
- const markup = renderToString(<App url={url} initial={resolved} />);
86
+ let markup: string;
87
+ try {
88
+ markup = renderToString(<App url={url} initial={resolved} />);
89
+ } catch (error) {
90
+ // The server's half of the error boundary. React does not run class
91
+ // boundaries in `renderToString` — Fizz has no `getDerivedStateFromError`
92
+ // step outside a Suspense boundary — so `RouteView`'s boundary is the
93
+ // browser's containment and this is the server's. Without it one
94
+ // component that throws is the whole response, and during `uf build` the
95
+ // whole build. See ubugeeei-prod/uf#257.
96
+ if (error instanceof RedirectError) {
97
+ return redirectDocument(error);
98
+ }
99
+ resolved = await resolveFailure(table, url, error);
100
+ // Deliberately not caught again: this render is the boundary's own
101
+ // component, and a boundary that throws has nothing left to answer with.
102
+ // It reaches `uf dev`'s overlay and fails `uf build`'s route, which is
103
+ // where somebody can fix it.
104
+ markup = renderToString(<App url={url} initial={resolved} />);
105
+ }
106
+
66
107
  const html = assemble(markup, resolved, assets);
67
- return { status: resolved.status, html };
108
+ return { status: resolved.status, html, error: renderFailure(resolved) };
109
+ };
110
+ }
111
+
112
+ /** The exception a resolved route fell back to its error boundary for. */
113
+ function renderFailure(resolved: ResolvedRoute): mixed {
114
+ if (resolved.error == null) {
115
+ return undefined;
116
+ }
117
+ return match (resolved.error) {
118
+ {kind: "thrown", error: const error} => error,
119
+ {kind: "unauthorized"} => undefined,
120
+ {kind: "forbidden"} => undefined,
68
121
  };
69
122
  }
70
123