@uniflowed/router 0.0.0-alpha.34 → 0.0.0-alpha.37

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.
@@ -0,0 +1,478 @@
1
+ // @flow
2
+ //
3
+ // Internal to `@uniflowed/router`: one walk from a resolved route to the tree
4
+ // that renders it.
5
+ //
6
+ // The layouts, the `$loading.js` fallbacks, the error boundaries, the
7
+ // templates and the parallel-route slots all have to be threaded into one stack
8
+ // at the depth each was declared at, and there must be exactly one place that
9
+ // does it. There used to be exactly one — `RouteView` — and it read the route
10
+ // from the router's context, which is a place a server composing a tree for
11
+ // React Server Components does not have. So the walk is a function of the
12
+ // resolved route and the page element, `RouteView` calls it, and so can the
13
+ // Flight renderer (ubugeeei-prod/uf#519): two callers of one composition rather
14
+ // than two compositions that agree until one of them moves.
15
+ //
16
+ // Nothing here reads a context or calls a hook. The components it places — a
17
+ // route's own modules, the error boundary, the dev-only boundary marks — are
18
+ // referenced, not rendered, so what they need is decided where they render.
19
+
20
+ import * as React from "react";
21
+ import { Suspense } from "react";
22
+
23
+ import { ROOT_ERROR_ID, ROUTE_ERROR_ID, suspenseId } from "./boundary-data.js";
24
+ import type { RouteBoundary } from "./boundary-data.js";
25
+ import { BoundaryEdge } from "./boundaries.js";
26
+ import { RouteErrorBoundary } from "./error-view.js";
27
+ import { Head } from "./head.js";
28
+ import type { RouteParams, SearchParams } from "./routing.js";
29
+ import type {
30
+ LayoutModule,
31
+ LayoutRenderProps,
32
+ LoadingModule,
33
+ PageModule,
34
+ PageRenderProps,
35
+ ResolvedRoute,
36
+ ResolvedSlot,
37
+ ResolvedTemplate,
38
+ TemplateModule,
39
+ } from "./resolve.js";
40
+ import { renderable } from "./resolve.js";
41
+
42
+ /** What a caller hands [`composeRoute`] beside the resolved route. */
43
+ export type ComposeOptions = {|
44
+ /**
45
+ * The innermost element: the page, and whatever the caller renders beside it.
46
+ *
47
+ * The caller's rather than this module's, because it is the one element that
48
+ * depends on where the route is being rendered — the browser reads the
49
+ * loader's answer out of its router, and a server has it in hand.
50
+ */
51
+ readonly page: React.Node,
52
+ /**
53
+ * The boundary marks `uf dev` draws, keyed by boundary id, or `null` for none.
54
+ *
55
+ * Read only behind [`BOUNDARY_MARKS`], so a build folds every use away.
56
+ */
57
+ readonly marks: ?Map<string, RouteBoundary>,
58
+ |};
59
+
60
+ /** The route a slot renders inside, as much of it as a slot reads. */
61
+ type SlotRoute = { +pathname: string, +searchParams: SearchParams, ... };
62
+
63
+ /**
64
+ * Whether this bundle marks the boundaries it renders.
65
+ *
66
+ * The same gate, spelled the same way, as `BOUNDARY_MARKS` in `./runtime.js`,
67
+ * which has the argument: `import.meta.hot` is defined while Vite serves and
68
+ * replaced with `undefined` in a build, so every branch it guards here is
69
+ * statically dead in a production bundle and `./boundaries.js` is dropped
70
+ * rather than shipped unused.
71
+ */
72
+ const BOUNDARY_MARKS: boolean = import.meta.hot != null;
73
+
74
+ /**
75
+ * The matched page inside its layouts, innermost last, with the document
76
+ * metadata as hoistable head elements.
77
+ *
78
+ * A function of a resolved route and the page element, and of nothing a render
79
+ * holds: no context is read here, so the same walk builds the tree the browser
80
+ * renders for a single-page application and the tree a server hands to React's
81
+ * Flight renderer. `RouteView` in `./runtime.js` is the browser's caller, and it
82
+ * decides the page element because that is the half that reads the router.
83
+ *
84
+ * # One walk down the layouts, not three
85
+ *
86
+ * The layouts, the error boundary and the `<Suspense>` boundaries all have to
87
+ * be threaded into the same stack at the depth each was declared at, so this
88
+ * is one descending loop over that depth rather than a pass per kind. `depth`
89
+ * counts the layouts still *outside* the element built so far, which is what
90
+ * `above` means on both a route's `errorBoundary` and each of its `loading`
91
+ * entries — one number, one meaning, one place it is compared.
92
+ *
93
+ * # Where the error boundaries go
94
+ *
95
+ * Two, and they are not the same thing twice. The inner one is the project's
96
+ * `$error.js`, placed at the depth the file sits at, so the layouts above
97
+ * it stay mounted and interactive while the subtree below is replaced — that
98
+ * placement *is* the feature. The outer one has no module and so renders the
99
+ * framework's page; it is what stands between a throw in a root layout, or in
100
+ * the error component itself, and an unmounted document. A single boundary
101
+ * cannot be both: put it outside and a page's throw takes the navigation down
102
+ * with it; put it inside and nothing catches the layout above.
103
+ *
104
+ * # Where the loading boundaries go
105
+ *
106
+ * Inside the layout of the segment that declared the file and outside
107
+ * everything under it, which is what makes the shell arrive first: a renderer
108
+ * streaming this tree can send every layout down to the boundary, and the
109
+ * fallback, before whatever the page is waiting for has resolved. A segment
110
+ * with no `$loading.js` contributes no boundary at all — it is not wrapped
111
+ * in a `<Suspense fallback={null}>` on the way past — so a project that
112
+ * declares none renders the tree it rendered before this existed, and a page
113
+ * that suspends without a boundary above it still fails the way React says it
114
+ * should rather than silently rendering nothing.
115
+ *
116
+ * The error boundary goes *outside* the fallback at the same depth. A throw
117
+ * while the page is resolving has to reach a boundary that is still mounted,
118
+ * and the `<Suspense>` is part of what the throw came out of.
119
+ *
120
+ * # Where the templates go
121
+ *
122
+ * Inside their own segment's layout and outside everything else at that depth
123
+ * — the error boundary, the fallback and the page — which is what makes a
124
+ * template's remount mean "this segment and what is under it" and a layout's
125
+ * persistence mean "this segment's frame". The two files are the same wrapper
126
+ * with opposite answers to one question, so they are one line apart here, and
127
+ * the whole of the difference is the `key` — see [`insideTemplates`], which is
128
+ * that line's other half.
129
+ *
130
+ * # Where the boundary marks go
131
+ *
132
+ * Inside each boundary and around nothing else, under `uf dev` only. A
133
+ * `<Suspense>` and a class boundary each render no element of their own, so the
134
+ * run of nodes one owns is indistinguishable on the page from the layout's own
135
+ * nodes beside it — the marks are what distinguish it, and this loop is the
136
+ * only place that knows which boundary is which. `./boundaries.js` has the
137
+ * mechanism and the argument; every reference to it here is inside a
138
+ * [`BOUNDARY_MARKS`] branch, so a build has none of it. See
139
+ * ubugeeei-prod/uf#520.
140
+ */
141
+ export function composeRoute(resolved: ResolvedRoute, options: ComposeOptions): React.Node {
142
+ const { module, above } = resolved.errorBoundary;
143
+ const { marks } = options;
144
+ // The innermost element, so a page that suspends — on its loader or on
145
+ // anything else — suspends below every boundary the loop below adds, which is
146
+ // what makes the layouts and the fallback the shell rather than something
147
+ // waiting behind the page.
148
+ let element: React.Node = options.page;
149
+
150
+ for (let depth = resolved.layouts.length; depth >= 0; depth -= 1) {
151
+ // Backwards over a root-first list, so the deepest segment's fallback ends
152
+ // up closest to the page. Two segments land on the same depth whenever the
153
+ // inner one declares no layout of its own, and then this order is the only
154
+ // thing that keeps them nested the way the directories are.
155
+ for (let index = resolved.loading.length - 1; index >= 0; index -= 1) {
156
+ const boundary = resolved.loading[index];
157
+ if (boundary.above !== depth) {
158
+ continue;
159
+ }
160
+ const Fallback = loadingComponent(boundary.module);
161
+ element = (
162
+ <Suspense fallback={<Fallback />}>
163
+ {BOUNDARY_MARKS ? insideBoundary(marks?.get(suspenseId(index)), element) : element}
164
+ </Suspense>
165
+ );
166
+ }
167
+ // Placed on `above` alone, and not on there being a module: a `null` one is
168
+ // the framework's own error page, and where it renders is exactly the
169
+ // question ubugeeei-prod/uf#351 asks. A project that declares no
170
+ // `$error.js` has the record the build synthesises for the router root,
171
+ // whose `above` is the root's layouts — so the framework's page appears
172
+ // inside the masthead rather than in place of the document. A table with no
173
+ // record at all answers 0, which puts this boundary outside every layout,
174
+ // where the outer one below already stood.
175
+ //
176
+ // Not around a route that already resolved to its error page: that page is
177
+ // the boundary's own component, and wrapping it in the same boundary would
178
+ // answer a throw inside it with itself.
179
+ if (depth === above && resolved.error == null) {
180
+ element = (
181
+ <RouteErrorBoundary module={module} resetKey={resolved.pathname}>
182
+ {BOUNDARY_MARKS ? insideBoundary(marks?.get(ROUTE_ERROR_ID), element) : element}
183
+ </RouteErrorBoundary>
184
+ );
185
+ }
186
+ element = insideTemplates(element, resolved, depth);
187
+ if (depth > 0) {
188
+ const Layout = layoutComponent(resolved.layouts[depth - 1]);
189
+ // The slots declared on this layout's own segment, beside `children`.
190
+ // Spread rather than passed as one `slots` object, because a slot is a
191
+ // prop a layout declares by name — `component Dashboard(children, team)`
192
+ // — and a bag would make every layout destructure a map to find out
193
+ // whether the router had anything for it.
194
+ // The spread first and `params` after it, so that a slot named after a
195
+ // prop the layout already has loses rather than wins. `@params` and
196
+ // `@children` are refused by the scan, and this is the second line of
197
+ // that defence for a table written by hand: losing a slot is a hole in
198
+ // the page, and overwriting `params` is every route in the segment
199
+ // rendering against the wrong parameters.
200
+ element = (
201
+ <Layout {...slotsAt(resolved.slots, depth, resolved)} params={resolved.params}>
202
+ {element}
203
+ </Layout>
204
+ );
205
+ }
206
+ }
207
+ if (needsRootStreamFrame(resolved)) {
208
+ element = <RootStreamFrame>{element}</RootStreamFrame>;
209
+ }
210
+ return (
211
+ <>
212
+ <Head metadata={resolved.metadata} />
213
+ <RouteErrorBoundary module={null} resetKey={resolved.pathname}>
214
+ {BOUNDARY_MARKS ? insideBoundary(marks?.get(ROOT_ERROR_ID), element) : element}
215
+ </RouteErrorBoundary>
216
+ </>
217
+ );
218
+ }
219
+
220
+ /**
221
+ * `children`, between the two marks of `boundary`.
222
+ *
223
+ * `children` unchanged when there is no boundary to mark, so a caller never has
224
+ * to ask twice. The marks are the first and last children of a fragment rather
225
+ * than a wrapper's, so the nodes between them are siblings of them, and every
226
+ * position in the fragment is fixed — an edge going from `null` to a hidden
227
+ * mark after mount is an insertion beside `children` and not around it,
228
+ * which is why it costs no remount.
229
+ *
230
+ * Here rather than beside the marks in `./boundaries.js`, because this is a
231
+ * factory and that is a client module: a server composing a tree for React
232
+ * Server Components calls this, and the edges it places stay references to
233
+ * the module that renders them.
234
+ */
235
+ export function insideBoundary(boundary: ?RouteBoundary, children: React.Node): React.Node {
236
+ if (boundary == null) {
237
+ return children;
238
+ }
239
+ return (
240
+ <>
241
+ <BoundaryEdge boundary={boundary} edge="open" />
242
+ {children}
243
+ <BoundaryEdge boundary={boundary} edge="close" />
244
+ </>
245
+ );
246
+ }
247
+
248
+ /**
249
+ * Whether the outermost route fallback needs one host element above it.
250
+ *
251
+ * React can flush a shell whose suspended boundary is inside any host element,
252
+ * but not one whose boundary is a direct child of the render root. A route with
253
+ * no layout and a root `$loading.js` is exactly that second tree: every
254
+ * framework component above it renders no element, so the fallback waits for
255
+ * the page it was meant to stand in for. A root layout is already the element
256
+ * that can carry it, and deeper fallbacks sit inside a layout by construction.
257
+ */
258
+ function needsRootStreamFrame(resolved: ResolvedRoute): boolean {
259
+ return resolved.layouts.length === 0 && resolved.loading.some((boundary) => boundary.above === 0);
260
+ }
261
+
262
+ component RootStreamFrame(children: React.Node) {
263
+ return (
264
+ <div data-uf-stream-root="" style={{ display: "contents" }}>
265
+ {children}
266
+ </div>
267
+ );
268
+ }
269
+
270
+ /**
271
+ * The component a page module renders: its default export, or the named
272
+ * `Page` that `uf create` scaffolds. An MDX page always has a default export.
273
+ */
274
+ export function pageComponent(module: PageModule): React.ComponentType<PageRenderProps> {
275
+ const component = module.default ?? module.Page;
276
+ if (component == null) {
277
+ throw new Error(
278
+ "@uniflowed/router: a page module must export a component as `default` or `Page`",
279
+ );
280
+ }
281
+ return renderable(component);
282
+ }
283
+
284
+ /**
285
+ * The component a loading module renders: `default`, or the named `Loading`.
286
+ *
287
+ * No props, unlike a page or a layout. A fallback is what the router shows
288
+ * when it does not have the route's answer yet, so there is nothing it could
289
+ * be handed that would be true — not `data`, which is the thing being waited
290
+ * for, and not `children`, because it renders instead of them.
291
+ */
292
+ export function loadingComponent(module: LoadingModule): React.ComponentType<{||}> {
293
+ const component = module.default ?? module.Loading;
294
+ if (component == null) {
295
+ throw new Error(
296
+ "@uniflowed/router: a loading module must export a component as `default` or `Loading`",
297
+ );
298
+ }
299
+ return renderable(component);
300
+ }
301
+
302
+ /**
303
+ * `element`, wrapped in every template declared at `depth`.
304
+ *
305
+ * Outside the boundaries at that depth and inside the layout below it, and
306
+ * backwards over a root-first list for the reason the fallbacks are: two
307
+ * segments share a depth whenever the inner one declares no layout, and this
308
+ * order is what keeps them nested the way the directories are.
309
+ *
310
+ * A function beside `RouteView` rather than a third loop inside it, and that
311
+ * is not only for reading: a third nested loop assigning to `element` is what
312
+ * the React Compiler's aliasing inference gave up on, and a component it
313
+ * cannot compile is a component it does not memoise.
314
+ */
315
+ export function insideTemplates(
316
+ element: React.Node,
317
+ resolved: {
318
+ readonly pathname: string,
319
+ readonly params: RouteParams,
320
+ readonly templates: $ReadOnlyArray<ResolvedTemplate>,
321
+ ...
322
+ },
323
+ depth: number,
324
+ ): React.Node {
325
+ let out = element;
326
+ for (let index = resolved.templates.length - 1; index >= 0; index -= 1) {
327
+ const entry = resolved.templates[index];
328
+ if (entry.above !== depth) {
329
+ continue;
330
+ }
331
+ const Template = templateComponent(entry.module);
332
+ // Keyed on the pathname, which is the whole difference between this file
333
+ // and `$layout.js`: React throws the subtree away and builds it again
334
+ // whenever the key changes, and a navigation that changes only the query
335
+ // string leaves it alone.
336
+ out = (
337
+ <Template key={resolved.pathname} params={resolved.params}>
338
+ {out}
339
+ </Template>
340
+ );
341
+ }
342
+ return out;
343
+ }
344
+
345
+ /**
346
+ * The slots declared at `depth`, as the props the layout there receives.
347
+ *
348
+ * One object per layout rather than one lookup per slot, so the common case —
349
+ * a project with no slots at all — allocates nothing and spreads nothing.
350
+ *
351
+ * A slot the URL addressed and that has no `$default.js` is `null` rather
352
+ * than absent: a layout that declares `team` receives `team` on every route,
353
+ * so `{team ?? <Empty />}` is a thing a project can write and rely on.
354
+ */
355
+ export function slotsAt(
356
+ slots: $ReadOnlyArray<ResolvedSlot>,
357
+ depth: number,
358
+ route: SlotRoute,
359
+ ): { readonly [string]: React.Node } {
360
+ if (slots.length === 0) {
361
+ return EMPTY_SLOTS;
362
+ }
363
+ const props: { [string]: React.Node } = {};
364
+ for (const slot of slots) {
365
+ if (slot.above === depth) {
366
+ // `null` rather than an element that renders nothing, and the difference
367
+ // is the whole of what the prop is for: `{team ?? <Empty />}` has to be
368
+ // able to tell "this slot has nothing in it" from "this slot rendered
369
+ // something empty", and an element is never `null`.
370
+ props[slot.name] =
371
+ slot.page == null ? null : (
372
+ <SlotView slot={slot} pathname={route.pathname} searchParams={route.searchParams} />
373
+ );
374
+ }
375
+ }
376
+ return props;
377
+ }
378
+
379
+ /** One object for every layout on a project that declares no slot. */
380
+ const EMPTY_SLOTS: { readonly [string]: React.Node } = Object.freeze({});
381
+
382
+ /**
383
+ * One slot's tree: its page, inside the layouts declared under the slot, with
384
+ * the slots those layouts declare in turn.
385
+ *
386
+ * The same composition [`RouteView`] does and deliberately not the same
387
+ * function. A route's tree carries the things a slot does not have — the error
388
+ * boundary, the `<Suspense>` fallbacks, the templates, the head — and folding
389
+ * a second, simpler case into that loop would be four `if`s asking which of the
390
+ * two this is. What the two share is the *order*, page innermost and layouts
391
+ * backwards over a root-first list, and that is short enough to be right twice.
392
+ *
393
+ * A slot with no page is never rendered through this component at all —
394
+ * [`slotsAt`] hands the layout `null` instead, so the layout can tell an empty
395
+ * slot from one that rendered something empty. The guard below is what makes
396
+ * that a fact about one place rather than a convention two places share.
397
+ */
398
+ component SlotView(slot: ResolvedSlot, pathname: string, searchParams: SearchParams) {
399
+ // The pathname and the search string are the route's, and they are handed
400
+ // down rather than read from the router: a slot matches the path and the
401
+ // query belongs to the URL rather than to either match, and a component that
402
+ // reads no context is one a server can render for React Server Components.
403
+ const page = slot.page;
404
+ if (page == null) {
405
+ return null;
406
+ }
407
+ // Except in an interception, whose URL is not the one the route on screen was
408
+ // resolved for. The page it renders reads the intercepted URL's query, and
409
+ // its templates and error boundary are keyed on the intercepted pathname — so
410
+ // a second photo opened in the modal remounts what the first one mounted, the
411
+ // way a navigation between two pages does.
412
+ const at = slot.intercepted?.pathname ?? pathname;
413
+ const query = slot.intercepted?.searchParams ?? searchParams;
414
+ const Page = pageComponent(page);
415
+ let element: React.Node = <Page params={slot.params} searchParams={query} data={undefined} />;
416
+ const templateContext = {
417
+ pathname: at,
418
+ params: slot.params,
419
+ templates: slot.templates,
420
+ };
421
+ for (let depth = slot.layouts.length; depth >= 0; depth -= 1) {
422
+ for (let index = slot.loading.length - 1; index >= 0; index -= 1) {
423
+ const boundary = slot.loading[index];
424
+ if (boundary.above !== depth) {
425
+ continue;
426
+ }
427
+ const Fallback = loadingComponent(boundary.module);
428
+ element = <Suspense fallback={<Fallback />}>{element}</Suspense>;
429
+ }
430
+ const errorBoundary = slot.errorBoundary;
431
+ if (errorBoundary != null && errorBoundary.above === depth) {
432
+ element = (
433
+ <RouteErrorBoundary module={errorBoundary.module} resetKey={`${at}:${slot.name}`}>
434
+ {element}
435
+ </RouteErrorBoundary>
436
+ );
437
+ }
438
+ element = insideTemplates(element, templateContext, depth);
439
+ if (depth > 0) {
440
+ const Layout = layoutComponent(slot.layouts[depth - 1]);
441
+ element = (
442
+ <Layout {...slotsAt(slot.slots, depth, { pathname, searchParams })} params={slot.params}>
443
+ {element}
444
+ </Layout>
445
+ );
446
+ }
447
+ }
448
+ return element;
449
+ }
450
+
451
+ /**
452
+ * The component a template module renders: `default`, or the named `Template`.
453
+ *
454
+ * The same props a layout receives, because it is a layout in every way but
455
+ * one: it wraps `children`, it may read the route's parameters, and the only
456
+ * difference is that `RouteView` gives the element a `key` so React builds it
457
+ * again on every navigation.
458
+ */
459
+ function templateComponent(module: TemplateModule): React.ComponentType<LayoutRenderProps> {
460
+ const component = module.default ?? module.Template;
461
+ if (component == null) {
462
+ throw new Error(
463
+ "@uniflowed/router: a template module must export a component as `default` or `Template`",
464
+ );
465
+ }
466
+ return renderable(component);
467
+ }
468
+
469
+ /** The component a layout module renders: `default`, or the named `Layout`. */
470
+ function layoutComponent(module: LayoutModule): React.ComponentType<LayoutRenderProps> {
471
+ const component = module.default ?? module.Layout;
472
+ if (component == null) {
473
+ throw new Error(
474
+ "@uniflowed/router: a layout module must export a component as `default` or `Layout`",
475
+ );
476
+ }
477
+ return renderable(component);
478
+ }
@@ -0,0 +1,189 @@
1
+ // @flow
2
+ //
3
+ // Internal to `@uniflowed/router`: what renders in place of a subtree that threw.
4
+ //
5
+ // The framework's error page, the component that picks between it and a
6
+ // project's `$error.js`, the page a route that resolved to its error boundary
7
+ // renders, and the class boundary that catches a throw while the browser
8
+ // renders. Every one of them is the browser's: a boundary is a class, and a
9
+ // retry is a navigation, so none of this can run in a graph resolved under
10
+ // React's `react-server` condition — which is why it is out of `./runtime.js`'s
11
+ // top half and in a module of its own. See ubugeeei-prod/uf#519.
12
+
13
+ "use client";
14
+
15
+ import * as React from "react";
16
+
17
+ import type { RouteError } from "./routing.js";
18
+ import type { ErrorModule } from "./resolve.js";
19
+ import { errorTitle, renderable, routeErrorFor } from "./resolve.js";
20
+ import { useRouterState } from "./runtime.js";
21
+
22
+ /**
23
+ * The framework's error page, for a project that declares no `$error.js`.
24
+ *
25
+ * It says which of the three happened and offers the reset, and it does *not*
26
+ * print the thrown error: on the server that message is written for whoever
27
+ * deployed the application — a query, a path, a token in a stack — and this
28
+ * markup is sent to whoever asked for the page. `uf dev` reports the throw in
29
+ * the terminal and `uf build` fails the route, which are the places the person
30
+ * who can act on it is looking.
31
+ */
32
+ component DefaultRouteError(error: RouteError, reset: () => void) {
33
+ const title = errorTitle(error);
34
+ const detail = match (error) {
35
+ {kind: "unauthorized"} => "This page needs you to be signed in.",
36
+ {kind: "forbidden"} => "You do not have access to this page.",
37
+ {kind: "thrown"} => "This page could not be rendered.",
38
+ };
39
+ return (
40
+ <main>
41
+ <title>{title}</title>
42
+ <h1>{title}</h1>
43
+ <p>{detail}</p>
44
+ <button type="button" onClick={reset}>
45
+ Try again
46
+ </button>
47
+ </main>
48
+ );
49
+ }
50
+
51
+ /** The component an error module renders: `default`, or the named `Error`. */
52
+ function errorComponent(module: ErrorModule): React.ComponentType<ErrorRenderProps> {
53
+ const component = module.default ?? module.Error;
54
+ if (component == null) {
55
+ throw new Error(
56
+ "@uniflowed/router: an error module must export a component as `default` or `Error`",
57
+ );
58
+ }
59
+ return renderable(component);
60
+ }
61
+
62
+ /** The props an error boundary's component receives. */
63
+ type ErrorRenderProps = {|
64
+ readonly error: RouteError,
65
+ readonly reset: () => void,
66
+ |};
67
+
68
+ /**
69
+ * The error UI, from whichever module is in scope.
70
+ *
71
+ * One component for both ways in — the class boundary below, which catches a
72
+ * throw while the browser renders, and `ResolvedErrorPage`, which is what the
73
+ * server renders because React's boundaries do not run in `renderToString`.
74
+ * Two paths to the same screen is exactly the pair that drifts.
75
+ */
76
+ component RouteErrorView(module: ?ErrorModule, error: RouteError, reset: () => void) {
77
+ if (module == null) {
78
+ return <DefaultRouteError error={error} reset={reset} />;
79
+ }
80
+ const Boundary = errorComponent(module);
81
+ return <Boundary error={error} reset={reset} />;
82
+ }
83
+
84
+ /**
85
+ * The page of a route that resolved to an error.
86
+ *
87
+ * A resolved error route carries the error and the module on the route itself,
88
+ * so this is a static component rather than a closure the resolver builds:
89
+ * `RouteView` composes it in its layouts exactly like a page, which is what
90
+ * makes "inside the layouts above the boundary" one code path and not two.
91
+ *
92
+ * `reset()` here is `router.refresh()` — this route resolved to an error
93
+ * because a loader or an import threw, so re-running the resolution is what
94
+ * trying again means. On the server `refresh` does nothing, which is correct:
95
+ * a static render has nothing to re-run.
96
+ */
97
+ export component ResolvedErrorPage() {
98
+ const { route, view, router } = useRouterState();
99
+ const reset = () => {
100
+ router.refresh().catch(() => {});
101
+ };
102
+
103
+ // Unreachable otherwise: this is only ever the page of an error route that was
104
+ // resolved from its modules. A route React Server Components rendered has
105
+ // [`ErrorRoutePage`] instead, with the boundary's module handed over as a prop.
106
+ if (route.error == null || view.kind !== "modules") {
107
+ return null;
108
+ }
109
+ return (
110
+ <RouteErrorView module={view.resolved.errorBoundary.module} error={route.error} reset={reset} />
111
+ );
112
+ }
113
+
114
+ /**
115
+ * The page of a route that resolved to an error, rendered by React Server
116
+ * Components.
117
+ *
118
+ * [`ResolvedErrorPage`] reads the error and the boundary's module out of the
119
+ * router, which holds a whole resolved route in a single-page application. The
120
+ * route a Flight payload hands the browser carries neither — the module is the
121
+ * server's, and what crosses is the part a hook reads — so the Flight renderer
122
+ * passes both as props: the boundary's module as `{ default: <client
123
+ * reference> }`, which is what a `"use client"` `$error.js` becomes on the way,
124
+ * and the error, which React serialises the way it serialises any error value.
125
+ * The retry is the same `router.refresh()`, because trying again still means
126
+ * resolving the route again. See ubugeeei-prod/uf#519.
127
+ */
128
+ export component ErrorRoutePage(module: ?ErrorModule, error: RouteError) {
129
+ const { router } = useRouterState();
130
+ const reset = () => {
131
+ router.refresh().catch(() => {});
132
+ };
133
+ return <RouteErrorView module={module} error={error} reset={reset} />;
134
+ }
135
+
136
+ type RouteErrorBoundaryProps = {|
137
+ readonly module: ?ErrorModule,
138
+ readonly resetKey: string,
139
+ readonly children: React.Node,
140
+ |};
141
+
142
+ type RouteErrorBoundaryState = {| readonly error: ?RouteError |};
143
+
144
+ /**
145
+ * The boundary that catches a throw while the browser renders the subtree.
146
+ *
147
+ * A class, because `getDerivedStateFromError` is React's contract for this and
148
+ * there is no hook that does it — this is the one place in the router where
149
+ * following React's public contract means not using a function component.
150
+ *
151
+ * Recovering on navigation is `componentDidUpdate` watching `resetKey`, not
152
+ * `key={pathname}` on the boundary. Keying it remounts the subtree on *every*
153
+ * navigation, error or not, and everything below the boundary goes with it —
154
+ * which is the layouts, whose whole purpose is to survive navigation with
155
+ * their scroll position and their open sections intact.
156
+ */
157
+ export class RouteErrorBoundary extends React.Component<
158
+ RouteErrorBoundaryProps,
159
+ RouteErrorBoundaryState,
160
+ > {
161
+ constructor(props: RouteErrorBoundaryProps) {
162
+ super(props);
163
+ this.state = { error: null };
164
+ }
165
+
166
+ static getDerivedStateFromError(error: mixed): RouteErrorBoundaryState {
167
+ return { error: routeErrorFor(error) };
168
+ }
169
+
170
+ componentDidUpdate(previous: RouteErrorBoundaryProps) {
171
+ if (this.state.error != null && previous.resetKey !== this.props.resetKey) {
172
+ this.setState({ error: null });
173
+ }
174
+ }
175
+
176
+ render(): React.Node {
177
+ const { error } = this.state;
178
+ if (error == null) {
179
+ return this.props.children;
180
+ }
181
+ return (
182
+ <RouteErrorView
183
+ module={this.props.module}
184
+ error={error}
185
+ reset={() => this.setState({ error: null })}
186
+ />
187
+ );
188
+ }
189
+ }