@uniflowed/router 0.0.0-alpha.35 → 0.0.0-alpha.39
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 +6 -0
- package/client.js +29 -105
- package/index.js +1 -0
- package/internal/base-path.js +175 -0
- package/internal/compose.js +12 -0
- package/internal/error-view.js +4 -0
- package/internal/flight-browser.js +17 -19
- package/internal/flight-chunks.js +32 -8
- package/internal/flight-ssr.js +6 -0
- package/internal/flight.js +26 -0
- package/internal/navigation-cache.js +144 -0
- package/internal/prepare-document.js +49 -0
- package/internal/react-version.js +77 -0
- package/internal/runtime.js +208 -26
- package/internal/server-route.js +6 -0
- package/internal/shell.js +115 -0
- package/internal/stream.js +111 -21
- package/middleware.js +144 -14
- package/package.json +12 -5
- package/rsc-client.js +120 -0
- package/rsc-ssr.js +440 -0
- package/rsc.js +11 -0
- package/server-components.js +4 -0
- package/server.js +20 -484
package/action.js
CHANGED
|
@@ -111,6 +111,7 @@ import {
|
|
|
111
111
|
decodeActionResult,
|
|
112
112
|
encodeActionArguments,
|
|
113
113
|
} from "./internal/action-wire.js";
|
|
114
|
+
import { clearNavigationCache } from "./internal/navigation-cache.js";
|
|
114
115
|
|
|
115
116
|
export type { ActionArgument, ActionValue } from "./internal/action-wire.js";
|
|
116
117
|
export {
|
|
@@ -264,6 +265,11 @@ export function createServerReference(id: string, name: string): ServerActionFun
|
|
|
264
265
|
headers,
|
|
265
266
|
body,
|
|
266
267
|
});
|
|
268
|
+
// A route a navigation kept may no longer show what this action wrote, and
|
|
269
|
+
// which routes is the server's to know, so every kept route is asked for
|
|
270
|
+
// again. Whatever the status: an action that failed part-way may have
|
|
271
|
+
// written before it failed. See `./internal/navigation-cache.js`.
|
|
272
|
+
clearNavigationCache();
|
|
267
273
|
if (!response.ok) {
|
|
268
274
|
throw new ServerActionError(name, response.status);
|
|
269
275
|
}
|
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,8 @@ import {
|
|
|
101
108
|
hasClientPage,
|
|
102
109
|
installNavigation,
|
|
103
110
|
installRoutes,
|
|
111
|
+
installRouting,
|
|
112
|
+
installStaleTime,
|
|
104
113
|
matchRoute,
|
|
105
114
|
resolveFailure,
|
|
106
115
|
resolveMatch,
|
|
@@ -108,7 +117,8 @@ import {
|
|
|
108
117
|
import { DATA_ID, ROOT_ID } from "./internal/document.js";
|
|
109
118
|
import { decodePayload } from "./internal/payload.js";
|
|
110
119
|
import { createPayloadReader, domObserver } from "./internal/payload-rows.js";
|
|
111
|
-
import {
|
|
120
|
+
import { prepareDocumentForHydration } from "./internal/prepare-document.js";
|
|
121
|
+
import { type TrailingSlash, addressOf, applicationPathOf } from "./internal/base-path.js";
|
|
112
122
|
|
|
113
123
|
/**
|
|
114
124
|
* Hydrate the current document.
|
|
@@ -124,6 +134,9 @@ export async function hydrate(options: {|
|
|
|
124
134
|
readonly errors: RouteTable["errors"],
|
|
125
135
|
readonly strictMode?: boolean,
|
|
126
136
|
readonly navigation?: Navigation,
|
|
137
|
+
readonly basePath?: string,
|
|
138
|
+
readonly trailingSlash?: TrailingSlash,
|
|
139
|
+
readonly staleTime?: number,
|
|
127
140
|
|}): Promise<void> {
|
|
128
141
|
const table: RouteTable = {
|
|
129
142
|
routes: options.routes,
|
|
@@ -136,15 +149,19 @@ export async function hydrate(options: {|
|
|
|
136
149
|
// `app.rendering.navigation` existed and what a hand-written entry still
|
|
137
150
|
// means: the default is the behaviour, not the absence of one.
|
|
138
151
|
installNavigation(options.navigation ?? "client");
|
|
152
|
+
installRouting({ basePath: options.basePath, trailingSlash: options.trailingSlash });
|
|
153
|
+
installStaleTime(options.staleTime ?? 0);
|
|
154
|
+
// The route table has no base path in it, and the address bar does.
|
|
155
|
+
const applicationPath = applicationPathOf(window.location.pathname) ?? window.location.pathname;
|
|
139
156
|
|
|
140
157
|
// Before the loader data is read and before `resolveMatch` is called: both
|
|
141
158
|
// would go looking for a page module that is not in this bundle.
|
|
142
|
-
const matched = matchRoute(table.routes,
|
|
159
|
+
const matched = matchRoute(table.routes, applicationPath);
|
|
143
160
|
if (matched != null && !hasClientPage(matched.route)) {
|
|
144
161
|
return;
|
|
145
162
|
}
|
|
146
163
|
|
|
147
|
-
const url =
|
|
164
|
+
const url = applicationPath + window.location.search;
|
|
148
165
|
// Row 0 of the payload, and the reader that will fill in the rows it refers
|
|
149
166
|
// to. Both before `hydrateRoot`, and in this order: `decodePayload` is what
|
|
150
167
|
// tells the reader which rows the page is waiting for, and `watch` is what
|
|
@@ -218,106 +235,6 @@ export async function hydrate(options: {|
|
|
|
218
235
|
}
|
|
219
236
|
}
|
|
220
237
|
|
|
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
238
|
/**
|
|
322
239
|
* Render the current route into an empty shell.
|
|
323
240
|
*
|
|
@@ -354,6 +271,9 @@ export async function render(options: {|
|
|
|
354
271
|
readonly errors: RouteTable["errors"],
|
|
355
272
|
readonly strictMode?: boolean,
|
|
356
273
|
readonly navigation?: Navigation,
|
|
274
|
+
readonly basePath?: string,
|
|
275
|
+
readonly trailingSlash?: TrailingSlash,
|
|
276
|
+
readonly staleTime?: number,
|
|
357
277
|
|}): Promise<void> {
|
|
358
278
|
const table: RouteTable = {
|
|
359
279
|
routes: options.routes,
|
|
@@ -362,14 +282,18 @@ export async function render(options: {|
|
|
|
362
282
|
};
|
|
363
283
|
installRoutes(table);
|
|
364
284
|
installNavigation(options.navigation ?? "client");
|
|
285
|
+
installRouting({ basePath: options.basePath, trailingSlash: options.trailingSlash });
|
|
286
|
+
installStaleTime(options.staleTime ?? 0);
|
|
365
287
|
|
|
366
|
-
const url =
|
|
288
|
+
const url =
|
|
289
|
+
(applicationPathOf(window.location.pathname) ?? window.location.pathname) +
|
|
290
|
+
window.location.search;
|
|
367
291
|
let resolved;
|
|
368
292
|
try {
|
|
369
293
|
resolved = await resolveMatch(table, url);
|
|
370
294
|
} catch (error) {
|
|
371
295
|
if (error instanceof RedirectError) {
|
|
372
|
-
window.location.replace(error.to);
|
|
296
|
+
window.location.replace(addressOf(error.to));
|
|
373
297
|
return;
|
|
374
298
|
}
|
|
375
299
|
// 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
|
+
}
|
package/internal/compose.js
CHANGED
|
@@ -412,6 +412,11 @@ component SlotView(slot: ResolvedSlot, pathname: string, searchParams: SearchPar
|
|
|
412
412
|
const at = slot.intercepted?.pathname ?? pathname;
|
|
413
413
|
const query = slot.intercepted?.searchParams ?? searchParams;
|
|
414
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
|
|
415
420
|
let element: React.Node = <Page params={slot.params} searchParams={query} data={undefined} />;
|
|
416
421
|
const templateContext = {
|
|
417
422
|
pathname: at,
|
|
@@ -425,6 +430,10 @@ component SlotView(slot: ResolvedSlot, pathname: string, searchParams: SearchPar
|
|
|
425
430
|
continue;
|
|
426
431
|
}
|
|
427
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
|
|
428
437
|
element = <Suspense fallback={<Fallback />}>{element}</Suspense>;
|
|
429
438
|
}
|
|
430
439
|
const errorBoundary = slot.errorBoundary;
|
|
@@ -439,6 +448,9 @@ component SlotView(slot: ResolvedSlot, pathname: string, searchParams: SearchPar
|
|
|
439
448
|
if (depth > 0) {
|
|
440
449
|
const Layout = layoutComponent(slot.layouts[depth - 1]);
|
|
441
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
|
|
442
454
|
<Layout {...slotsAt(slot.slots, depth, { pathname, searchParams })} params={slot.params}>
|
|
443
455
|
{element}
|
|
444
456
|
</Layout>
|
package/internal/error-view.js
CHANGED
|
@@ -78,6 +78,10 @@ component RouteErrorView(module: ?ErrorModule, error: RouteError, reset: () => v
|
|
|
78
78
|
return <DefaultRouteError error={error} reset={reset} />;
|
|
79
79
|
}
|
|
80
80
|
const Boundary = errorComponent(module);
|
|
81
|
+
// The error module's own export, looked up by route, for the same reason as
|
|
82
|
+
// the page component in `compose.js`: the same component on every render of
|
|
83
|
+
// that route, through a lookup the compiler cannot see into.
|
|
84
|
+
// uf-lint-disable-next-line react-compiler/static-components
|
|
81
85
|
return <Boundary error={error} reset={reset} />;
|
|
82
86
|
}
|
|
83
87
|
|
|
@@ -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), {
|
|
@@ -55,9 +55,33 @@ function elementJson(value: FlightChunk): string {
|
|
|
55
55
|
.replace(/\u2029/g, "\\u2029");
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
/**
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
/**
|
|
59
|
+
* The element for one chunk, or the end marker for `null`.
|
|
60
|
+
*
|
|
61
|
+
* `nonce` is this response's, and it is carried even though the element is
|
|
62
|
+
* `application/json` and therefore a data block the browser never executes.
|
|
63
|
+
* Two reasons, and neither is "in case the spec changes". A uf document's
|
|
64
|
+
* invariant is that every `<script>` in it carries the nonce — that is what
|
|
65
|
+
* `packages/router/streaming.test.js` pins, and an invariant with an exception
|
|
66
|
+
* in it is one a reader has to check every new script against. And these are
|
|
67
|
+
* written as text into a stream rather than rendered by React, so unlike the
|
|
68
|
+
* payload rows in `./runtime.js` there is no second render on the client to
|
|
69
|
+
* disagree with; the attribute costs a comparison nobody makes.
|
|
70
|
+
*/
|
|
71
|
+
export function flightChunkElement(chunk: FlightChunk, nonce?: string | null): string {
|
|
72
|
+
const carried = nonce == null ? "" : ` nonce="${escapeNonce(nonce)}"`;
|
|
73
|
+
return `<script type="application/json"${carried} ${FLIGHT_CHUNK_ATTRIBUTE}>${elementJson(chunk)}</script>`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A nonce, as an attribute value.
|
|
78
|
+
*
|
|
79
|
+
* uf's own nonces are base64 and hold none of these, so this is about the one a
|
|
80
|
+
* project supplied: the value reaches a document that a browser parses as
|
|
81
|
+
* HTML, and a `"` in it would end the attribute and open whatever follows.
|
|
82
|
+
*/
|
|
83
|
+
function escapeNonce(value: string): string {
|
|
84
|
+
return value.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
61
85
|
}
|
|
62
86
|
|
|
63
87
|
/**
|
|
@@ -74,7 +98,7 @@ export type ChunkEncoder = {|
|
|
|
74
98
|
readonly end: () => string,
|
|
75
99
|
|};
|
|
76
100
|
|
|
77
|
-
export function createChunkEncoder(): ChunkEncoder {
|
|
101
|
+
export function createChunkEncoder(nonce?: string | null): ChunkEncoder {
|
|
78
102
|
const strict = new TextDecoder("utf-8", { fatal: true });
|
|
79
103
|
let carry: Uint8Array = new Uint8Array(0);
|
|
80
104
|
|
|
@@ -87,17 +111,17 @@ export function createChunkEncoder(): ChunkEncoder {
|
|
|
87
111
|
}
|
|
88
112
|
const body = joined.subarray(0, complete);
|
|
89
113
|
try {
|
|
90
|
-
return flightChunkElement(strict.decode(body));
|
|
114
|
+
return flightChunkElement(strict.decode(body), nonce);
|
|
91
115
|
} catch {
|
|
92
|
-
return flightChunkElement({ bytes: base64(body) });
|
|
116
|
+
return flightChunkElement({ bytes: base64(body) }, nonce);
|
|
93
117
|
}
|
|
94
118
|
}
|
|
95
119
|
|
|
96
120
|
function end(): string {
|
|
97
121
|
const rest = carry;
|
|
98
122
|
carry = new Uint8Array(0);
|
|
99
|
-
const tail = rest.length === 0 ? "" : flightChunkElement({ bytes: base64(rest) });
|
|
100
|
-
return `${tail}${flightChunkElement(null)}`;
|
|
123
|
+
const tail = rest.length === 0 ? "" : flightChunkElement({ bytes: base64(rest) }, nonce);
|
|
124
|
+
return `${tail}${flightChunkElement(null, nonce)}`;
|
|
101
125
|
}
|
|
102
126
|
|
|
103
127
|
return { encode, end };
|
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 {
|