@uniflowed/router 0.0.0-alpha.17 → 0.0.0-alpha.20
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 +187 -5
- package/handler.js +2 -2
- package/index.js +29 -6
- package/internal/action-wire.js +6 -3
- package/internal/boundaries.js +557 -0
- package/internal/devtools.js +2 -2
- package/internal/diagnostics.js +1 -1
- package/internal/hydration.js +232 -39
- package/internal/inspector.js +615 -0
- package/internal/payload-rows.js +258 -0
- package/internal/payload.js +669 -0
- package/internal/runtime.js +754 -50
- package/internal/stream.js +114 -10
- package/middleware.js +3 -3
- package/package.json +5 -4
- package/server.js +99 -1
package/client.js
CHANGED
|
@@ -1,12 +1,31 @@
|
|
|
1
1
|
// @flow
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// Starting the application in the browser.
|
|
4
|
+
//
|
|
5
|
+
// Two entry points, and which one `virtual:uf/client` calls is decided by
|
|
6
|
+
// `app.rendering.modes`. `hydrate` is the one every uf build has used: a server
|
|
7
|
+
// or a prerender wrote the markup, and React attaches to it. `render` is for
|
|
8
|
+
// `["csr"]`, where the build wrote one shell with an empty root and nothing has
|
|
9
|
+
// been rendered anywhere yet; see its own comment for why that is not `hydrate`
|
|
10
|
+
// with a flag.
|
|
4
11
|
//
|
|
5
12
|
// `virtual:uf/client` calls `hydrate` with the app root and the route table.
|
|
6
13
|
// The current route's chunks are loaded and its embedded loader data read
|
|
7
14
|
// *before* `hydrateRoot`, so the first client render is synchronous and
|
|
8
15
|
// matches the server's markup exactly.
|
|
9
16
|
//
|
|
17
|
+
// # What "its loader data" means once the loader can defer
|
|
18
|
+
//
|
|
19
|
+
// It is a payload rather than a value: `internal/payload.js` writes the model
|
|
20
|
+
// into `<script id="__uf_data">` with a `"$P<n>"` reference wherever the loader
|
|
21
|
+
// left a promise, and each of those arrives later in a `<script data-uf-row>`
|
|
22
|
+
// of its own. So two things happen before `hydrateRoot` rather than one — the
|
|
23
|
+
// model is decoded, and `internal/payload-rows.js` starts watching for the
|
|
24
|
+
// rows it referred to. Both have to be first: the decoded model is what the
|
|
25
|
+
// first render is handed, and a row that landed while nothing was watching
|
|
26
|
+
// would be a boundary that never resolves. A document with nothing deferred
|
|
27
|
+
// has no references, so the reader is handed no ids and installs nothing.
|
|
28
|
+
//
|
|
10
29
|
// # A hydration that fails says what differed
|
|
11
30
|
//
|
|
12
31
|
// React reports a mismatch with one sentence and a list of the six things that
|
|
@@ -49,6 +68,17 @@
|
|
|
49
68
|
// `app.react.strictMode: false` in `uf.config.js` turns it off. See
|
|
50
69
|
// ubugeeei-prod/uf#516.
|
|
51
70
|
//
|
|
71
|
+
// # And an application can decline to be navigated
|
|
72
|
+
//
|
|
73
|
+
// `app.rendering.navigation: "document"` is the whole application saying what
|
|
74
|
+
// the paragraph below says about one route: the document the server wrote is
|
|
75
|
+
// what a link produces, and the browser fetches the next one. It is *not* the
|
|
76
|
+
// same as declining to hydrate — the page still hydrates, so a `"use client"`
|
|
77
|
+
// component is still interactive — and what it removes is the takeover. The
|
|
78
|
+
// flag reaches the runtime through `installNavigation` before the first render;
|
|
79
|
+
// see `internal/runtime.js` for what `RouterProvider` and `Link` then do, and
|
|
80
|
+
// `docs/app/guide/rendering` for when a project wants it.
|
|
81
|
+
//
|
|
52
82
|
// # A route can decline to be hydrated
|
|
53
83
|
//
|
|
54
84
|
// uf's server-component analysis decides which routes have a `"use client"`
|
|
@@ -61,17 +91,23 @@
|
|
|
61
91
|
|
|
62
92
|
import * as React from "react";
|
|
63
93
|
import { StrictMode, startTransition } from "react";
|
|
64
|
-
import { hydrateRoot } from "react-dom/client";
|
|
94
|
+
import { createRoot, hydrateRoot } from "react-dom/client";
|
|
65
95
|
|
|
66
96
|
import {
|
|
67
97
|
type AppProps,
|
|
98
|
+
type Navigation,
|
|
68
99
|
type RouteTable,
|
|
100
|
+
RedirectError,
|
|
69
101
|
hasClientPage,
|
|
102
|
+
installNavigation,
|
|
70
103
|
installRoutes,
|
|
71
104
|
matchRoute,
|
|
105
|
+
resolveFailure,
|
|
72
106
|
resolveMatch,
|
|
73
107
|
} from "./internal/runtime.js";
|
|
74
108
|
import { DATA_ID, ROOT_ID } from "./internal/document.js";
|
|
109
|
+
import { decodePayload } from "./internal/payload.js";
|
|
110
|
+
import { createPayloadReader, domObserver } from "./internal/payload-rows.js";
|
|
75
111
|
|
|
76
112
|
/**
|
|
77
113
|
* Hydrate the current document.
|
|
@@ -86,6 +122,7 @@ export async function hydrate(options: {|
|
|
|
86
122
|
readonly notFound: RouteTable["notFound"],
|
|
87
123
|
readonly errors: RouteTable["errors"],
|
|
88
124
|
readonly strictMode?: boolean,
|
|
125
|
+
readonly navigation?: Navigation,
|
|
89
126
|
|}): Promise<void> {
|
|
90
127
|
const table: RouteTable = {
|
|
91
128
|
routes: options.routes,
|
|
@@ -93,6 +130,11 @@ export async function hydrate(options: {|
|
|
|
93
130
|
errors: options.errors,
|
|
94
131
|
};
|
|
95
132
|
installRoutes(table);
|
|
133
|
+
// Beside the table, and before anything renders. `"client"` when the entry
|
|
134
|
+
// says nothing, which is what `virtual:uf/client` generated before
|
|
135
|
+
// `app.rendering.navigation` existed and what a hand-written entry still
|
|
136
|
+
// means: the default is the behaviour, not the absence of one.
|
|
137
|
+
installNavigation(options.navigation ?? "client");
|
|
96
138
|
|
|
97
139
|
// Before the loader data is read and before `resolveMatch` is called: both
|
|
98
140
|
// would go looking for a page module that is not in this bundle.
|
|
@@ -102,12 +144,28 @@ export async function hydrate(options: {|
|
|
|
102
144
|
}
|
|
103
145
|
|
|
104
146
|
const url = window.location.pathname + window.location.search;
|
|
147
|
+
// Row 0 of the payload, and the reader that will fill in the rows it refers
|
|
148
|
+
// to. Both before `hydrateRoot`, and in this order: `decodePayload` is what
|
|
149
|
+
// tells the reader which rows the page is waiting for, and `watch` is what
|
|
150
|
+
// makes it notice the ones the server has not written yet. A document with
|
|
151
|
+
// nothing deferred has no references, so the reader is handed no ids, and
|
|
152
|
+
// `watch` returns without installing anything — see `internal/payload.js`.
|
|
105
153
|
const embedded = document.getElementById(DATA_ID);
|
|
106
|
-
const
|
|
154
|
+
const reader = createPayloadReader(document, domObserver(document));
|
|
155
|
+
const data =
|
|
156
|
+
embedded != null
|
|
157
|
+
? decodePayload(
|
|
158
|
+
JSON.parse(embedded.textContent ?? "null"),
|
|
159
|
+
reader.resolve,
|
|
160
|
+
"the route's loader data",
|
|
161
|
+
)
|
|
162
|
+
: undefined;
|
|
163
|
+
reader.watch();
|
|
107
164
|
const resolved = await resolveMatch(table, url, { data, skipLoader: embedded != null });
|
|
108
165
|
|
|
109
166
|
const { App } = options;
|
|
110
167
|
const container = document.getElementById(ROOT_ID) ?? document;
|
|
168
|
+
prepareDocumentForHydration(document);
|
|
111
169
|
|
|
112
170
|
// The server's markup, and the reporter that will read it, in development
|
|
113
171
|
// only. Both have to be in place *before* `hydrateRoot`: React repairs a
|
|
@@ -117,13 +175,16 @@ export async function hydrate(options: {|
|
|
|
117
175
|
// `import.meta.hot` is the gate because it is the one signal that is right in
|
|
118
176
|
// all three places this module is evaluated. Vite defines it while serving
|
|
119
177
|
// and replaces it with `undefined` in a build, so the branch is statically
|
|
120
|
-
// dead there; Node leaves it undefined, so `
|
|
178
|
+
// dead there; Node leaves it undefined, so `packages/vite/rsc-split.test.js`
|
|
121
179
|
// imports this file without a bundler and gets the production path. The
|
|
122
180
|
// import is dynamic so that the overlay is not merely shaken out of a
|
|
123
181
|
// production bundle but never reachable from one.
|
|
124
182
|
let recovery = null;
|
|
183
|
+
let restoreDevHead = null;
|
|
125
184
|
if (import.meta.hot != null) {
|
|
126
|
-
const { captureServerMarkup, hydrationErrorHandler } =
|
|
185
|
+
const { captureServerMarkup, hydrationErrorHandler, prepareDevHeadForHydration } =
|
|
186
|
+
await import("./internal/hydration.js");
|
|
187
|
+
restoreDevHead = prepareDevHeadForHydration(document);
|
|
127
188
|
recovery = hydrationErrorHandler(container, captureServerMarkup(container), document);
|
|
128
189
|
}
|
|
129
190
|
|
|
@@ -138,6 +199,9 @@ export async function hydrate(options: {|
|
|
|
138
199
|
options.strictMode === true ? <StrictMode>{tree}</StrictMode> : tree,
|
|
139
200
|
recovery == null ? undefined : { onRecoverableError: recovery },
|
|
140
201
|
);
|
|
202
|
+
if (restoreDevHead != null) {
|
|
203
|
+
setTimeout(restoreDevHead, 250);
|
|
204
|
+
}
|
|
141
205
|
});
|
|
142
206
|
|
|
143
207
|
// And, in development only, whether the panel a developer is about to open
|
|
@@ -152,3 +216,121 @@ export async function hydrate(options: {|
|
|
|
152
216
|
reportDevtools(window);
|
|
153
217
|
}
|
|
154
218
|
}
|
|
219
|
+
|
|
220
|
+
function prepareDocumentForHydration(document: Document): void {
|
|
221
|
+
const head = document.head;
|
|
222
|
+
const envelope = head.querySelector('meta[name="uf:render"]');
|
|
223
|
+
if (envelope != null && head.firstChild !== envelope) {
|
|
224
|
+
head.insertBefore(envelope, head.firstChild);
|
|
225
|
+
}
|
|
226
|
+
moveLayoutMetaAfterRouteHead(head, head.querySelector("meta[charset]"));
|
|
227
|
+
moveLayoutMetaAfterRouteHead(head, head.querySelector('meta[name="viewport"]'));
|
|
228
|
+
document.getElementById("_R_")?.remove();
|
|
229
|
+
normalizeReactFormActions(document);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function moveLayoutMetaAfterRouteHead(head: HTMLHeadElement, meta: Element | null): void {
|
|
233
|
+
if (meta == null) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const colorScheme = head.querySelector('meta[name="color-scheme"]');
|
|
237
|
+
if (colorScheme != null && colorScheme !== meta) {
|
|
238
|
+
head.insertBefore(meta, colorScheme);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
head.appendChild(meta);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const SERVER_FORM_PLACEHOLDER = "javascript:throw new Error('React form unexpectedly submitted.')";
|
|
245
|
+
const CLIENT_FORM_PLACEHOLDER =
|
|
246
|
+
"javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you\\'re trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')";
|
|
247
|
+
|
|
248
|
+
function normalizeReactFormActions(document: Document): void {
|
|
249
|
+
for (const form of document.querySelectorAll("form")) {
|
|
250
|
+
if (form.getAttribute("action") === SERVER_FORM_PLACEHOLDER) {
|
|
251
|
+
form.setAttribute("action", CLIENT_FORM_PLACEHOLDER);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Render the current route into an empty shell.
|
|
258
|
+
*
|
|
259
|
+
* The single-page entry point: `app.rendering.modes: ["csr"]` writes one
|
|
260
|
+
* document with an empty root and no markup in it, and this is what fills it.
|
|
261
|
+
*
|
|
262
|
+
* # Why it is not `hydrate` with a flag
|
|
263
|
+
*
|
|
264
|
+
* Because hydration is React comparing what it renders against what a server
|
|
265
|
+
* sent, and here no server sent anything. `hydrateRoot` against an empty
|
|
266
|
+
* container is a mismatch on the first node of every page — React would report
|
|
267
|
+
* it, throw the shell away and render from scratch, which is this function
|
|
268
|
+
* with a warning in front of it. `createRoot` says what is actually happening:
|
|
269
|
+
* the browser is the only renderer this application has.
|
|
270
|
+
*
|
|
271
|
+
* Three more things follow from there, and each of them is a line below rather
|
|
272
|
+
* than an omission:
|
|
273
|
+
*
|
|
274
|
+
* * **The loader runs here.** `resolveMatch` fetches the route's modules and
|
|
275
|
+
* runs its loader in the browser, because there was no server render to run
|
|
276
|
+
* it in and no `<script id="__uf_data">` for it to have left an answer in.
|
|
277
|
+
* * **A URL that matches nothing is the not-found boundary**, resolved the
|
|
278
|
+
* way a server resolves it. The host served this shell for a URL it had no
|
|
279
|
+
* file for, so "nothing matched" is a perfectly ordinary arrival here
|
|
280
|
+
* rather than the exception it is during hydration.
|
|
281
|
+
* * **A redirect is the browser's.** `redirect()` from a loader throws before
|
|
282
|
+
* anything is rendered; on a server that becomes a 307 and here it becomes
|
|
283
|
+
* `location.replace`, which is the same instruction to the same browser.
|
|
284
|
+
*/
|
|
285
|
+
export async function render(options: {|
|
|
286
|
+
readonly App: React.ComponentType<AppProps>,
|
|
287
|
+
readonly routes: RouteTable["routes"],
|
|
288
|
+
readonly notFound: RouteTable["notFound"],
|
|
289
|
+
readonly errors: RouteTable["errors"],
|
|
290
|
+
readonly strictMode?: boolean,
|
|
291
|
+
readonly navigation?: Navigation,
|
|
292
|
+
|}): Promise<void> {
|
|
293
|
+
const table: RouteTable = {
|
|
294
|
+
routes: options.routes,
|
|
295
|
+
notFound: options.notFound,
|
|
296
|
+
errors: options.errors,
|
|
297
|
+
};
|
|
298
|
+
installRoutes(table);
|
|
299
|
+
installNavigation(options.navigation ?? "client");
|
|
300
|
+
|
|
301
|
+
const url = window.location.pathname + window.location.search;
|
|
302
|
+
let resolved;
|
|
303
|
+
try {
|
|
304
|
+
resolved = await resolveMatch(table, url);
|
|
305
|
+
} catch (error) {
|
|
306
|
+
if (error instanceof RedirectError) {
|
|
307
|
+
window.location.replace(error.to);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
// The error boundary, chosen the same way the server chooses it. A throw
|
|
311
|
+
// from a loader is a page that cannot render, and rendering the boundary is
|
|
312
|
+
// what this application has instead of a 500.
|
|
313
|
+
resolved = await resolveFailure(table, url, error);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const { App } = options;
|
|
317
|
+
// An element, and never `document` — which is the other difference from
|
|
318
|
+
// `hydrate` above. `hydrateRoot` takes a document, because an app whose root
|
|
319
|
+
// layout renders `<html>` owns the whole of one and the server wrote it;
|
|
320
|
+
// `createRoot` does not, because creating a root *is* replacing the
|
|
321
|
+
// container's children and the container here would be the document. The
|
|
322
|
+
// shell always writes this element, so its absence means the document being
|
|
323
|
+
// rendered into is not one this build produced.
|
|
324
|
+
const container = document.getElementById(ROOT_ID);
|
|
325
|
+
if (container == null) {
|
|
326
|
+
throw new Error(
|
|
327
|
+
`@uniflowed/router: no #${ROOT_ID} in this document, so there is nothing to render into. ` +
|
|
328
|
+
"A single-page build writes the shell that carries it; this document came from " +
|
|
329
|
+
"somewhere else.",
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
const tree = <App url={url} initial={resolved} />;
|
|
333
|
+
createRoot(container).render(
|
|
334
|
+
options.strictMode === true ? <StrictMode>{tree}</StrictMode> : tree,
|
|
335
|
+
);
|
|
336
|
+
}
|
package/handler.js
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Route handlers: a path that answers a request instead of rendering a page.
|
|
4
4
|
//
|
|
5
|
-
// `app/api/users
|
|
5
|
+
// `app/api/users/$route.js` exporting `GET` and `POST` serves
|
|
6
6
|
// `/api/users`. A handler takes a `Request` and returns a `Response` — the
|
|
7
7
|
// platform's own types, not a framework's wrapper — because that is what runs
|
|
8
8
|
// unchanged on Node.js, Bun, Deno and a Cloudflare Worker, and uf's whole
|
|
9
9
|
// position is that the host is a capability rather than a target.
|
|
10
10
|
//
|
|
11
|
-
// // app/api/users/[id]
|
|
11
|
+
// // app/api/users/[id]/$route.js
|
|
12
12
|
// // @flow
|
|
13
13
|
// export async function GET(request: Request, context: HandlerContext) {
|
|
14
14
|
// const user = await find(context.params.id);
|
package/index.js
CHANGED
|
@@ -2,19 +2,26 @@
|
|
|
2
2
|
//
|
|
3
3
|
// `@uniflowed/router`: the file-system router.
|
|
4
4
|
//
|
|
5
|
-
// Pages live in `app/` as
|
|
6
|
-
//
|
|
5
|
+
// Pages live in `app/` as `$page.js` (or `.mdx`), layouts as
|
|
6
|
+
// `$layout.js`, and `app.js` exports `routerView("./app")`. The route table
|
|
7
7
|
// is generated from the directory at build time; this module is the runtime
|
|
8
8
|
// that matches, loads, navigates and renders it.
|
|
9
9
|
//
|
|
10
|
-
//
|
|
10
|
+
// `$not-found.js` and `$error.js` are the two boundaries: the page for a
|
|
11
11
|
// path that matched nothing, and what renders in place of a subtree that threw.
|
|
12
12
|
// Both are segment files, resolved by the nearest one above the path — and the
|
|
13
13
|
// router root always has one of each, so a project that declares neither still
|
|
14
14
|
// answers a 404 inside its own layouts rather than beside them.
|
|
15
15
|
//
|
|
16
|
-
//
|
|
16
|
+
// `$template.js` is a layout that remounts on every navigation, for the
|
|
17
17
|
// cases where a layout's persistence is the wrong default.
|
|
18
|
+
//
|
|
19
|
+
// A directory named `@team` is a parallel-route slot: it contributes no URL
|
|
20
|
+
// segment, and the layout of the segment that holds it receives the slot as a
|
|
21
|
+
// `team` prop beside `children`. The slot's pages are matched against the same
|
|
22
|
+
// URL the page is, so one URL renders two subtrees at once, and
|
|
23
|
+
// `$default.js` is what a slot renders when the URL matched none of its
|
|
24
|
+
// routes. See ubugeeei-prod/uf#267.
|
|
18
25
|
|
|
19
26
|
import * as React from "react";
|
|
20
27
|
|
|
@@ -43,7 +50,10 @@ export type {
|
|
|
43
50
|
RouteRecord,
|
|
44
51
|
RouteTable,
|
|
45
52
|
Router,
|
|
53
|
+
ResolvedSlot,
|
|
46
54
|
SearchParams,
|
|
55
|
+
SlotRecord,
|
|
56
|
+
SlotRouteRecord,
|
|
47
57
|
TemplateModule,
|
|
48
58
|
TwitterCard,
|
|
49
59
|
} from "./internal/runtime.js";
|
|
@@ -87,13 +97,26 @@ export type PageProps<
|
|
|
87
97
|
readonly data: TData,
|
|
88
98
|
|};
|
|
89
99
|
|
|
90
|
-
/** Props an
|
|
100
|
+
/** Props an `$error.js` component receives. */
|
|
91
101
|
export type ErrorProps = {|
|
|
92
102
|
readonly error: RouteError,
|
|
93
103
|
readonly reset: () => void,
|
|
94
104
|
|};
|
|
95
105
|
|
|
96
|
-
/**
|
|
106
|
+
/**
|
|
107
|
+
* Props a layout receives.
|
|
108
|
+
*
|
|
109
|
+
* A layout on a segment that declares parallel-route slots receives one more
|
|
110
|
+
* prop per slot, named after the directory without its `@`, and this exact
|
|
111
|
+
* type does not describe those — the names are the project's. Declare them: a
|
|
112
|
+
* layout beside `@team` and `@analytics` is
|
|
113
|
+
*
|
|
114
|
+
* component Dashboard(children: React.Node, team: React.Node, analytics: React.Node)
|
|
115
|
+
*
|
|
116
|
+
* and the router passes `null` for a slot the URL addressed by neither a route
|
|
117
|
+
* of its own nor a `$default.js`, so `{team ?? <Empty />}` is a thing that
|
|
118
|
+
* can be written and relied on.
|
|
119
|
+
*/
|
|
97
120
|
export type LayoutProps<
|
|
98
121
|
TParams extends { readonly [string]: string | $ReadOnlyArray<string> } = {},
|
|
99
122
|
> = {|
|
package/internal/action-wire.js
CHANGED
|
@@ -61,9 +61,12 @@
|
|
|
61
61
|
//
|
|
62
62
|
// * **A reference format.** React's Flight payload can carry a reference to a
|
|
63
63
|
// client module, a promise, or an element, and a decoder that reconstructs
|
|
64
|
-
// those is a decoder that constructs attacker-chosen objects. uf
|
|
65
|
-
// payload
|
|
66
|
-
//
|
|
64
|
+
// those is a decoder that constructs attacker-chosen objects. uf's payload
|
|
65
|
+
// (`./payload.js`) now carries one of the three — a reference to a *row of
|
|
66
|
+
// itself*, which names nothing to construct — and it is a document the
|
|
67
|
+
// server writes rather than a body somebody sends. This grammar is the one
|
|
68
|
+
// an untrusted sender is decoded under, so it still has none, and it is
|
|
69
|
+
// still not the place to grow one quietly (ubugeeei-prod/uf#252).
|
|
67
70
|
// * **Class instances, `Map`, `Set`, `Date`, `RegExp`, typed arrays.** Each
|
|
68
71
|
// would need a tag in the payload saying which constructor to call, and a
|
|
69
72
|
// tag naming a constructor is the oracle every deserialisation CVE is made
|