@uniflowed/router 0.0.0-alpha.35 → 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.
- package/client.js +24 -105
- package/index.js +1 -0
- package/internal/base-path.js +175 -0
- package/internal/flight-browser.js +17 -19
- package/internal/flight-ssr.js +6 -0
- package/internal/flight.js +26 -0
- package/internal/prepare-document.js +49 -0
- package/internal/react-version.js +77 -0
- package/internal/runtime.js +89 -19
- package/internal/server-route.js +6 -0
- package/internal/shell.js +107 -0
- package/internal/stream.js +67 -7
- package/middleware.js +144 -14
- package/package.json +12 -5
- package/rsc-client.js +117 -0
- package/rsc-ssr.js +432 -0
- package/rsc.js +6 -0
- package/server-components.js +4 -0
- package/server.js +10 -483
package/client.js
CHANGED
|
@@ -14,6 +14,13 @@
|
|
|
14
14
|
// *before* `hydrateRoot`, so the first client render is synchronous and
|
|
15
15
|
// matches the server's markup exactly.
|
|
16
16
|
//
|
|
17
|
+
// An application whose routes render as React Server Components, the default,
|
|
18
|
+
// starts from neither: `hydrateFlight` in `./rsc-client.js` hydrates the Flight
|
|
19
|
+
// payload its document carries. That is a separate entry so that this one,
|
|
20
|
+
// which every other web application imports, never names React's Flight
|
|
21
|
+
// client. The client is an optional peer that needs React 19.3, and a project
|
|
22
|
+
// on React 19.2 does not install it (ubugeeei-prod/uf#992).
|
|
23
|
+
//
|
|
17
24
|
// # What "its loader data" means once the loader can defer
|
|
18
25
|
//
|
|
19
26
|
// It is a payload rather than a value: `internal/payload.js` writes the model
|
|
@@ -101,6 +108,7 @@ import {
|
|
|
101
108
|
hasClientPage,
|
|
102
109
|
installNavigation,
|
|
103
110
|
installRoutes,
|
|
111
|
+
installRouting,
|
|
104
112
|
matchRoute,
|
|
105
113
|
resolveFailure,
|
|
106
114
|
resolveMatch,
|
|
@@ -108,7 +116,8 @@ import {
|
|
|
108
116
|
import { DATA_ID, ROOT_ID } from "./internal/document.js";
|
|
109
117
|
import { decodePayload } from "./internal/payload.js";
|
|
110
118
|
import { createPayloadReader, domObserver } from "./internal/payload-rows.js";
|
|
111
|
-
import {
|
|
119
|
+
import { prepareDocumentForHydration } from "./internal/prepare-document.js";
|
|
120
|
+
import { type TrailingSlash, addressOf, applicationPathOf } from "./internal/base-path.js";
|
|
112
121
|
|
|
113
122
|
/**
|
|
114
123
|
* Hydrate the current document.
|
|
@@ -124,6 +133,8 @@ export async function hydrate(options: {|
|
|
|
124
133
|
readonly errors: RouteTable["errors"],
|
|
125
134
|
readonly strictMode?: boolean,
|
|
126
135
|
readonly navigation?: Navigation,
|
|
136
|
+
readonly basePath?: string,
|
|
137
|
+
readonly trailingSlash?: TrailingSlash,
|
|
127
138
|
|}): Promise<void> {
|
|
128
139
|
const table: RouteTable = {
|
|
129
140
|
routes: options.routes,
|
|
@@ -136,15 +147,18 @@ export async function hydrate(options: {|
|
|
|
136
147
|
// `app.rendering.navigation` existed and what a hand-written entry still
|
|
137
148
|
// means: the default is the behaviour, not the absence of one.
|
|
138
149
|
installNavigation(options.navigation ?? "client");
|
|
150
|
+
installRouting({ basePath: options.basePath, trailingSlash: options.trailingSlash });
|
|
151
|
+
// The route table has no base path in it, and the address bar does.
|
|
152
|
+
const applicationPath = applicationPathOf(window.location.pathname) ?? window.location.pathname;
|
|
139
153
|
|
|
140
154
|
// Before the loader data is read and before `resolveMatch` is called: both
|
|
141
155
|
// would go looking for a page module that is not in this bundle.
|
|
142
|
-
const matched = matchRoute(table.routes,
|
|
156
|
+
const matched = matchRoute(table.routes, applicationPath);
|
|
143
157
|
if (matched != null && !hasClientPage(matched.route)) {
|
|
144
158
|
return;
|
|
145
159
|
}
|
|
146
160
|
|
|
147
|
-
const url =
|
|
161
|
+
const url = applicationPath + window.location.search;
|
|
148
162
|
// Row 0 of the payload, and the reader that will fill in the rows it refers
|
|
149
163
|
// to. Both before `hydrateRoot`, and in this order: `decodePayload` is what
|
|
150
164
|
// tells the reader which rows the page is waiting for, and `watch` is what
|
|
@@ -218,106 +232,6 @@ export async function hydrate(options: {|
|
|
|
218
232
|
}
|
|
219
233
|
}
|
|
220
234
|
|
|
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
|
-
|
|
285
|
-
function prepareDocumentForHydration(document: Document): void {
|
|
286
|
-
const head = document.head;
|
|
287
|
-
const envelope = head.querySelector('meta[name="uf:render"]');
|
|
288
|
-
if (envelope != null && head.firstChild !== envelope) {
|
|
289
|
-
head.insertBefore(envelope, head.firstChild);
|
|
290
|
-
}
|
|
291
|
-
moveLayoutMetaAfterRouteHead(head, head.querySelector("meta[charset]"));
|
|
292
|
-
moveLayoutMetaAfterRouteHead(head, head.querySelector('meta[name="viewport"]'));
|
|
293
|
-
document.getElementById("_R_")?.remove();
|
|
294
|
-
normalizeReactFormActions(document);
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
function moveLayoutMetaAfterRouteHead(head: HTMLHeadElement, meta: Element | null): void {
|
|
298
|
-
if (meta == null) {
|
|
299
|
-
return;
|
|
300
|
-
}
|
|
301
|
-
const colorScheme = head.querySelector('meta[name="color-scheme"]');
|
|
302
|
-
if (colorScheme != null && colorScheme !== meta) {
|
|
303
|
-
head.insertBefore(meta, colorScheme);
|
|
304
|
-
return;
|
|
305
|
-
}
|
|
306
|
-
head.appendChild(meta);
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
const SERVER_FORM_PLACEHOLDER = "javascript:throw new Error('React form unexpectedly submitted.')";
|
|
310
|
-
const CLIENT_FORM_PLACEHOLDER =
|
|
311
|
-
"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().')";
|
|
312
|
-
|
|
313
|
-
function normalizeReactFormActions(document: Document): void {
|
|
314
|
-
for (const form of document.querySelectorAll("form")) {
|
|
315
|
-
if (form.getAttribute("action") === SERVER_FORM_PLACEHOLDER) {
|
|
316
|
-
form.setAttribute("action", CLIENT_FORM_PLACEHOLDER);
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
|
|
321
235
|
/**
|
|
322
236
|
* Render the current route into an empty shell.
|
|
323
237
|
*
|
|
@@ -354,6 +268,8 @@ export async function render(options: {|
|
|
|
354
268
|
readonly errors: RouteTable["errors"],
|
|
355
269
|
readonly strictMode?: boolean,
|
|
356
270
|
readonly navigation?: Navigation,
|
|
271
|
+
readonly basePath?: string,
|
|
272
|
+
readonly trailingSlash?: TrailingSlash,
|
|
357
273
|
|}): Promise<void> {
|
|
358
274
|
const table: RouteTable = {
|
|
359
275
|
routes: options.routes,
|
|
@@ -362,14 +278,17 @@ export async function render(options: {|
|
|
|
362
278
|
};
|
|
363
279
|
installRoutes(table);
|
|
364
280
|
installNavigation(options.navigation ?? "client");
|
|
281
|
+
installRouting({ basePath: options.basePath, trailingSlash: options.trailingSlash });
|
|
365
282
|
|
|
366
|
-
const url =
|
|
283
|
+
const url =
|
|
284
|
+
(applicationPathOf(window.location.pathname) ?? window.location.pathname) +
|
|
285
|
+
window.location.search;
|
|
367
286
|
let resolved;
|
|
368
287
|
try {
|
|
369
288
|
resolved = await resolveMatch(table, url);
|
|
370
289
|
} catch (error) {
|
|
371
290
|
if (error instanceof RedirectError) {
|
|
372
|
-
window.location.replace(error.to);
|
|
291
|
+
window.location.replace(addressOf(error.to));
|
|
373
292
|
return;
|
|
374
293
|
}
|
|
375
294
|
// The error boundary, chosen the same way the server chooses it. A throw
|
package/index.js
CHANGED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Internal to `@uniflowed/router`: `app.router.basePath` and
|
|
4
|
+
// `app.router.trailingSlash`, as the router reads them.
|
|
5
|
+
//
|
|
6
|
+
// The route table has no base in it — `/guide` is `/guide` whether the site is
|
|
7
|
+
// served at `/` or at `/docs` — and the browser's address bar has one. This
|
|
8
|
+
// module is the one place those two spellings meet: a `Link` and a navigation
|
|
9
|
+
// turn an application path into the address, and hydration, `popstate` and a
|
|
10
|
+
// refresh turn the address back into an application path before the table is
|
|
11
|
+
// asked. Every other part of the router speaks application paths.
|
|
12
|
+
//
|
|
13
|
+
// The policy decides the spelling of a path a link writes, so that a page is
|
|
14
|
+
// linked by the address its server answers without a redirect. The server's
|
|
15
|
+
// half — the `308` for the other spelling, and the `404` outside the base — is
|
|
16
|
+
// `@uniflowed/server`'s `internal/routing.js`.
|
|
17
|
+
//
|
|
18
|
+
// # Installed, like navigation
|
|
19
|
+
//
|
|
20
|
+
// Module state beside `installNavigation`, set once by the entry that started
|
|
21
|
+
// the application: `virtual:uf/client` in the browser and `virtual:uf/server`
|
|
22
|
+
// in the graph that renders documents. `@uniflowed/vite` writes both calls from
|
|
23
|
+
// `uf.config.js`, so a dev server and a build link the same way. A test that
|
|
24
|
+
// installs nothing gets the root and `"ignore"`, which is what every
|
|
25
|
+
// application had before the setting existed.
|
|
26
|
+
|
|
27
|
+
/** Which spelling of a path is the page; see `app.router.trailingSlash`. */
|
|
28
|
+
export type TrailingSlash = "never" | "always" | "ignore";
|
|
29
|
+
|
|
30
|
+
/** What an entry installs. */
|
|
31
|
+
export type RoutingSettings = {|
|
|
32
|
+
readonly basePath?: string,
|
|
33
|
+
readonly trailingSlash?: TrailingSlash,
|
|
34
|
+
|};
|
|
35
|
+
|
|
36
|
+
let installedBase: string = "";
|
|
37
|
+
let installedSlash: TrailingSlash = "ignore";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Say where this application is served and how its paths are spelled. Called
|
|
41
|
+
* once, by the entry that starts it.
|
|
42
|
+
*/
|
|
43
|
+
export function installRouting(settings: RoutingSettings): void {
|
|
44
|
+
installedBase = normalizeBase(settings.basePath ?? "");
|
|
45
|
+
installedSlash = settings.trailingSlash ?? "ignore";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The path this application is served under: `""` at the root, `"/docs"`
|
|
50
|
+
* otherwise.
|
|
51
|
+
*
|
|
52
|
+
* For code that builds an absolute address the router did not build for it —
|
|
53
|
+
* a middleware's `Response.redirect(new URL(`${basePath()}/sign-in`, request.url))`
|
|
54
|
+
* — because a route handler and a middleware are handed the application path,
|
|
55
|
+
* and an address built from `/sign-in` alone leaves the base behind.
|
|
56
|
+
*/
|
|
57
|
+
export function basePath(): string {
|
|
58
|
+
return installedBase;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** How this application spells a path. */
|
|
62
|
+
export function trailingSlash(): TrailingSlash {
|
|
63
|
+
return installedSlash;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The address for an application path: the base in front, the policy's
|
|
68
|
+
* spelling, the query and the fragment kept.
|
|
69
|
+
*
|
|
70
|
+
* Only a path that starts with a single `/` is an application path. Anything
|
|
71
|
+
* else — `https://…`, `//cdn…`, `mailto:`, `?page=2`, `#top`, `../up` — is
|
|
72
|
+
* returned as it was written, because the browser resolves it against the
|
|
73
|
+
* address that is already there.
|
|
74
|
+
*/
|
|
75
|
+
export function addressOf(to: string): string {
|
|
76
|
+
if (!to.startsWith("/") || to.startsWith("//")) {
|
|
77
|
+
return to;
|
|
78
|
+
}
|
|
79
|
+
const { path, rest } = splitPath(to);
|
|
80
|
+
return `${installedBase}${spellPath(path, installedSlash, installedBase !== "")}${rest}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The application path for an address's pathname, or `null` when the address
|
|
85
|
+
* is outside the base.
|
|
86
|
+
*
|
|
87
|
+
* `/docs/guide` is `/guide` under `/docs`, and `/docs` and `/docs/` are both
|
|
88
|
+
* `/`. `/docsx` is not under `/docs`: a base is whole segments.
|
|
89
|
+
*/
|
|
90
|
+
export function applicationPathOf(pathname: string): string | null {
|
|
91
|
+
if (installedBase === "") {
|
|
92
|
+
return pathname;
|
|
93
|
+
}
|
|
94
|
+
if (pathname === installedBase) {
|
|
95
|
+
return "/";
|
|
96
|
+
}
|
|
97
|
+
if (pathname.startsWith(`${installedBase}/`)) {
|
|
98
|
+
return pathname.slice(installedBase.length);
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* A browser pathname in this application's spelling: the base kept, the
|
|
105
|
+
* application path under it spelled by the policy. A pathname outside the
|
|
106
|
+
* base is returned as it was.
|
|
107
|
+
*
|
|
108
|
+
* For an address the router read back rather than wrote — the document path a
|
|
109
|
+
* payload URL names has lost its trailing slash, and the history entry a
|
|
110
|
+
* navigation writes should be the address the server answers without a
|
|
111
|
+
* redirect.
|
|
112
|
+
*/
|
|
113
|
+
export function canonicalAddress(pathname: string): string {
|
|
114
|
+
const application = applicationPathOf(pathname);
|
|
115
|
+
if (application == null) {
|
|
116
|
+
return pathname;
|
|
117
|
+
}
|
|
118
|
+
const spelled = spellPath(application, installedSlash, installedBase !== "");
|
|
119
|
+
return `${installedBase}${spelled}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* `path` in `policy`'s spelling.
|
|
124
|
+
*
|
|
125
|
+
* The root of an application at the root is `/` whatever the policy says. The
|
|
126
|
+
* root of an application under a base is the base itself — `/docs` — unless
|
|
127
|
+
* the policy is `"always"`, which spells it `/docs/`. A path whose last segment
|
|
128
|
+
* looks like a file keeps what it was written with, because `/robots.txt/` is
|
|
129
|
+
* not a page.
|
|
130
|
+
*/
|
|
131
|
+
export function spellPath(path: string, policy: TrailingSlash, underBase: boolean): string {
|
|
132
|
+
let end = path.length;
|
|
133
|
+
while (end > 0 && path.charCodeAt(end - 1) === 47) {
|
|
134
|
+
end -= 1;
|
|
135
|
+
}
|
|
136
|
+
const trimmed = path.slice(0, end);
|
|
137
|
+
if (trimmed === "") {
|
|
138
|
+
return policy === "always" || !underBase ? "/" : "";
|
|
139
|
+
}
|
|
140
|
+
if (policy === "ignore" || looksLikeAFile(trimmed)) {
|
|
141
|
+
return path;
|
|
142
|
+
}
|
|
143
|
+
return policy === "always" ? `${trimmed}/` : trimmed;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Whether a path's last segment has an extension. */
|
|
147
|
+
function looksLikeAFile(path: string): boolean {
|
|
148
|
+
const last = path.slice(path.lastIndexOf("/") + 1);
|
|
149
|
+
return last.includes(".");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function splitPath(to: string): {| readonly path: string, readonly rest: string |} {
|
|
153
|
+
let end = to.length;
|
|
154
|
+
for (let index = 0; index < to.length; index += 1) {
|
|
155
|
+
const code = to.charCodeAt(index);
|
|
156
|
+
// `?` and `#`.
|
|
157
|
+
if (code === 63 || code === 35) {
|
|
158
|
+
end = index;
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return { path: to.slice(0, end), rest: to.slice(end) };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* A base as `uf_config` accepts one, with a trailing slash forgiven: `""`,
|
|
167
|
+
* `"/"` and absent are the root.
|
|
168
|
+
*/
|
|
169
|
+
function normalizeBase(base: string): string {
|
|
170
|
+
let end = base.length;
|
|
171
|
+
while (end > 0 && base.charCodeAt(end - 1) === 47) {
|
|
172
|
+
end -= 1;
|
|
173
|
+
}
|
|
174
|
+
return base.slice(0, end);
|
|
175
|
+
}
|
|
@@ -26,11 +26,21 @@
|
|
|
26
26
|
import { createFromFetch, createFromReadableStream } from "react-server-dom-parcel/client.browser";
|
|
27
27
|
|
|
28
28
|
import { FLIGHT_CHUNK_ATTRIBUTE, flightChunkBytes } from "./flight-chunks.js";
|
|
29
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
FLIGHT_CONTENT_TYPE,
|
|
31
|
+
type FetchedFlight,
|
|
32
|
+
type FlightRoot,
|
|
33
|
+
documentPathOf,
|
|
34
|
+
flightUrl,
|
|
35
|
+
} from "./flight.js";
|
|
36
|
+
import { requireServerComponentsReact } from "./react-version.js";
|
|
30
37
|
|
|
31
38
|
/** A module namespace, as far as the loader looks into one. */
|
|
32
39
|
type ModuleNamespace = { +[string]: mixed };
|
|
33
40
|
|
|
41
|
+
/** The entry a refusal names: the one an application reaches this module through. */
|
|
42
|
+
const ENTRY = "@uniflowed/router/rsc/client";
|
|
43
|
+
|
|
34
44
|
/**
|
|
35
45
|
* Install the module hook React's Flight client resolves references through.
|
|
36
46
|
*
|
|
@@ -39,6 +49,7 @@ type ModuleNamespace = { +[string]: mixed };
|
|
|
39
49
|
* as an accessor cannot be assigned to, and hydration must not throw over it.
|
|
40
50
|
*/
|
|
41
51
|
export function installBrowserModules(): void {
|
|
52
|
+
requireServerComponentsReact(ENTRY);
|
|
42
53
|
const loaded: Map<string, ModuleNamespace> = new Map();
|
|
43
54
|
// A function with three properties, which is the shape React's Parcel client
|
|
44
55
|
// calls: `parcelRequire(id)` for a module, `parcelRequire.load(url)` for the
|
|
@@ -156,27 +167,10 @@ export function readDocumentPayload(
|
|
|
156
167
|
document: DocumentLike,
|
|
157
168
|
observe: ?(callback: () => void) => (() => void) | null,
|
|
158
169
|
): Promise<FlightRoot> {
|
|
170
|
+
requireServerComponentsReact(ENTRY);
|
|
159
171
|
return createFromReadableStream(documentPayload(document, observe));
|
|
160
172
|
}
|
|
161
173
|
|
|
162
|
-
/** What fetching a route's payload turned into. */
|
|
163
|
-
export type FetchedFlight =
|
|
164
|
-
| {|
|
|
165
|
-
readonly kind: "flight",
|
|
166
|
-
/** The route the server answered for: a redirect's target, when there was one. */
|
|
167
|
-
readonly url: string,
|
|
168
|
-
readonly root: Promise<FlightRoot>,
|
|
169
|
-
|}
|
|
170
|
-
| {|
|
|
171
|
-
/**
|
|
172
|
-
* The answer was not a payload: a redirect off this origin, or a host
|
|
173
|
-
* that had no payload for the URL. The browser should load `url` as a
|
|
174
|
-
* document.
|
|
175
|
-
*/
|
|
176
|
-
readonly kind: "document",
|
|
177
|
-
readonly url: string,
|
|
178
|
-
|};
|
|
179
|
-
|
|
180
174
|
/**
|
|
181
175
|
* Fetch `url`'s payload.
|
|
182
176
|
*
|
|
@@ -193,6 +187,10 @@ export type FetchedFlight =
|
|
|
193
187
|
* refusal — is a document, whatever its status.
|
|
194
188
|
*/
|
|
195
189
|
export async function fetchFlight(url: string): Promise<FetchedFlight> {
|
|
190
|
+
// Outside the `try` below, which answers every failure to fetch with a
|
|
191
|
+
// document load: too old a React is not a network failure, and a navigation
|
|
192
|
+
// that quietly reloaded the page would hide it.
|
|
193
|
+
requireServerComponentsReact(ENTRY);
|
|
196
194
|
let response: Response;
|
|
197
195
|
try {
|
|
198
196
|
response = await fetch(flightUrl(url), {
|
package/internal/flight-ssr.js
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
import { createFromReadableStream } from "react-server-dom-parcel/client.edge";
|
|
26
26
|
|
|
27
27
|
import type { FlightRoot } from "./flight.js";
|
|
28
|
+
import { requireServerComponentsReact } from "./react-version.js";
|
|
28
29
|
|
|
29
30
|
/** A module namespace, as far as the hook looks into one. */
|
|
30
31
|
type ModuleNamespace = { +[string]: mixed };
|
|
@@ -32,6 +33,9 @@ type ModuleNamespace = { +[string]: mixed };
|
|
|
32
33
|
/** The server copy of the client module at a browser chunk URL. */
|
|
33
34
|
export type ClientModuleLoader = (url: string) => Promise<ModuleNamespace>;
|
|
34
35
|
|
|
36
|
+
/** The entry a refusal names: the one an application reaches this module through. */
|
|
37
|
+
const ENTRY = "@uniflowed/router/rsc/ssr";
|
|
38
|
+
|
|
35
39
|
/**
|
|
36
40
|
* Install the module hook React's Flight client resolves references through.
|
|
37
41
|
*
|
|
@@ -40,6 +44,7 @@ export type ClientModuleLoader = (url: string) => Promise<ModuleNamespace>;
|
|
|
40
44
|
* stale one.
|
|
41
45
|
*/
|
|
42
46
|
export function installServerModules(load: ClientModuleLoader): void {
|
|
47
|
+
requireServerComponentsReact(ENTRY);
|
|
43
48
|
const loaded: Map<string, ModuleNamespace> = new Map();
|
|
44
49
|
// The browser's hook, on a server; `./flight-browser.js` has the shape.
|
|
45
50
|
function parcelRequire(id: string): ModuleNamespace {
|
|
@@ -68,5 +73,6 @@ export function installServerModules(load: ClientModuleLoader): void {
|
|
|
68
73
|
|
|
69
74
|
/** Read a payload into its root value, as React's client does in the browser. */
|
|
70
75
|
export function readPayload(stream: ReadableStream<Uint8Array>): Promise<FlightRoot> {
|
|
76
|
+
requireServerComponentsReact(ENTRY);
|
|
71
77
|
return createFromReadableStream(stream);
|
|
72
78
|
}
|
package/internal/flight.js
CHANGED
|
@@ -60,6 +60,32 @@ export type FlightRoot = {|
|
|
|
60
60
|
readonly tree: Node,
|
|
61
61
|
|};
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* What fetching a route's payload turned into.
|
|
65
|
+
*
|
|
66
|
+
* Declared here rather than beside the fetch in `./flight-browser.js`, because
|
|
67
|
+
* the router's navigation names this type and must not import React's Flight
|
|
68
|
+
* client: `./runtime.js` is in every application's bundle, and that client is
|
|
69
|
+
* only in the bundles of applications that render Server Components
|
|
70
|
+
* (ubugeeei-prod/uf#992).
|
|
71
|
+
*/
|
|
72
|
+
export type FetchedFlight =
|
|
73
|
+
| {|
|
|
74
|
+
readonly kind: "flight",
|
|
75
|
+
/** The route the server answered for: a redirect's target, when there was one. */
|
|
76
|
+
readonly url: string,
|
|
77
|
+
readonly root: Promise<FlightRoot>,
|
|
78
|
+
|}
|
|
79
|
+
| {|
|
|
80
|
+
/**
|
|
81
|
+
* The answer was not a payload: a redirect off this origin, or a host
|
|
82
|
+
* that had no payload for the URL. The browser should load `url` as a
|
|
83
|
+
* document.
|
|
84
|
+
*/
|
|
85
|
+
readonly kind: "document",
|
|
86
|
+
readonly url: string,
|
|
87
|
+
|};
|
|
88
|
+
|
|
63
89
|
/** The part of a resolved route that crosses to the browser. */
|
|
64
90
|
export function routeState(resolved: ResolvedRoute): RouteState {
|
|
65
91
|
return {
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Internal to `@uniflowed/router`: a server's document, made ready for React to
|
|
4
|
+
// hydrate.
|
|
5
|
+
//
|
|
6
|
+
// Where a server wrote the head's metadata, an element React leaves behind, and
|
|
7
|
+
// the placeholder action React writes into a form whose action is a function
|
|
8
|
+
// (its server and its client spell that placeholder differently): none of these
|
|
9
|
+
// comes from a route, and each would be reported as a mismatch. So each is put
|
|
10
|
+
// right before `hydrateRoot` compares the markup with the tree. Both ways of
|
|
11
|
+
// hydrating do it — `hydrate` in `../client.js`, for a route rendered from its
|
|
12
|
+
// modules, and `hydrateFlight` in `../rsc-client.js`, for one React Server
|
|
13
|
+
// Components rendered — so the code lives here rather than in either entry.
|
|
14
|
+
|
|
15
|
+
export function prepareDocumentForHydration(document: Document): void {
|
|
16
|
+
const head = document.head;
|
|
17
|
+
const envelope = head.querySelector('meta[name="uf:render"]');
|
|
18
|
+
if (envelope != null && head.firstChild !== envelope) {
|
|
19
|
+
head.insertBefore(envelope, head.firstChild);
|
|
20
|
+
}
|
|
21
|
+
moveLayoutMetaAfterRouteHead(head, head.querySelector("meta[charset]"));
|
|
22
|
+
moveLayoutMetaAfterRouteHead(head, head.querySelector('meta[name="viewport"]'));
|
|
23
|
+
document.getElementById("_R_")?.remove();
|
|
24
|
+
normalizeReactFormActions(document);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function moveLayoutMetaAfterRouteHead(head: HTMLHeadElement, meta: Element | null): void {
|
|
28
|
+
if (meta == null) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const colorScheme = head.querySelector('meta[name="color-scheme"]');
|
|
32
|
+
if (colorScheme != null && colorScheme !== meta) {
|
|
33
|
+
head.insertBefore(meta, colorScheme);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
head.appendChild(meta);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const SERVER_FORM_PLACEHOLDER = "javascript:throw new Error('React form unexpectedly submitted.')";
|
|
40
|
+
const CLIENT_FORM_PLACEHOLDER =
|
|
41
|
+
"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().')";
|
|
42
|
+
|
|
43
|
+
function normalizeReactFormActions(document: Document): void {
|
|
44
|
+
for (const form of document.querySelectorAll("form")) {
|
|
45
|
+
if (form.getAttribute("action") === SERVER_FORM_PLACEHOLDER) {
|
|
46
|
+
form.setAttribute("action", CLIENT_FORM_PLACEHOLDER);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Internal to `@uniflowed/router`: the React that React Server Components need.
|
|
4
|
+
//
|
|
5
|
+
// The router installs beside React 19.2.3, the React that Expo SDK 57 and React
|
|
6
|
+
// Native 0.87 ship. Matching a URL, the native route table and a route rendered
|
|
7
|
+
// from its modules need nothing newer (ubugeeei-prod/uf#992). React Server
|
|
8
|
+
// Components do. They render through `react-server-dom-parcel`, React's own
|
|
9
|
+
// Flight renderer and client, which is released with React and requires the
|
|
10
|
+
// React it was released with: 19.3. So that package is an optional peer, and
|
|
11
|
+
// every module that loads it asks here first.
|
|
12
|
+
//
|
|
13
|
+
// # Why a check at run time
|
|
14
|
+
//
|
|
15
|
+
// A peer range cannot say it. The router has one range for `react`, and that
|
|
16
|
+
// range has to admit 19.2.3 for a native app, while an optional peer that is
|
|
17
|
+
// absent is never compared with anything. The failure is also not where the
|
|
18
|
+
// mistake is: React's Flight client imports against React 19.2 and fails later,
|
|
19
|
+
// inside a render, in terms of React's internals. So each entry that loads
|
|
20
|
+
// Flight refuses before it does anything, and names the React it found and the
|
|
21
|
+
// one it needs.
|
|
22
|
+
//
|
|
23
|
+
// It is a call rather than a statement at module scope, because no shipped
|
|
24
|
+
// module runs anything when it is imported
|
|
25
|
+
// (`crates/uf_lib/tests/package_surface.rs`).
|
|
26
|
+
|
|
27
|
+
import * as React from "react";
|
|
28
|
+
|
|
29
|
+
/** The oldest React that React Server Components render on. */
|
|
30
|
+
export const SERVER_COMPONENTS_REACT: string = "19.3.0";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Why `entry` cannot run on React `installed`, or `null` when it can.
|
|
34
|
+
*
|
|
35
|
+
* The release numbers are compared and a prerelease tag is ignored, so a 19.3
|
|
36
|
+
* canary counts as 19.3: React names a canary after the release it leads to.
|
|
37
|
+
*/
|
|
38
|
+
export function serverComponentsRefusal(entry: string, installed: string): string | null {
|
|
39
|
+
const found = releaseOf(installed);
|
|
40
|
+
const needed = releaseOf(SERVER_COMPONENTS_REACT);
|
|
41
|
+
if (found != null && needed != null && !isBefore(found, needed)) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
return (
|
|
45
|
+
`@uniflowed/router: ${entry} needs React ${SERVER_COMPONENTS_REACT} or newer for React ` +
|
|
46
|
+
`Server Components, and the React it loaded is ${installed}. Install react, react-dom and ` +
|
|
47
|
+
"react-server-dom-parcel at ^19.3.0, or set `app.rsc: false` in uf.config.js to render " +
|
|
48
|
+
"routes from their modules, which the router supports from React 19.2.3."
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Refuse, with [`serverComponentsRefusal`]'s sentence, unless the React this
|
|
54
|
+
* module loaded can render React Server Components.
|
|
55
|
+
*/
|
|
56
|
+
export function requireServerComponentsReact(entry: string): void {
|
|
57
|
+
const refusal = serverComponentsRefusal(entry, React.version);
|
|
58
|
+
if (refusal != null) {
|
|
59
|
+
throw new Error(refusal);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** `[major, minor, patch]` of a version, or `null` when it does not start with one. */
|
|
64
|
+
function releaseOf(version: string): [number, number, number] | null {
|
|
65
|
+
const match = /^(\d+)\.(\d+)\.(\d+)/.exec(version);
|
|
66
|
+
return match == null ? null : [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Whether release `a` comes before release `b`. */
|
|
70
|
+
function isBefore(a: [number, number, number], b: [number, number, number]): boolean {
|
|
71
|
+
for (let index = 0; index < 3; index += 1) {
|
|
72
|
+
if (a[index] !== b[index]) {
|
|
73
|
+
return a[index] < b[index];
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return false;
|
|
77
|
+
}
|