@uniflowed/router 0.0.0-alpha.34 → 0.0.0-alpha.35
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 +65 -0
- package/internal/boundaries.js +48 -25
- package/internal/compose.js +478 -0
- package/internal/error-view.js +189 -0
- package/internal/flight-browser.js +230 -0
- package/internal/flight-chunks.js +181 -0
- package/internal/flight-ssr.js +72 -0
- package/internal/flight.js +132 -0
- package/internal/head.js +219 -0
- package/internal/resolve.js +1615 -0
- package/internal/runtime.js +522 -2452
- package/internal/server-route.js +52 -0
- package/internal/stream.js +156 -3
- package/package.json +18 -6
- package/rsc.js +323 -0
- package/server-components.js +155 -0
- package/server.js +428 -1
package/client.js
CHANGED
|
@@ -108,6 +108,7 @@ import {
|
|
|
108
108
|
import { DATA_ID, ROOT_ID } from "./internal/document.js";
|
|
109
109
|
import { decodePayload } from "./internal/payload.js";
|
|
110
110
|
import { createPayloadReader, domObserver } from "./internal/payload-rows.js";
|
|
111
|
+
import { installBrowserModules, readDocumentPayload } from "./internal/flight-browser.js";
|
|
111
112
|
|
|
112
113
|
/**
|
|
113
114
|
* Hydrate the current document.
|
|
@@ -217,6 +218,70 @@ export async function hydrate(options: {|
|
|
|
217
218
|
}
|
|
218
219
|
}
|
|
219
220
|
|
|
221
|
+
/**
|
|
222
|
+
* Hydrate a document React Server Components rendered.
|
|
223
|
+
*
|
|
224
|
+
* [`hydrate`] resolves the route from its modules and renders it again over the
|
|
225
|
+
* server's markup. This one resolves nothing and imports no route module: the
|
|
226
|
+
* document carries the Flight payload its tree was rendered from, React's own
|
|
227
|
+
* client reads it, and the tree the browser hydrates is the tree the server
|
|
228
|
+
* rendered — a Server Component is markup and a reference, and a client
|
|
229
|
+
* component is the one kind of module this page loads. See
|
|
230
|
+
* ubugeeei-prod/uf#519.
|
|
231
|
+
*
|
|
232
|
+
* The payload is read while the document is still arriving. Row 0 is in the
|
|
233
|
+
* shell, so hydration starts as soon as the module script runs, and every row
|
|
234
|
+
* after it lands in a later chunk that the reader picks up as it is parsed — so
|
|
235
|
+
* a boundary the server completes after hydration began resolves then, with no
|
|
236
|
+
* second request.
|
|
237
|
+
*
|
|
238
|
+
* Everything else is [`hydrate`]'s, for the reasons written there: the
|
|
239
|
+
* navigation mode is installed before the first render, the development
|
|
240
|
+
* hydration report captures the server's markup before React repairs it, and
|
|
241
|
+
* Strict Mode wraps the root.
|
|
242
|
+
*/
|
|
243
|
+
export async function hydrateFlight(options: {|
|
|
244
|
+
readonly App: React.ComponentType<AppProps>,
|
|
245
|
+
readonly strictMode?: boolean,
|
|
246
|
+
readonly navigation?: Navigation,
|
|
247
|
+
|}): Promise<void> {
|
|
248
|
+
installNavigation(options.navigation ?? "client");
|
|
249
|
+
installBrowserModules();
|
|
250
|
+
const flight = readDocumentPayload(document, domObserver(document));
|
|
251
|
+
|
|
252
|
+
const url = window.location.pathname + window.location.search;
|
|
253
|
+
const { App } = options;
|
|
254
|
+
const container = document.getElementById(ROOT_ID) ?? document;
|
|
255
|
+
prepareDocumentForHydration(document);
|
|
256
|
+
|
|
257
|
+
let recovery = null;
|
|
258
|
+
let restoreDevHead = null;
|
|
259
|
+
if (import.meta.hot != null) {
|
|
260
|
+
const { captureServerMarkup, hydrationErrorHandler, prepareDevHeadForHydration } =
|
|
261
|
+
await import("./internal/hydration.js");
|
|
262
|
+
restoreDevHead = prepareDevHeadForHydration(document);
|
|
263
|
+
recovery = hydrationErrorHandler(container, captureServerMarkup(container), document);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const tree = <App url={url} flight={flight} />;
|
|
267
|
+
|
|
268
|
+
startTransition(() => {
|
|
269
|
+
hydrateRoot(
|
|
270
|
+
container,
|
|
271
|
+
options.strictMode === true ? <StrictMode>{tree}</StrictMode> : tree,
|
|
272
|
+
recovery == null ? undefined : { onRecoverableError: recovery },
|
|
273
|
+
);
|
|
274
|
+
if (restoreDevHead != null) {
|
|
275
|
+
setTimeout(restoreDevHead, 250);
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
if (import.meta.hot != null) {
|
|
280
|
+
const { reportDevtools } = await import("./internal/devtools.js");
|
|
281
|
+
reportDevtools(window);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
220
285
|
function prepareDocumentForHydration(document: Document): void {
|
|
221
286
|
const head = document.head;
|
|
222
287
|
const envelope = head.querySelector('meta[name="uf:render"]');
|
package/internal/boundaries.js
CHANGED
|
@@ -83,8 +83,10 @@
|
|
|
83
83
|
// package is `sideEffects: false`, so with the references folded away the
|
|
84
84
|
// module is dropped rather than merely unused.
|
|
85
85
|
|
|
86
|
+
"use client";
|
|
87
|
+
|
|
86
88
|
import * as React from "react";
|
|
87
|
-
import { useEffect,
|
|
89
|
+
import { useEffect, useSyncExternalStore } from "react";
|
|
88
90
|
|
|
89
91
|
import { SYNTHESISED_SOURCE } from "./boundary-data.js";
|
|
90
92
|
import { reportDiagnostic } from "./diagnostics.js";
|
|
@@ -114,13 +116,32 @@ export const BOUNDARY_GLOBAL: string = "__ufBoundaries";
|
|
|
114
116
|
/**
|
|
115
117
|
* Whether an edge that mounts now should be in the DOM immediately.
|
|
116
118
|
*
|
|
117
|
-
* Latched by the first edge to mount and never cleared
|
|
118
|
-
* `
|
|
119
|
-
* difference between "
|
|
119
|
+
* Latched by the first edge to mount and never cleared, and read through
|
|
120
|
+
* `useSyncExternalStore` rather than during the render body, which is the
|
|
121
|
+
* difference between "a value React asked for" and "a module variable a
|
|
120
122
|
* memoising compiler is entitled to hold on to".
|
|
121
123
|
*/
|
|
122
124
|
let marksAreLive = false;
|
|
123
125
|
|
|
126
|
+
/** The edges waiting to hear that marks have gone live. */
|
|
127
|
+
const liveListeners: Set<() => void> = new Set();
|
|
128
|
+
|
|
129
|
+
function subscribeToLiveMarks(listener: () => void): () => void {
|
|
130
|
+
liveListeners.add(listener);
|
|
131
|
+
return () => {
|
|
132
|
+
liveListeners.delete(listener);
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function marksAreLiveNow(): boolean {
|
|
137
|
+
return marksAreLive;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** What a server rendered, and so what every hydrating edge renders: nothing. */
|
|
141
|
+
function noMarksOnTheServer(): boolean {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
|
|
124
145
|
/**
|
|
125
146
|
* One end of one boundary.
|
|
126
147
|
*
|
|
@@ -128,12 +149,25 @@ let marksAreLive = false;
|
|
|
128
149
|
* This used to be a `<template>`, but React 19.3 reports template insertion
|
|
129
150
|
* during document-root hydration as a browser error. A `span hidden` carries
|
|
130
151
|
* the same marker data without entering layout or the accessibility tree.
|
|
152
|
+
*
|
|
153
|
+
* # Why the server snapshot, and not a first state
|
|
154
|
+
*
|
|
155
|
+
* An edge used to take `marksAreLive` as its first state, which is right only
|
|
156
|
+
* if every edge on a page hydrates in the same pass. Under React Server
|
|
157
|
+
* Components they do not: a client reference loads when the payload names it,
|
|
158
|
+
* so the part of the tree above it hydrates, commits and runs this effect
|
|
159
|
+
* first, and an edge that hydrates afterwards read `true` and rendered a mark
|
|
160
|
+
* the server never wrote — a hydration mismatch on every page with a boundary
|
|
161
|
+
* below a client component, under `uf dev` only. `useSyncExternalStore` hands a
|
|
162
|
+
* hydrating edge the server's answer whenever it hydrates, and an edge mounted
|
|
163
|
+
* by a navigation or by HMR the live one, in its own commit, as before.
|
|
131
164
|
*/
|
|
132
|
-
component BoundaryEdge(boundary: RouteBoundary, edge: "open" | "close") {
|
|
133
|
-
const
|
|
165
|
+
export component BoundaryEdge(boundary: RouteBoundary, edge: "open" | "close") {
|
|
166
|
+
const live = useSyncExternalStore(subscribeToLiveMarks, marksAreLiveNow, noMarksOnTheServer);
|
|
134
167
|
useEffect(() => {
|
|
168
|
+
if (marksAreLive) return;
|
|
135
169
|
marksAreLive = true;
|
|
136
|
-
|
|
170
|
+
for (const listener of [...liveListeners]) listener();
|
|
137
171
|
}, []);
|
|
138
172
|
if (!live) {
|
|
139
173
|
return null;
|
|
@@ -154,25 +188,14 @@ component BoundaryEdge(boundary: RouteBoundary, edge: "open" | "close") {
|
|
|
154
188
|
/**
|
|
155
189
|
* `children`, between the two marks of `boundary`.
|
|
156
190
|
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
191
|
+
* Kept importable from here, where the marks are, and written in
|
|
192
|
+
* `./compose.js`, where they are placed. This module is a client module — an
|
|
193
|
+
* edge has state and an effect — and a server composing a tree for React Server
|
|
194
|
+
* Components calls the factory rather than rendering it, so the factory has to
|
|
195
|
+
* live in a module that graph evaluates while the edges it places stay
|
|
196
|
+
* references to this one. See ubugeeei-prod/uf#519.
|
|
163
197
|
*/
|
|
164
|
-
export
|
|
165
|
-
if (boundary == null) {
|
|
166
|
-
return children;
|
|
167
|
-
}
|
|
168
|
-
return (
|
|
169
|
-
<>
|
|
170
|
-
<BoundaryEdge boundary={boundary} edge="open" />
|
|
171
|
-
{children}
|
|
172
|
-
<BoundaryEdge boundary={boundary} edge="close" />
|
|
173
|
-
</>
|
|
174
|
-
);
|
|
175
|
-
}
|
|
198
|
+
export { insideBoundary } from "./compose.js";
|
|
176
199
|
|
|
177
200
|
/**
|
|
178
201
|
* How far a walk between two marks will go before giving up.
|
|
@@ -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
|
+
}
|