@uniflowed/router 0.0.0-alpha.8 → 0.1.0
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/action.js +324 -0
- package/client.js +261 -7
- package/handler.js +113 -99
- package/http-client.js +104 -0
- package/index.js +49 -6
- package/instrumentation.js +92 -0
- package/internal/action-endpoint.js +438 -0
- package/internal/action-wire.js +608 -0
- package/internal/base-path.js +175 -0
- package/internal/boundaries.js +481 -0
- package/internal/boundary-data.js +88 -0
- package/internal/compose.js +490 -0
- package/internal/deployment.js +160 -0
- package/internal/devtools.js +131 -0
- package/internal/diagnostics.js +169 -0
- package/internal/error-view.js +193 -0
- package/internal/flight-browser.js +242 -0
- package/internal/flight-chunks.js +205 -0
- package/internal/flight-rows.js +135 -0
- package/internal/flight-ssr.js +91 -0
- package/internal/flight.js +192 -0
- package/internal/head.js +219 -0
- package/internal/hydration.js +1085 -0
- package/internal/inspector.js +626 -0
- package/internal/native-links.js +67 -0
- package/internal/native-tree.js +89 -0
- package/internal/navigation-cache.js +181 -0
- package/internal/payload-rows.js +270 -0
- package/internal/payload.js +685 -0
- package/internal/prepare-document.js +54 -0
- package/internal/react-version.js +77 -0
- package/internal/resolve.js +1617 -0
- package/internal/resolved-summary.js +199 -0
- package/internal/routing.js +478 -0
- package/internal/runtime.js +1597 -1329
- package/internal/server-instrumentation.js +12 -0
- package/internal/server-route.js +58 -0
- package/internal/shell.js +125 -0
- package/internal/stream.js +754 -21
- package/middleware.js +161 -22
- package/native-navigation.js +217 -0
- package/native.js +416 -0
- package/package.json +48 -7
- package/routing.js +51 -0
- package/rsc-client.js +120 -0
- package/rsc-ssr.js +637 -0
- package/rsc.js +402 -0
- package/server-components.js +159 -0
- package/server.js +254 -106
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// React-free names for the boundaries a resolved route renders.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What `file` says for the error boundary the build synthesises.
|
|
7
|
+
*
|
|
8
|
+
* `routesModuleSource` writes this string where a declared boundary has a path,
|
|
9
|
+
* because the record has no module to name: the framework's own error page
|
|
10
|
+
* renders in its place. This module is plain data so the routing surface and
|
|
11
|
+
* the dev-only DOM marker code can share one spelling without importing React.
|
|
12
|
+
*/
|
|
13
|
+
export const SYNTHESISED_SOURCE: string = "@uniflowed/router";
|
|
14
|
+
|
|
15
|
+
/** Which kind of boundary a mark or payload row belongs to. */
|
|
16
|
+
export type BoundaryKind = "suspense" | "error";
|
|
17
|
+
|
|
18
|
+
/** The id of the `<Suspense>` boundary at `index` of a route's `loading`. */
|
|
19
|
+
export function suspenseId(index: number): string {
|
|
20
|
+
return `suspense:${index}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The id of the boundary a route's own `$error.js` renders. */
|
|
24
|
+
export const ROUTE_ERROR_ID: string = "error:route";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The id of the boundary that stands outside every layout.
|
|
28
|
+
*
|
|
29
|
+
* It has no module and renders the framework's page; it is what is between a
|
|
30
|
+
* throw in a root layout, or in the error component itself, and an unmounted
|
|
31
|
+
* document.
|
|
32
|
+
*/
|
|
33
|
+
export const ROOT_ERROR_ID: string = "error:root";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* One boundary of a resolved route, in uf's vocabulary rather than React's.
|
|
37
|
+
*
|
|
38
|
+
* `id` is the stable name `RouteView` marks in the DOM today and the name a
|
|
39
|
+
* future element payload can put beside the chunk that completed it. `above`
|
|
40
|
+
* is how many layouts are outside the boundary.
|
|
41
|
+
*/
|
|
42
|
+
export type RouteBoundary = {|
|
|
43
|
+
readonly id: string,
|
|
44
|
+
readonly kind: BoundaryKind,
|
|
45
|
+
readonly above: number,
|
|
46
|
+
readonly source: ?string,
|
|
47
|
+
|};
|
|
48
|
+
|
|
49
|
+
/** The part of a resolved route the boundary map reads. */
|
|
50
|
+
type BoundedRoute = {
|
|
51
|
+
readonly errorBoundary: { readonly above: number, ... },
|
|
52
|
+
readonly loading: $ReadOnlyArray<{ readonly above: number, ... }>,
|
|
53
|
+
readonly error: mixed,
|
|
54
|
+
...
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Every boundary a resolved route renders, in the order they nest.
|
|
59
|
+
*
|
|
60
|
+
* A `Map` rather than a list because callers read it both ways: `RouteView`
|
|
61
|
+
* asks for one by id as it builds the stack, while diagnostics and payload
|
|
62
|
+
* summaries walk the values.
|
|
63
|
+
*/
|
|
64
|
+
export function routeBoundaries(
|
|
65
|
+
resolved: BoundedRoute,
|
|
66
|
+
errorSource: ?string,
|
|
67
|
+
): Map<string, RouteBoundary> {
|
|
68
|
+
const found: Map<string, RouteBoundary> = new Map();
|
|
69
|
+
found.set(ROOT_ERROR_ID, {
|
|
70
|
+
id: ROOT_ERROR_ID,
|
|
71
|
+
kind: "error",
|
|
72
|
+
above: 0,
|
|
73
|
+
source: SYNTHESISED_SOURCE,
|
|
74
|
+
});
|
|
75
|
+
if (resolved.error == null) {
|
|
76
|
+
found.set(ROUTE_ERROR_ID, {
|
|
77
|
+
id: ROUTE_ERROR_ID,
|
|
78
|
+
kind: "error",
|
|
79
|
+
above: resolved.errorBoundary.above,
|
|
80
|
+
source: errorSource,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
resolved.loading.forEach((boundary, index) => {
|
|
84
|
+
const id = suspenseId(index);
|
|
85
|
+
found.set(id, { id, kind: "suspense", above: boundary.above, source: null });
|
|
86
|
+
});
|
|
87
|
+
return found;
|
|
88
|
+
}
|
|
@@ -0,0 +1,490 @@
|
|
|
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
|
+
// The slot module's own export, looked up by slot: `pageComponent` hands back
|
|
416
|
+
// its `default` or `Page` as it is, so this is the same component on every
|
|
417
|
+
// render of the same slot. The React Compiler cannot see through the lookup
|
|
418
|
+
// and reports a component created during render.
|
|
419
|
+
// uf-lint-disable-next-line react-compiler/static-components
|
|
420
|
+
let element: React.Node = <Page params={slot.params} searchParams={query} data={undefined} />;
|
|
421
|
+
const templateContext = {
|
|
422
|
+
pathname: at,
|
|
423
|
+
params: slot.params,
|
|
424
|
+
templates: slot.templates,
|
|
425
|
+
};
|
|
426
|
+
for (let depth = slot.layouts.length; depth >= 0; depth -= 1) {
|
|
427
|
+
for (let index = slot.loading.length - 1; index >= 0; index -= 1) {
|
|
428
|
+
const boundary = slot.loading[index];
|
|
429
|
+
if (boundary.above !== depth) {
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
const Fallback = loadingComponent(boundary.module);
|
|
433
|
+
// The same lookup as `Page` above: `loadingComponent` hands back the
|
|
434
|
+
// boundary module's own export as it is, so this is the same component
|
|
435
|
+
// on every render of that boundary.
|
|
436
|
+
// uf-lint-disable-next-line react-compiler/static-components
|
|
437
|
+
element = <Suspense fallback={<Fallback />}>{element}</Suspense>;
|
|
438
|
+
}
|
|
439
|
+
const errorBoundary = slot.errorBoundary;
|
|
440
|
+
if (errorBoundary != null && errorBoundary.above === depth) {
|
|
441
|
+
element = (
|
|
442
|
+
<RouteErrorBoundary module={errorBoundary.module} resetKey={`${at}:${slot.name}`}>
|
|
443
|
+
{element}
|
|
444
|
+
</RouteErrorBoundary>
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
element = insideTemplates(element, templateContext, depth);
|
|
448
|
+
if (depth > 0) {
|
|
449
|
+
const Layout = layoutComponent(slot.layouts[depth - 1]);
|
|
450
|
+
element = (
|
|
451
|
+
// The layout module's own export, looked up by depth, for the same
|
|
452
|
+
// reason as `Page` above.
|
|
453
|
+
// uf-lint-disable-next-line react-compiler/static-components
|
|
454
|
+
<Layout {...slotsAt(slot.slots, depth, { pathname, searchParams })} params={slot.params}>
|
|
455
|
+
{element}
|
|
456
|
+
</Layout>
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return element;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* The component a template module renders: `default`, or the named `Template`.
|
|
465
|
+
*
|
|
466
|
+
* The same props a layout receives, because it is a layout in every way but
|
|
467
|
+
* one: it wraps `children`, it may read the route's parameters, and the only
|
|
468
|
+
* difference is that `RouteView` gives the element a `key` so React builds it
|
|
469
|
+
* again on every navigation.
|
|
470
|
+
*/
|
|
471
|
+
function templateComponent(module: TemplateModule): React.ComponentType<LayoutRenderProps> {
|
|
472
|
+
const component = module.default ?? module.Template;
|
|
473
|
+
if (component == null) {
|
|
474
|
+
throw new Error(
|
|
475
|
+
"@uniflowed/router: a template module must export a component as `default` or `Template`",
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
return renderable(component);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** The component a layout module renders: `default`, or the named `Layout`. */
|
|
482
|
+
function layoutComponent(module: LayoutModule): React.ComponentType<LayoutRenderProps> {
|
|
483
|
+
const component = module.default ?? module.Layout;
|
|
484
|
+
if (component == null) {
|
|
485
|
+
throw new Error(
|
|
486
|
+
"@uniflowed/router: a layout module must export a component as `default` or `Layout`",
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
return renderable(component);
|
|
490
|
+
}
|