@uniflowed/router 0.0.0-alpha.46 → 0.0.0-alpha.47
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 +23 -0
- package/internal/deployment.js +160 -0
- package/internal/flight-browser.js +5 -1
- package/internal/flight-rows.js +135 -0
- package/internal/flight-ssr.js +16 -3
- package/internal/flight.js +9 -0
- package/internal/prepare-document.js +5 -0
- package/internal/runtime.js +48 -0
- package/internal/shell.js +10 -0
- package/internal/stream.js +155 -0
- package/package.json +3 -3
- package/rsc-ssr.js +196 -5
- package/rsc.js +8 -1
- package/server.js +44 -0
package/action.js
CHANGED
|
@@ -87,6 +87,16 @@
|
|
|
87
87
|
// all and falls through to the route handlers. The `415` answers a request
|
|
88
88
|
// that claims to be an action, which is the only kind that reaches it.
|
|
89
89
|
//
|
|
90
|
+
// # After a deploy
|
|
91
|
+
//
|
|
92
|
+
// A reference carries its build's id, and a new build mints new ids, so a tab
|
|
93
|
+
// left open across a deploy holds ids the live server has never heard of. The
|
|
94
|
+
// call names the page's build in `uf-deployment`; a server on another build
|
|
95
|
+
// answers `409` without running anything; and the reference then loads the
|
|
96
|
+
// page's document again — a hard navigation onto the live build — instead of
|
|
97
|
+
// throwing. The promise it returned never settles, because the page it was
|
|
98
|
+
// returned to is being replaced. See `./internal/deployment.js`.
|
|
99
|
+
//
|
|
90
100
|
// # What Flow checks, and where
|
|
91
101
|
//
|
|
92
102
|
// Flow reads `app/_actions/clicks.js`, not the reference the bundler
|
|
@@ -111,6 +121,7 @@ import {
|
|
|
111
121
|
decodeActionResult,
|
|
112
122
|
encodeActionArguments,
|
|
113
123
|
} from "./internal/action-wire.js";
|
|
124
|
+
import { loadDocument, refusedAsAnotherDeployment, withDeployment } from "./internal/deployment.js";
|
|
114
125
|
import { clearNavigationCache } from "./internal/navigation-cache.js";
|
|
115
126
|
|
|
116
127
|
export type { ActionArgument, ActionValue } from "./internal/action-wire.js";
|
|
@@ -251,6 +262,9 @@ export function createServerReference(id: string, name: string): ServerActionFun
|
|
|
251
262
|
// declines to track.
|
|
252
263
|
const headers: { [string]: string } = { "content-type": ACTION_CONTENT_TYPE };
|
|
253
264
|
headers[ACTION_HEADER] = id;
|
|
265
|
+
// Which build this page is, so a server on another build refuses the call
|
|
266
|
+
// rather than looking this id up in a table it was never in.
|
|
267
|
+
withDeployment(headers);
|
|
254
268
|
const response = await fetch(currentUrl(), {
|
|
255
269
|
method: "POST",
|
|
256
270
|
// Stated rather than left to the default, because the default is what a
|
|
@@ -270,6 +284,15 @@ export function createServerReference(id: string, name: string): ServerActionFun
|
|
|
270
284
|
// again. Whatever the status: an action that failed part-way may have
|
|
271
285
|
// written before it failed. See `./internal/navigation-cache.js`.
|
|
272
286
|
clearNavigationCache();
|
|
287
|
+
// The server is on another build: nothing ran, and nothing on this page
|
|
288
|
+
// can be called correctly any more. Load the page again, from the build
|
|
289
|
+
// that is live, and never settle — a rejection here would reach an error
|
|
290
|
+
// boundary for the moment before the document is replaced, and a result
|
|
291
|
+
// would be a lie. See `./internal/deployment.js`.
|
|
292
|
+
if (refusedAsAnotherDeployment(response)) {
|
|
293
|
+
loadDocument(currentUrl());
|
|
294
|
+
return new Promise<ActionValue | void>(() => {});
|
|
295
|
+
}
|
|
273
296
|
if (!response.ok) {
|
|
274
297
|
throw new ServerActionError(name, response.status);
|
|
275
298
|
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Internal to `@uniflowed/router`: which build this page is, and what it does
|
|
4
|
+
// when the server is on another one.
|
|
5
|
+
//
|
|
6
|
+
// A tab stays open across a deploy. The page in it was built by build N — its
|
|
7
|
+
// action ids, its chunk names, its copy of React — and after build N+1 goes
|
|
8
|
+
// live the server it talks to is N+1's. Three things can then go wrong, and
|
|
9
|
+
// this module is the browser's half of the answer to each:
|
|
10
|
+
//
|
|
11
|
+
// 1. **An action call.** N's action ids name nothing in N+1 (an id is an HMAC
|
|
12
|
+
// over a per-build secret), so the call cannot be answered — and must not
|
|
13
|
+
// be answered by guessing. The call names its build in the
|
|
14
|
+
// [`DEPLOYMENT_HEADER`]; a server on another build answers `409` without
|
|
15
|
+
// running anything; and the reference makes a hard navigation instead of
|
|
16
|
+
// throwing, so the reader lands on N+1's page and presses the button again
|
|
17
|
+
// on a page whose ids are right.
|
|
18
|
+
// 2. **A navigation.** N+1's payload names N+1's client chunks, and loading
|
|
19
|
+
// them into N's page would put a second React beside the first. A payload
|
|
20
|
+
// request names its build the same way and is refused the same way, and
|
|
21
|
+
// `./flight-browser.js` already turns any answer that is not a payload into
|
|
22
|
+
// a document load. A payload the build *prerendered* is a file, answered by
|
|
23
|
+
// a host that runs nothing, so the payload also says which build rendered
|
|
24
|
+
// it (`FlightRoot.deployment`), and the router loads the document instead
|
|
25
|
+
// of rendering one from another build ([`fromAnotherDeployment`]).
|
|
26
|
+
// 3. **A chunk.** `uf build` keeps the previous build's hashed files in the
|
|
27
|
+
// output directory for one more build, so a lazy chunk of N's is still
|
|
28
|
+
// there while N+1 is live and the page keeps working. Past that window — or
|
|
29
|
+
// on a host that did not keep them — the `import()` fails, and a
|
|
30
|
+
// navigation whose module will not load becomes a document load of the
|
|
31
|
+
// same URL rather than an error page ([`isChunkLoadFailure`]).
|
|
32
|
+
//
|
|
33
|
+
// The id is `<meta name="uf:deployment">` in the document's head, which
|
|
34
|
+
// `./shell.js` writes. `uf dev` writes none, and a page without one sends no
|
|
35
|
+
// header and compares nothing: there is no other build to be skewed against.
|
|
36
|
+
|
|
37
|
+
/** The request header a page names its build in, and a refusal names the server's in. */
|
|
38
|
+
export const DEPLOYMENT_HEADER: string = "uf-deployment";
|
|
39
|
+
|
|
40
|
+
/** The `<meta name>` the document carries the id under. */
|
|
41
|
+
export const DEPLOYMENT_META: string = "uf:deployment";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* What this page has read, once.
|
|
45
|
+
*
|
|
46
|
+
* `undefined` is "not read yet"; `null` is "read, and there is none". Read
|
|
47
|
+
* before hydration by [`rememberDeployment`], because an application that owns
|
|
48
|
+
* `<html>` hands its head to React, and the id is a fact about the document
|
|
49
|
+
* that arrived rather than about whatever the head holds later.
|
|
50
|
+
*/
|
|
51
|
+
let remembered: string | null | void;
|
|
52
|
+
|
|
53
|
+
/** The parts of a `Document` read here. */
|
|
54
|
+
type HeadLike = interface {
|
|
55
|
+
readonly querySelector: (selector: string) => ?interface {
|
|
56
|
+
readonly getAttribute: (name: string) => ?string,
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Read the page's build from its document and keep it.
|
|
62
|
+
*
|
|
63
|
+
* Called by both client entries before `hydrateRoot`, through
|
|
64
|
+
* `./prepare-document.js`.
|
|
65
|
+
*/
|
|
66
|
+
export function rememberDeployment(document: ?HeadLike): void {
|
|
67
|
+
const meta = document?.querySelector(`meta[name="${DEPLOYMENT_META}"]`);
|
|
68
|
+
const content = meta?.getAttribute("content");
|
|
69
|
+
remembered = content == null || content === "" ? null : content;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The build this page is, or `null` for a page that does not say. */
|
|
73
|
+
export function currentDeployment(): string | null {
|
|
74
|
+
if (remembered === undefined) {
|
|
75
|
+
rememberDeployment(typeof window === "undefined" ? null : window.document);
|
|
76
|
+
}
|
|
77
|
+
return remembered ?? null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Forget what was read; for a test that builds a second page in one process. */
|
|
81
|
+
export function forgetDeployment(): void {
|
|
82
|
+
remembered = undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Name this page's build on an outgoing request's headers, when it has one. */
|
|
86
|
+
export function withDeployment(headers: { [string]: string }): { [string]: string } {
|
|
87
|
+
const deployment = currentDeployment();
|
|
88
|
+
if (deployment != null) {
|
|
89
|
+
headers[DEPLOYMENT_HEADER] = deployment;
|
|
90
|
+
}
|
|
91
|
+
return headers;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Whether `response` is a server on another build refusing this page.
|
|
96
|
+
*
|
|
97
|
+
* The status and the header together: a `409` alone is an application's own
|
|
98
|
+
* answer to something, and only a front door refusing a build names the build
|
|
99
|
+
* it *is* on.
|
|
100
|
+
*/
|
|
101
|
+
export function refusedAsAnotherDeployment(response: Response): boolean {
|
|
102
|
+
if (response.status !== 409) return false;
|
|
103
|
+
const serving = response.headers.get(DEPLOYMENT_HEADER);
|
|
104
|
+
return serving != null && serving !== currentDeployment();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Whether a payload was rendered by another build than this page's.
|
|
109
|
+
*
|
|
110
|
+
* Only when both say: a payload from a server with no id (`uf dev`) and a page
|
|
111
|
+
* with none are the same build as far as anyone can tell.
|
|
112
|
+
*/
|
|
113
|
+
export function fromAnotherDeployment(rendered: ?string): boolean {
|
|
114
|
+
const current = currentDeployment();
|
|
115
|
+
return current != null && rendered != null && rendered !== "" && rendered !== current;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Whether `error` is a module that could not be fetched, rather than one that
|
|
120
|
+
* threw while it ran.
|
|
121
|
+
*
|
|
122
|
+
* An `import()` of a file the server no longer has rejects with a `TypeError`
|
|
123
|
+
* whose message is the browser's own and differs per engine; these are the
|
|
124
|
+
* three that ship, plus Vite's own message for a stylesheet its preload could
|
|
125
|
+
* not load. A module that threw while evaluating rejects with whatever it
|
|
126
|
+
* threw, and is an error for the route's boundary to show — reloading the page
|
|
127
|
+
* would only throw it again.
|
|
128
|
+
*/
|
|
129
|
+
export function isChunkLoadFailure(error: mixed): boolean {
|
|
130
|
+
if (!(error instanceof Error)) return false;
|
|
131
|
+
const message = error.message;
|
|
132
|
+
return (
|
|
133
|
+
message.includes("Failed to fetch dynamically imported module") ||
|
|
134
|
+
message.includes("error loading dynamically imported module") ||
|
|
135
|
+
message.includes("Importing a module script failed") ||
|
|
136
|
+
message.includes("Unable to preload CSS")
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Whether a route resolved to an error because one of its modules could not be
|
|
142
|
+
* fetched — `isChunkLoadFailure` applied to a resolution's error, which is where
|
|
143
|
+
* a navigation meets it. A route whose module threw is not one of these.
|
|
144
|
+
*/
|
|
145
|
+
export function failedOnAMissingChunk(
|
|
146
|
+
failed: ?{ readonly kind: string, readonly error?: mixed, ... },
|
|
147
|
+
): boolean {
|
|
148
|
+
return failed?.kind === "thrown" && isChunkLoadFailure(failed.error);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Leave this page for `href` by loading its document.
|
|
153
|
+
*
|
|
154
|
+
* `assign` rather than the history API, because the point is to stop running
|
|
155
|
+
* this build's code: the document that arrives is the current build's, with
|
|
156
|
+
* the current build's scripts.
|
|
157
|
+
*/
|
|
158
|
+
export function loadDocument(href: string): void {
|
|
159
|
+
window.location.assign(href);
|
|
160
|
+
}
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
|
|
26
26
|
import { createFromFetch, createFromReadableStream } from "react-server-dom-parcel/client.browser";
|
|
27
27
|
|
|
28
|
+
import { withDeployment } from "./deployment.js";
|
|
28
29
|
import { FLIGHT_CHUNK_ATTRIBUTE, flightChunkBytes } from "./flight-chunks.js";
|
|
29
30
|
import {
|
|
30
31
|
FLIGHT_CONTENT_TYPE,
|
|
@@ -196,7 +197,10 @@ export async function fetchFlight(
|
|
|
196
197
|
// document load: too old a React is not a network failure, and a navigation
|
|
197
198
|
// that quietly reloaded the page would hide it.
|
|
198
199
|
requireServerComponentsReact(ENTRY);
|
|
199
|
-
|
|
200
|
+
// With the page's build, so a server on another one refuses the request
|
|
201
|
+
// rather than answering with a payload that names chunks this page does not
|
|
202
|
+
// have. The refusal is not a payload, so it becomes a document load below.
|
|
203
|
+
const headers = new Headers(withDeployment({ accept: FLIGHT_CONTENT_TYPE }));
|
|
200
204
|
const interceptedFrom = options?.interceptedFrom;
|
|
201
205
|
if (interceptedFrom != null) {
|
|
202
206
|
headers.set(INTERCEPTED_FROM_HEADER, interceptedFrom);
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Internal to `@uniflowed/router`: a finished Flight payload, with the rows a
|
|
4
|
+
// partial prerender left for the request taken out.
|
|
5
|
+
//
|
|
6
|
+
// While `uf build` prerenders a page's static shell, a read of the request
|
|
7
|
+
// throws (`@uniflowed/server`'s `internal/partial.js` says why a throw), and
|
|
8
|
+
// React's Flight renderer writes an error row where that part of the tree would
|
|
9
|
+
// have been, carrying the digest the renderer's `onError` returned for it. Fed
|
|
10
|
+
// to the HTML renderer as it is, each of those rows is an error, and a
|
|
11
|
+
// `<Suspense>` boundary that meets an error in a prerender is written as one the
|
|
12
|
+
// browser has to render — not as a hole a server can finish.
|
|
13
|
+
//
|
|
14
|
+
// So the rows go. A row that never arrives is a part of the tree React's Flight
|
|
15
|
+
// client is still waiting for, and a boundary waiting on it when the prerender
|
|
16
|
+
// is stopped is exactly what React leaves as a hole in the shell. Everything
|
|
17
|
+
// else in the payload arrived, so everything else is rendered.
|
|
18
|
+
//
|
|
19
|
+
// # Reading the rows
|
|
20
|
+
//
|
|
21
|
+
// The wire format is React's, and this reads the framing of it and nothing
|
|
22
|
+
// more, following the state machine in React's own client
|
|
23
|
+
// (`react-server-dom-*/client`, `processBinaryChunk`): a row is a hexadecimal
|
|
24
|
+
// id, a colon, and then either a tag with a hexadecimal length, a comma and
|
|
25
|
+
// that many raw bytes — text and typed arrays, which may hold a newline — or
|
|
26
|
+
// everything up to the next newline. Only error rows are ever parsed further,
|
|
27
|
+
// and only as the JSON React wrote them.
|
|
28
|
+
|
|
29
|
+
/** The tags whose row is a length and raw bytes rather than a line. */
|
|
30
|
+
const SIZED: $ReadOnlySet<number> = new Set(
|
|
31
|
+
["T", "A", "O", "o", "b", "U", "S", "s", "L", "l", "G", "g", "M", "m", "V"].map((tag) =>
|
|
32
|
+
tag.charCodeAt(0),
|
|
33
|
+
),
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const COLON = 0x3a;
|
|
37
|
+
const COMMA = 0x2c;
|
|
38
|
+
const NEWLINE = 0x0a;
|
|
39
|
+
const ERROR_TAG = "E".charCodeAt(0);
|
|
40
|
+
|
|
41
|
+
/** One row's place in the payload. */
|
|
42
|
+
type Row = {| +start: number, +end: number, +tag: number, +body: number |};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Every row of a finished payload, in order.
|
|
46
|
+
*
|
|
47
|
+
* A payload that ends partway through a row is not one React finished writing,
|
|
48
|
+
* and is returned whole as its last row rather than guessed at.
|
|
49
|
+
*/
|
|
50
|
+
function rowsOf(bytes: Uint8Array): Array<Row> {
|
|
51
|
+
const rows: Array<Row> = [];
|
|
52
|
+
let at = 0;
|
|
53
|
+
while (at < bytes.length) {
|
|
54
|
+
const start = at;
|
|
55
|
+
const colon = bytes.indexOf(COLON, at);
|
|
56
|
+
if (colon === -1) {
|
|
57
|
+
rows.push({ start, end: bytes.length, tag: 0, body: bytes.length });
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
const tag = bytes[colon + 1];
|
|
61
|
+
if (SIZED.has(tag)) {
|
|
62
|
+
const comma = bytes.indexOf(COMMA, colon + 2);
|
|
63
|
+
if (comma === -1) {
|
|
64
|
+
rows.push({ start, end: bytes.length, tag: 0, body: bytes.length });
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
const length = Number.parseInt(
|
|
68
|
+
new TextDecoder().decode(bytes.subarray(colon + 2, comma)),
|
|
69
|
+
16,
|
|
70
|
+
);
|
|
71
|
+
const end = Math.min(comma + 1 + length, bytes.length);
|
|
72
|
+
rows.push({ start, end, tag, body: comma + 1 });
|
|
73
|
+
at = end;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const newline = bytes.indexOf(NEWLINE, colon + 1);
|
|
77
|
+
const end = newline === -1 ? bytes.length : newline + 1;
|
|
78
|
+
// A tag is one upper-case letter, `#`, `r` or `x`; anything else is the
|
|
79
|
+
// first byte of a model row, which has no tag.
|
|
80
|
+
const tagged = (tag > 64 && tag < 91) || tag === 0x23 || tag === 0x72 || tag === 0x78;
|
|
81
|
+
rows.push({ start, end, tag: tagged ? tag : 0, body: tagged ? colon + 2 : colon + 1 });
|
|
82
|
+
at = end;
|
|
83
|
+
}
|
|
84
|
+
return rows;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* `payload` without the error rows whose digest `left` recognises.
|
|
89
|
+
*
|
|
90
|
+
* Returns the payload itself, unchanged, when there is no such row.
|
|
91
|
+
*/
|
|
92
|
+
export function withoutErrorRows(
|
|
93
|
+
payload: Uint8Array,
|
|
94
|
+
left: (digest: string) => boolean,
|
|
95
|
+
): {| +payload: Uint8Array, +removed: number |} {
|
|
96
|
+
const decoder = new TextDecoder();
|
|
97
|
+
const kept: Array<Uint8Array> = [];
|
|
98
|
+
let removed = 0;
|
|
99
|
+
let length = 0;
|
|
100
|
+
for (const row of rowsOf(payload)) {
|
|
101
|
+
if (
|
|
102
|
+
row.tag === ERROR_TAG &&
|
|
103
|
+
left(digestOf(decoder.decode(payload.subarray(row.body, row.end))))
|
|
104
|
+
) {
|
|
105
|
+
removed += 1;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const bytes = payload.subarray(row.start, row.end);
|
|
109
|
+
kept.push(bytes);
|
|
110
|
+
length += bytes.byteLength;
|
|
111
|
+
}
|
|
112
|
+
if (removed === 0) {
|
|
113
|
+
return { payload, removed };
|
|
114
|
+
}
|
|
115
|
+
const joined = new Uint8Array(length);
|
|
116
|
+
let at = 0;
|
|
117
|
+
for (const bytes of kept) {
|
|
118
|
+
joined.set(bytes, at);
|
|
119
|
+
at += bytes.byteLength;
|
|
120
|
+
}
|
|
121
|
+
return { payload: joined, removed };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The digest an error row carries, or `""`. */
|
|
125
|
+
function digestOf(json: string): string {
|
|
126
|
+
try {
|
|
127
|
+
const parsed: mixed = JSON.parse(json);
|
|
128
|
+
if (parsed != null && typeof parsed === "object" && typeof parsed.digest === "string") {
|
|
129
|
+
return parsed.digest;
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
// Not React's JSON, so not a row this module took out.
|
|
133
|
+
}
|
|
134
|
+
return "";
|
|
135
|
+
}
|
package/internal/flight-ssr.js
CHANGED
|
@@ -71,8 +71,21 @@ export function installServerModules(load: ClientModuleLoader): void {
|
|
|
71
71
|
});
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
/**
|
|
75
|
-
|
|
74
|
+
/**
|
|
75
|
+
* Read a payload into its root value, as React's client does in the browser.
|
|
76
|
+
*
|
|
77
|
+
* `partial` is for a payload that is missing rows on purpose: a static shell's,
|
|
78
|
+
* with the parts a partial prerender left for the request taken out
|
|
79
|
+
* (`./flight-rows.js`). React's client then keeps waiting on those parts once
|
|
80
|
+
* the stream has ended, rather than failing every one of them with
|
|
81
|
+
* "Connection closed", so the HTML renderer leaves each as a hole.
|
|
82
|
+
*/
|
|
83
|
+
export function readPayload(
|
|
84
|
+
stream: ReadableStream<Uint8Array>,
|
|
85
|
+
options?: {| +partial?: boolean |},
|
|
86
|
+
): Promise<FlightRoot> {
|
|
76
87
|
requireServerComponentsReact(ENTRY);
|
|
77
|
-
return
|
|
88
|
+
return options?.partial === true
|
|
89
|
+
? createFromReadableStream(stream, { unstable_allowPartialStream: true })
|
|
90
|
+
: createFromReadableStream(stream);
|
|
78
91
|
}
|
package/internal/flight.js
CHANGED
|
@@ -59,6 +59,15 @@ export type RouteState = {|
|
|
|
59
59
|
export type FlightRoot = {|
|
|
60
60
|
readonly route: RouteState,
|
|
61
61
|
readonly tree: Node,
|
|
62
|
+
/**
|
|
63
|
+
* The build that rendered it, when the build has an id.
|
|
64
|
+
*
|
|
65
|
+
* In the payload rather than only in a response header, because the payload
|
|
66
|
+
* of a prerendered route is a file and a host that serves files runs nothing
|
|
67
|
+
* that could set one. A page on another build loads the document instead of
|
|
68
|
+
* rendering this; see `./deployment.js`.
|
|
69
|
+
*/
|
|
70
|
+
readonly deployment?: string | null,
|
|
62
71
|
|};
|
|
63
72
|
|
|
64
73
|
/** The intercepted URL a Flight payload rendered, and the page it rendered over. */
|
|
@@ -12,7 +12,12 @@
|
|
|
12
12
|
// modules, and `hydrateFlight` in `../rsc-client.js`, for one React Server
|
|
13
13
|
// Components rendered — so the code lives here rather than in either entry.
|
|
14
14
|
|
|
15
|
+
import { rememberDeployment } from "./deployment.js";
|
|
16
|
+
|
|
15
17
|
export function prepareDocumentForHydration(document: Document): void {
|
|
18
|
+
// Before React touches the head: which build this document is, read off the
|
|
19
|
+
// document that arrived. See `./deployment.js`.
|
|
20
|
+
rememberDeployment(document);
|
|
16
21
|
const head = document.head;
|
|
17
22
|
const envelope = head.querySelector('meta[name="uf:render"]');
|
|
18
23
|
if (envelope != null && head.firstChild !== envelope) {
|
package/internal/runtime.js
CHANGED
|
@@ -86,6 +86,12 @@ import {
|
|
|
86
86
|
routeState,
|
|
87
87
|
} from "./flight.js";
|
|
88
88
|
import { Head } from "./head.js";
|
|
89
|
+
import {
|
|
90
|
+
failedOnAMissingChunk,
|
|
91
|
+
fromAnotherDeployment,
|
|
92
|
+
isChunkLoadFailure,
|
|
93
|
+
loadDocument,
|
|
94
|
+
} from "./deployment.js";
|
|
89
95
|
import { addressOf, applicationPathOf, canonicalAddress } from "./base-path.js";
|
|
90
96
|
import {
|
|
91
97
|
clearNavigationCache,
|
|
@@ -727,6 +733,17 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
|
|
|
727
733
|
const nextResolved =
|
|
728
734
|
(intercepting ? await resolveInterception(routeTable(), origin, next) : null) ??
|
|
729
735
|
(await (routeNavigations.read(key) ?? keepRoute(key, resolveMatch(routeTable(), next))));
|
|
736
|
+
// A module that could not be fetched, rather than one that threw: the
|
|
737
|
+
// chunk is gone, which after a deploy means this page is a build the
|
|
738
|
+
// server no longer carries. The document is the live build's page for
|
|
739
|
+
// this URL, so that is what is loaded — an error boundary would be a page
|
|
740
|
+
// that works after a reload, reported as though it were broken. See
|
|
741
|
+
// `./deployment.js`.
|
|
742
|
+
if (failedOnAMissingChunk(nextResolved.error)) {
|
|
743
|
+
setPending(false);
|
|
744
|
+
loadDocument(target.href);
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
730
747
|
// An intercepted entry remembers where it was intercepted from, so back
|
|
731
748
|
// and forward can put the page underneath under it again. Every other
|
|
732
749
|
// entry is written the way it always was.
|
|
@@ -791,6 +808,13 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
|
|
|
791
808
|
return undefined;
|
|
792
809
|
}
|
|
793
810
|
const arrive = (nextResolved: ResolvedRoute) => {
|
|
811
|
+
// A chunk this page's build had and the server no longer does; the
|
|
812
|
+
// history entry has already moved, so reloading loads its document. See
|
|
813
|
+
// `navigateTo` above.
|
|
814
|
+
if (failedOnAMissingChunk(nextResolved.error)) {
|
|
815
|
+
window.location.reload();
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
794
818
|
// The back button is a navigation, and a navigation that animates in
|
|
795
819
|
// one direction and cuts in the other would read as a bug in the
|
|
796
820
|
// animation rather than as a decision.
|
|
@@ -1029,6 +1053,15 @@ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
|
|
|
1029
1053
|
}
|
|
1030
1054
|
const payload = fetched.root;
|
|
1031
1055
|
const nextRoot = await payload;
|
|
1056
|
+
// A payload another build rendered: a prerendered one, answered as a
|
|
1057
|
+
// file by a host that runs nothing and so refused nothing. Its tree names
|
|
1058
|
+
// that build's chunks, so it is not rendered here; the document is. See
|
|
1059
|
+
// `./deployment.js`.
|
|
1060
|
+
if (fromAnotherDeployment(nextRoot.deployment)) {
|
|
1061
|
+
setPending(false);
|
|
1062
|
+
loadDocument(target.href);
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1032
1065
|
// The URL the payload came from, which is a redirect's target when the
|
|
1033
1066
|
// route redirected: the history entry is where the visitor ended up.
|
|
1034
1067
|
// In the trailing-slash policy's spelling: a payload URL names its
|
|
@@ -1065,6 +1098,13 @@ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
|
|
|
1065
1098
|
}
|
|
1066
1099
|
} catch (error) {
|
|
1067
1100
|
setPending(false);
|
|
1101
|
+
// A client module the payload named could not be fetched: after a
|
|
1102
|
+
// deploy, a chunk the server no longer has. The document for the URL is
|
|
1103
|
+
// the live build's, so it is what the reader gets.
|
|
1104
|
+
if (isChunkLoadFailure(error)) {
|
|
1105
|
+
loadDocument(target.href);
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1068
1108
|
throw error;
|
|
1069
1109
|
}
|
|
1070
1110
|
};
|
|
@@ -1105,6 +1145,10 @@ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
|
|
|
1105
1145
|
const payload = fetched.root;
|
|
1106
1146
|
payload.then(
|
|
1107
1147
|
(nextRoot) => {
|
|
1148
|
+
if (fromAnotherDeployment(nextRoot.deployment)) {
|
|
1149
|
+
window.location.reload();
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1108
1152
|
withViewTransition(nextRoot.route.viewTransition, () => {
|
|
1109
1153
|
show(payload, nextRoot);
|
|
1110
1154
|
});
|
|
@@ -1177,6 +1221,10 @@ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
|
|
|
1177
1221
|
}
|
|
1178
1222
|
const payload = fetched.root;
|
|
1179
1223
|
const nextRoot = await payload;
|
|
1224
|
+
if (fromAnotherDeployment(nextRoot.deployment)) {
|
|
1225
|
+
window.location.reload();
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1180
1228
|
// No view transition: a refresh is the same URL rendered again. See
|
|
1181
1229
|
// `ModuleRouter`'s refresh.
|
|
1182
1230
|
startTransition(() => {
|
package/internal/shell.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import type { PrerenderResult, RenderAssets, RenderResult } from "../server.js";
|
|
15
15
|
import { addressOf } from "./base-path.js";
|
|
16
|
+
import { DEPLOYMENT_META } from "./deployment.js";
|
|
16
17
|
import { ROOT_ID } from "./document.js";
|
|
17
18
|
import type { RedirectError } from "./routing.js";
|
|
18
19
|
import { type DocumentShell, bodyOfText } from "./stream.js";
|
|
@@ -90,6 +91,15 @@ export function shellFor(assets: RenderAssets, nonce?: string | null): DocumentS
|
|
|
90
91
|
|
|
91
92
|
function headTags(assets: RenderAssets, nonce?: string | null): string {
|
|
92
93
|
let tags = "";
|
|
94
|
+
// Which build this document is, first, because everything after it is a URL
|
|
95
|
+
// that build wrote. The browser reads it once and names it on every action
|
|
96
|
+
// call and payload request, so a server on another build can refuse rather
|
|
97
|
+
// than answer with ids and chunks this page does not have. See
|
|
98
|
+
// `./deployment.js`.
|
|
99
|
+
const deployment = assets.deployment;
|
|
100
|
+
if (deployment != null && deployment !== "") {
|
|
101
|
+
tags += `<meta name="${DEPLOYMENT_META}" content="${escapeAttribute(deployment)}">`;
|
|
102
|
+
}
|
|
93
103
|
for (const href of assets.styles) {
|
|
94
104
|
tags += `<link rel="stylesheet" href="${escapeAttribute(href)}">`;
|
|
95
105
|
}
|
package/internal/stream.js
CHANGED
|
@@ -882,6 +882,161 @@ export async function prerenderDocument(node: React.Node, options: RenderOptions
|
|
|
882
882
|
).text();
|
|
883
883
|
}
|
|
884
884
|
|
|
885
|
+
/**
|
|
886
|
+
* A page's static shell, and what React needs to finish it per request.
|
|
887
|
+
*
|
|
888
|
+
* What `uf build` writes for a page it prerenders partially (`ppr`): the whole
|
|
889
|
+
* document React could render without a request, with each `<Suspense>`
|
|
890
|
+
* boundary that waited on one written as its fallback, and React's record of
|
|
891
|
+
* where those holes are.
|
|
892
|
+
*/
|
|
893
|
+
export type PrerenderedShell = {|
|
|
894
|
+
/**
|
|
895
|
+
* The document as far as a server sends it before it renders anything: the
|
|
896
|
+
* head, uf's tags in it, and every byte React finished. It stops where the
|
|
897
|
+
* request's part begins, which is before the closing tags.
|
|
898
|
+
*/
|
|
899
|
+
readonly html: string,
|
|
900
|
+
/**
|
|
901
|
+
* What follows the request's part: the closing tags of a document uf wraps
|
|
902
|
+
* around the application. Empty for a document the application renders
|
|
903
|
+
* itself, because React's `resume` writes `</body></html>` for that one.
|
|
904
|
+
*/
|
|
905
|
+
readonly close: string,
|
|
906
|
+
/** See [`Layout`]'s field of the same name. */
|
|
907
|
+
readonly rootDepth: number,
|
|
908
|
+
/**
|
|
909
|
+
* React's postponed state: which boundaries are holes and how to find them
|
|
910
|
+
* again. Plain JSON, which is what lets a build write it to a file and a
|
|
911
|
+
* server read it back. `null` when the prerender left nothing for the
|
|
912
|
+
* request, and `html` is then a whole document.
|
|
913
|
+
*/
|
|
914
|
+
readonly postponed: mixed,
|
|
915
|
+
|};
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* Prerender `node` to a static shell, leaving whatever is still waiting when
|
|
919
|
+
* `settle` resolves as a hole.
|
|
920
|
+
*
|
|
921
|
+
* React's `prerender` with an abort. A boundary whose content is waiting on
|
|
922
|
+
* something when the render is stopped is written as its fallback, and React
|
|
923
|
+
* returns its postponed state beside the markup; [`resumeDocument`] hands that
|
|
924
|
+
* state back to React per request and streams only what was left.
|
|
925
|
+
*
|
|
926
|
+
* `settle` decides what is waiting, and it can only ever make a hole larger:
|
|
927
|
+
* stopped early, a boundary that would have finished is a hole instead, which
|
|
928
|
+
* the request then renders — slower, never wrong. The router waits until the
|
|
929
|
+
* payload it feeds this render has been read and every module it names has
|
|
930
|
+
* loaded, so what is left waiting is what was left out on purpose.
|
|
931
|
+
*
|
|
932
|
+
* Like [`prerenderDocument`], it is never given a nonce: what it writes is a
|
|
933
|
+
* file every request is sent.
|
|
934
|
+
*/
|
|
935
|
+
export async function prerenderShell(
|
|
936
|
+
node: React.Node,
|
|
937
|
+
options: {|
|
|
938
|
+
readonly shell: DocumentShell,
|
|
939
|
+
readonly onError: (error: mixed) => void,
|
|
940
|
+
readonly settle: () => Promise<void>,
|
|
941
|
+
|},
|
|
942
|
+
): Promise<PrerenderedShell> {
|
|
943
|
+
const controller = new AbortController();
|
|
944
|
+
// The reason every hole is reported with when the render stops. Stopping is
|
|
945
|
+
// the point rather than a failure, so it is not passed on.
|
|
946
|
+
const stopped = new Error("@uniflowed/router: the static shell is complete");
|
|
947
|
+
const settings = {
|
|
948
|
+
onError: (error: mixed) => {
|
|
949
|
+
if (error !== stopped) options.onError(error);
|
|
950
|
+
},
|
|
951
|
+
signal: controller.signal,
|
|
952
|
+
};
|
|
953
|
+
const pending =
|
|
954
|
+
typeof ReactDOMStatic.prerenderToNodeStream === "function"
|
|
955
|
+
? ReactDOMStatic.prerenderToNodeStream(node, settings)
|
|
956
|
+
: ReactDOMStatic.prerender(node, settings);
|
|
957
|
+
await options.settle();
|
|
958
|
+
controller.abort(stopped);
|
|
959
|
+
const result = await pending;
|
|
960
|
+
const layout: Layout = { rootDepth: 0 };
|
|
961
|
+
const text = await bodyOf(
|
|
962
|
+
assembled(preludeChunks(result.prelude), options.shell, undefined, layout),
|
|
963
|
+
).text();
|
|
964
|
+
const postponed = result.postponed ?? null;
|
|
965
|
+
if (postponed == null) {
|
|
966
|
+
return { html: text, close: "", rootDepth: layout.rootDepth, postponed: null };
|
|
967
|
+
}
|
|
968
|
+
// A shell uf wrapped ends in uf's closing tags, which `resume` does not write;
|
|
969
|
+
// a document React wrote ends in its own, which `resume` writes again.
|
|
970
|
+
if (text.endsWith(options.shell.close)) {
|
|
971
|
+
return {
|
|
972
|
+
html: text.slice(0, text.length - options.shell.close.length),
|
|
973
|
+
close: options.shell.close,
|
|
974
|
+
rootDepth: layout.rootDepth,
|
|
975
|
+
postponed,
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
return {
|
|
979
|
+
html: text.replace(/<\/body>\s*<\/html>\s*$/i, ""),
|
|
980
|
+
close: "",
|
|
981
|
+
rootDepth: layout.rootDepth,
|
|
982
|
+
postponed,
|
|
983
|
+
};
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* A page's static shell, then what the request fills its holes with.
|
|
988
|
+
*
|
|
989
|
+
* The shell goes out as the first chunk, before React has rendered anything for
|
|
990
|
+
* this request — that is the whole of what partial prerendering buys, and it is
|
|
991
|
+
* why the shell is text the build wrote rather than a render. React's `resume`
|
|
992
|
+
* then renders the holes, and its output follows the shell unchanged: each
|
|
993
|
+
* hole's content in a hidden element and the script that moves it into place,
|
|
994
|
+
* which is what a streamed `<Suspense>` boundary always was.
|
|
995
|
+
*
|
|
996
|
+
* `payload` is the request's Flight payload, the one the holes were rendered
|
|
997
|
+
* from, and it is written into the document by [`interleaved`] under the same
|
|
998
|
+
* rules as a streamed render's. The shell ends between elements at the root's
|
|
999
|
+
* own level, so the payload may follow it as soon as it exists.
|
|
1000
|
+
*/
|
|
1001
|
+
export function resumeDocument(
|
|
1002
|
+
node: React.Node,
|
|
1003
|
+
shell: PrerenderedShell,
|
|
1004
|
+
options: {|
|
|
1005
|
+
readonly onError: (error: mixed) => void,
|
|
1006
|
+
readonly payload: ReadableStream<Uint8Array>,
|
|
1007
|
+
readonly nonce?: string | null,
|
|
1008
|
+
readonly onStream?: (record: StreamRecord) => void,
|
|
1009
|
+
|},
|
|
1010
|
+
): DocumentBody {
|
|
1011
|
+
const controller = new AbortController();
|
|
1012
|
+
// Begun now and awaited only after the shell has gone: the shell must not
|
|
1013
|
+
// wait for anything React does for this request, and React must not wait
|
|
1014
|
+
// for the shell to be read before it starts on the holes.
|
|
1015
|
+
const resuming: Promise<ByteSource> = ReactDOMServer.resume(node, shell.postponed, {
|
|
1016
|
+
onError: options.onError,
|
|
1017
|
+
signal: controller.signal,
|
|
1018
|
+
nonce: options.nonce ?? undefined,
|
|
1019
|
+
});
|
|
1020
|
+
// A resume that fails before its first byte fails the stream below, once it
|
|
1021
|
+
// is read; this keeps the rejection from being reported as unhandled while
|
|
1022
|
+
// the shell is still being written.
|
|
1023
|
+
resuming.catch(() => {});
|
|
1024
|
+
async function* chunks(): AsyncGenerator<string, void, void> {
|
|
1025
|
+
yield shell.html;
|
|
1026
|
+
yield* decoded(await resuming);
|
|
1027
|
+
if (shell.close !== "") {
|
|
1028
|
+
yield shell.close;
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
const layout: Layout = { rootDepth: shell.rootDepth };
|
|
1032
|
+
return bodyOf(
|
|
1033
|
+
outgoing(interleaved(chunks(), options.payload, options.nonce, layout), options.onStream),
|
|
1034
|
+
() => {
|
|
1035
|
+
controller.abort();
|
|
1036
|
+
},
|
|
1037
|
+
);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
885
1040
|
/**
|
|
886
1041
|
* What [`assembled`] learns about a document that [`interleaved`] needs.
|
|
887
1042
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniflowed/router",
|
|
3
|
-
"version": "0.0.0-alpha.
|
|
3
|
+
"version": "0.0.0-alpha.47",
|
|
4
4
|
"description": "The file-system router for Flow React applications: matching, layouts, loaders, navigation, server rendering and hydration.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
}
|
|
72
72
|
},
|
|
73
73
|
"dependencies": {
|
|
74
|
-
"@uniflowed/hooks": "0.0.0-alpha.
|
|
75
|
-
"@uniflowed/server": "0.0.0-alpha.
|
|
74
|
+
"@uniflowed/hooks": "0.0.0-alpha.47",
|
|
75
|
+
"@uniflowed/server": "0.0.0-alpha.47"
|
|
76
76
|
}
|
|
77
77
|
}
|
package/rsc-ssr.js
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
import * as React from "react";
|
|
23
23
|
|
|
24
24
|
import { addressOf } from "./internal/base-path.js";
|
|
25
|
+
import { withoutErrorRows } from "./internal/flight-rows.js";
|
|
25
26
|
import {
|
|
26
27
|
type ClientModuleLoader,
|
|
27
28
|
installServerModules,
|
|
@@ -32,10 +33,22 @@ import { type StreamRecord, streamReporter } from "./internal/inspector.js";
|
|
|
32
33
|
import { requireServerComponentsReact } from "./internal/react-version.js";
|
|
33
34
|
import { RedirectError } from "./internal/routing.js";
|
|
34
35
|
import type { AppProps } from "./internal/runtime.js";
|
|
35
|
-
import {
|
|
36
|
+
import {
|
|
37
|
+
currentNonce,
|
|
38
|
+
isPostponedRead,
|
|
39
|
+
newPartialPrerender,
|
|
40
|
+
runPartialPrerender,
|
|
41
|
+
} from "@uniflowed/server/host";
|
|
36
42
|
|
|
37
43
|
import { redirectDocument, redirectResult, shellFor } from "./internal/shell.js";
|
|
38
|
-
import {
|
|
44
|
+
import {
|
|
45
|
+
type DocumentBody,
|
|
46
|
+
type PrerenderedShell,
|
|
47
|
+
prerenderDocument,
|
|
48
|
+
prerenderShell,
|
|
49
|
+
renderDocument,
|
|
50
|
+
resumeDocument,
|
|
51
|
+
} from "./internal/stream.js";
|
|
39
52
|
import type { FlightRenderer } from "./rsc.js";
|
|
40
53
|
import type {
|
|
41
54
|
FlightResponse,
|
|
@@ -233,22 +246,34 @@ export function createDocumentRenderer(options: DocumentRendererOptions): Render
|
|
|
233
246
|
): Promise<PrerenderResult> {
|
|
234
247
|
const report = settings?.onError ?? (() => {});
|
|
235
248
|
const ledger = createErrorLedger();
|
|
249
|
+
// A read of the request, while the build may leave it for the request, is
|
|
250
|
+
// not an exception to report: it is how the build finds the holes. Its row
|
|
251
|
+
// carries a digest of its own, so the HTML renderer's copy of it is known
|
|
252
|
+
// too, and `./internal/flight-rows.js` can take the row out.
|
|
253
|
+
const partial = settings?.partial === true ? newPartialPrerender() : null;
|
|
254
|
+
let postponedRows = 0;
|
|
236
255
|
// The Flight renderer's copy of an exception is the one reported, because it
|
|
237
256
|
// is the exception as thrown; the HTML renderer's copy of the same one is
|
|
238
257
|
// recognised by its digest and dropped. See [`createErrorLedger`].
|
|
239
258
|
const onServerError = (error: mixed): string => {
|
|
259
|
+
if (partial != null && isPostponedRead(error)) {
|
|
260
|
+
postponedRows += 1;
|
|
261
|
+
return `${POSTPONED_DIGEST}${postponedRows}`;
|
|
262
|
+
}
|
|
240
263
|
report(error);
|
|
241
264
|
return ledger.record(error);
|
|
242
265
|
};
|
|
243
266
|
const onHtmlError = (error: mixed) => {
|
|
244
|
-
if (!ledger.isCopy(error)) {
|
|
267
|
+
if (!ledger.isCopy(error) && !isPostponedCopy(error)) {
|
|
245
268
|
report(error);
|
|
246
269
|
}
|
|
247
270
|
};
|
|
248
271
|
|
|
249
272
|
// `defer: false`, for the reason [`createRenderer`]'s prerender passes it:
|
|
250
273
|
// a file has no fallback to show first.
|
|
251
|
-
const
|
|
274
|
+
const flightOf = () => renderFlight(url, { defer: false, onError: onServerError });
|
|
275
|
+
const rendered =
|
|
276
|
+
partial == null ? await flightOf() : await runPartialPrerender(partial, flightOf);
|
|
252
277
|
if (rendered.kind === "redirect") {
|
|
253
278
|
return redirectResult(
|
|
254
279
|
redirectDocument(new RedirectError(rendered.location, rendered.status === 308)),
|
|
@@ -264,8 +289,22 @@ export function createDocumentRenderer(options: DocumentRendererOptions): Render
|
|
|
264
289
|
// fails the stream itself, and that is this route's failure like any
|
|
265
290
|
// other.
|
|
266
291
|
payload = await bytesOf(rendered.stream);
|
|
292
|
+
// A read made before anything rendered — in a loader — has no boundary
|
|
293
|
+
// around it at all.
|
|
294
|
+
if (partial != null && isPostponedRead(failure)) {
|
|
295
|
+
throw readOutsideSuspense(url, partial.reads);
|
|
296
|
+
}
|
|
297
|
+
if (partial != null && partial.reads.length > 0 && failure == null) {
|
|
298
|
+
const shell = await partialDocumentOf(payload, url, assets, onHtmlError, partial.reads);
|
|
299
|
+
if (shell != null) {
|
|
300
|
+
return { status, html: shell.html, error: undefined, shell };
|
|
301
|
+
}
|
|
302
|
+
}
|
|
267
303
|
html = await staticDocumentOf(payload, url, assets, onHtmlError);
|
|
268
304
|
} catch (error) {
|
|
305
|
+
if (error instanceof ReadOutsideSuspenseError) {
|
|
306
|
+
throw error;
|
|
307
|
+
}
|
|
269
308
|
const cause = ledger.originalOf(error);
|
|
270
309
|
if (cause instanceof RedirectError) {
|
|
271
310
|
return redirectResult(redirectDocument(cause));
|
|
@@ -296,6 +335,104 @@ export function createDocumentRenderer(options: DocumentRendererOptions): Render
|
|
|
296
335
|
return { status, html, error: failure, payload };
|
|
297
336
|
}
|
|
298
337
|
|
|
338
|
+
/**
|
|
339
|
+
* A page's static shell, for a payload whose render read the request.
|
|
340
|
+
*
|
|
341
|
+
* Two renders of the same payload. The first is the whole document, with an
|
|
342
|
+
* error where each read was, and it is there to prove one thing: that every
|
|
343
|
+
* read sits inside a `<Suspense>` boundary. A read outside one fails the
|
|
344
|
+
* shell itself, and the page is refused naming what it read, because a shell
|
|
345
|
+
* that has to wait for the request is not a shell. It also loads every client
|
|
346
|
+
* module the payload names, so the second render waits on nothing but the
|
|
347
|
+
* request.
|
|
348
|
+
*
|
|
349
|
+
* The second is the shell: the same payload with the rows the reads produced
|
|
350
|
+
* taken out, rendered until it has done everything it can and then stopped,
|
|
351
|
+
* so each boundary that is still waiting on one of those rows is a hole. It is
|
|
352
|
+
* `null` when that left no hole — a read whose result the page never rendered
|
|
353
|
+
* — and the page is then the plain document the first render already was.
|
|
354
|
+
*/
|
|
355
|
+
async function partialDocumentOf(
|
|
356
|
+
payload: Uint8Array,
|
|
357
|
+
url: string,
|
|
358
|
+
assets: RenderAssets,
|
|
359
|
+
onError: (error: mixed) => void,
|
|
360
|
+
reads: $ReadOnlyArray<string>,
|
|
361
|
+
): Promise<PrerenderedShell | null> {
|
|
362
|
+
try {
|
|
363
|
+
await staticDocumentOf(payload, url, assets, onError);
|
|
364
|
+
} catch (error) {
|
|
365
|
+
if (isPostponedCopy(error)) {
|
|
366
|
+
throw readOutsideSuspense(url, reads);
|
|
367
|
+
}
|
|
368
|
+
throw error;
|
|
369
|
+
}
|
|
370
|
+
const shellPayload = withoutErrorRows(payload, (digest) =>
|
|
371
|
+
digest.startsWith(POSTPONED_DIGEST),
|
|
372
|
+
).payload;
|
|
373
|
+
const shell = await prerenderShell(
|
|
374
|
+
<App url={url} flight={readPayload(streamOf(shellPayload), { partial: true })} />,
|
|
375
|
+
{ shell: shellFor(assets), onError, settle: settled },
|
|
376
|
+
);
|
|
377
|
+
return shell.postponed == null ? null : shell;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Answer `url` from its static shell: the shell now, and its holes as this
|
|
382
|
+
* request renders them.
|
|
383
|
+
*
|
|
384
|
+
* The route is resolved first, and that is the only thing the shell waits
|
|
385
|
+
* for: a redirect has to be the whole response, and a request that resolves
|
|
386
|
+
* to something other than the page the build prerendered — a `404`, an error
|
|
387
|
+
* boundary — is not answered with that page's shell. Either way it is
|
|
388
|
+
* rendered the way any other request is. Otherwise the whole route is
|
|
389
|
+
* rendered as Server Components for this request, the shell goes out, and
|
|
390
|
+
* React's `resume` renders the holes from the payload while the payload
|
|
391
|
+
* itself is written into the document for the browser to hydrate from.
|
|
392
|
+
*
|
|
393
|
+
* The payload is the request's whole one, not the shell's with the holes
|
|
394
|
+
* added: the browser hydrates the document from it, and the shell was
|
|
395
|
+
* rendered from the same modules without a request, so what it has in common
|
|
396
|
+
* with the shell is the same markup.
|
|
397
|
+
*/
|
|
398
|
+
async function resume(
|
|
399
|
+
url: string,
|
|
400
|
+
assets: RenderAssets,
|
|
401
|
+
shell: PrerenderedShell,
|
|
402
|
+
settings?: RenderOptions,
|
|
403
|
+
): Promise<RenderResult> {
|
|
404
|
+
const report = settings?.onError ?? (() => {});
|
|
405
|
+
const ledger = createErrorLedger();
|
|
406
|
+
const stop = new AbortController();
|
|
407
|
+
const rendered = await renderFlight(url, {
|
|
408
|
+
onError: (error: mixed) => {
|
|
409
|
+
report(error);
|
|
410
|
+
return ledger.record(error);
|
|
411
|
+
},
|
|
412
|
+
signal: stop.signal,
|
|
413
|
+
});
|
|
414
|
+
if (rendered.kind === "redirect") {
|
|
415
|
+
return redirectDocument(new RedirectError(rendered.location, rendered.status === 308));
|
|
416
|
+
}
|
|
417
|
+
if (rendered.status !== 200 || rendered.failure != null) {
|
|
418
|
+
stop.abort();
|
|
419
|
+
return render(url, assets, settings);
|
|
420
|
+
}
|
|
421
|
+
const send = settings?.onStream;
|
|
422
|
+
const [forHtml, forBrowser] = rendered.stream.tee();
|
|
423
|
+
const body = resumeDocument(<App url={url} flight={readPayload(forHtml)} />, shell, {
|
|
424
|
+
onError: (error: mixed) => {
|
|
425
|
+
if (!ledger.isCopy(error)) {
|
|
426
|
+
report(error);
|
|
427
|
+
}
|
|
428
|
+
},
|
|
429
|
+
payload: forBrowser,
|
|
430
|
+
nonce: currentNonce(),
|
|
431
|
+
onStream: send == null ? undefined : streamReporter(url, send),
|
|
432
|
+
});
|
|
433
|
+
return { status: 200, pipe: body.pipe, stream: body.stream, text: body.text };
|
|
434
|
+
}
|
|
435
|
+
|
|
299
436
|
/** A finished document for a finished payload, with the payload written into it. */
|
|
300
437
|
function staticDocumentOf(
|
|
301
438
|
payload: Uint8Array,
|
|
@@ -342,7 +479,61 @@ export function createDocumentRenderer(options: DocumentRendererOptions): Render
|
|
|
342
479
|
};
|
|
343
480
|
}
|
|
344
481
|
|
|
345
|
-
return { render, prerender, flight };
|
|
482
|
+
return { render, prerender, flight, resume };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* The digests a partial prerender gives the rows its reads of the request
|
|
487
|
+
* produced. React writes the digest into the row and onto the error its client
|
|
488
|
+
* rebuilds from it, which is how both copies are recognised.
|
|
489
|
+
*/
|
|
490
|
+
const POSTPONED_DIGEST = "uf:postponed:";
|
|
491
|
+
|
|
492
|
+
/** Whether `error` is the HTML renderer's copy of a read left for the request. */
|
|
493
|
+
function isPostponedCopy(error: mixed): boolean {
|
|
494
|
+
return digestOf(error)?.startsWith(POSTPONED_DIGEST) === true;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* A page whose render read the request where no `<Suspense>` boundary could
|
|
499
|
+
* leave the read for later.
|
|
500
|
+
*
|
|
501
|
+
* Thrown rather than resolved to the error boundary, because it is not the
|
|
502
|
+
* page's failure to render — the page would render fine with a request — but
|
|
503
|
+
* the build's refusal to write it, and `uf build` names the route with it.
|
|
504
|
+
*/
|
|
505
|
+
class ReadOutsideSuspenseError extends Error {
|
|
506
|
+
constructor(message: string) {
|
|
507
|
+
super(message);
|
|
508
|
+
this.name = "ReadOutsideSuspenseError";
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function readOutsideSuspense(url: string, reads: $ReadOnlyArray<string>): ReadOutsideSuspenseError {
|
|
513
|
+
const named = [...new Set(reads)].map((read) => `${read}()`).join(", ");
|
|
514
|
+
return new ReadOutsideSuspenseError(
|
|
515
|
+
`${url} reads ${named} outside any <Suspense> boundary, so it has no static shell to ` +
|
|
516
|
+
"prerender. Move the part of the page that reads it inside a <Suspense> boundary, which " +
|
|
517
|
+
'leaves that part for the request, or export `dynamic = "force-dynamic"` from the page to ' +
|
|
518
|
+
"render all of it per request.",
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Until the render reading a finished payload has done everything it can.
|
|
524
|
+
*
|
|
525
|
+
* Every row it will ever get is in memory and every module the payload names
|
|
526
|
+
* was loaded by the render before it, so what is left is React's own work,
|
|
527
|
+
* which it schedules as microtasks and a task to start. A few turns of the
|
|
528
|
+
* event loop is more than that takes; stopping early would only make a hole
|
|
529
|
+
* larger, never the document wrong. See `prerenderShell`.
|
|
530
|
+
*/
|
|
531
|
+
async function settled(): Promise<void> {
|
|
532
|
+
for (let turn = 0; turn < 4; turn += 1) {
|
|
533
|
+
await new Promise((resolve) => {
|
|
534
|
+
setTimeout(resolve, 0);
|
|
535
|
+
});
|
|
536
|
+
}
|
|
346
537
|
}
|
|
347
538
|
|
|
348
539
|
/**
|
package/rsc.js
CHANGED
|
@@ -152,6 +152,13 @@ export function createFlightRenderer(options: {|
|
|
|
152
152
|
readonly routes: RouteTable["routes"],
|
|
153
153
|
readonly notFound: RouteTable["notFound"],
|
|
154
154
|
readonly errors: RouteTable["errors"],
|
|
155
|
+
/**
|
|
156
|
+
* The build's deployment id, written into every payload's root so that a
|
|
157
|
+
* page on another build can tell — including from a payload that was
|
|
158
|
+
* prerendered into a file. `null` under `uf dev`. See
|
|
159
|
+
* `./internal/deployment.js`.
|
|
160
|
+
*/
|
|
161
|
+
readonly deployment?: string | null,
|
|
155
162
|
|}): FlightRenderer {
|
|
156
163
|
// Before anything else: on a React older than 19.3 nothing below can render,
|
|
157
164
|
// and React's Flight renderer would only say so from inside a render.
|
|
@@ -201,7 +208,7 @@ export function createFlightRenderer(options: {|
|
|
|
201
208
|
) : null}
|
|
202
209
|
</>
|
|
203
210
|
);
|
|
204
|
-
const root: FlightRoot = { route: state, tree };
|
|
211
|
+
const root: FlightRoot = { route: state, tree, deployment: options.deployment ?? null };
|
|
205
212
|
const failure = renderFailure(route);
|
|
206
213
|
if (failure != null) reportRequestError(failure, "render");
|
|
207
214
|
const report = (error) => {
|
package/server.js
CHANGED
|
@@ -30,11 +30,14 @@ import * as React from "react";
|
|
|
30
30
|
|
|
31
31
|
import {
|
|
32
32
|
type DocumentBody,
|
|
33
|
+
type PrerenderedShell,
|
|
33
34
|
type WritableLike,
|
|
34
35
|
prerenderDocument,
|
|
35
36
|
renderDocument,
|
|
36
37
|
} from "./internal/stream.js";
|
|
37
38
|
|
|
39
|
+
export type { PrerenderedShell } from "./internal/stream.js";
|
|
40
|
+
|
|
38
41
|
import {
|
|
39
42
|
type AppProps,
|
|
40
43
|
type ResolvedRoute,
|
|
@@ -54,6 +57,12 @@ export type RenderAssets = {|
|
|
|
54
57
|
readonly scripts: $ReadOnlyArray<string>,
|
|
55
58
|
readonly styles: $ReadOnlyArray<string>,
|
|
56
59
|
readonly preloads: $ReadOnlyArray<string>,
|
|
60
|
+
/**
|
|
61
|
+
* The build the document belongs to, written into its head as
|
|
62
|
+
* `<meta name="uf:deployment">`. Absent under `uf dev`, where there is no
|
|
63
|
+
* other build to be skewed against. See `./internal/deployment.js`.
|
|
64
|
+
*/
|
|
65
|
+
readonly deployment?: string,
|
|
57
66
|
|};
|
|
58
67
|
|
|
59
68
|
/**
|
|
@@ -112,6 +121,17 @@ export type PrerenderResult = {|
|
|
|
112
121
|
* navigation at all. Absent for a document rendered from its modules.
|
|
113
122
|
*/
|
|
114
123
|
readonly payload?: Uint8Array,
|
|
124
|
+
/**
|
|
125
|
+
* The page's static shell, when the prerender was partial and the page read
|
|
126
|
+
* the request inside a `<Suspense>` boundary.
|
|
127
|
+
*
|
|
128
|
+
* `html` is then the shell's markup and not a document to write at the
|
|
129
|
+
* page's URL: a server answers the page by sending the shell and rendering
|
|
130
|
+
* the holes per request, through [`Renderer`]'s `resume`. No payload comes
|
|
131
|
+
* with it, because the browser hydrates from the request's. Only a renderer
|
|
132
|
+
* for React Server Components writes one.
|
|
133
|
+
*/
|
|
134
|
+
readonly shell?: PrerenderedShell,
|
|
115
135
|
|};
|
|
116
136
|
|
|
117
137
|
/**
|
|
@@ -197,6 +217,19 @@ export type RenderOptions = {|
|
|
|
197
217
|
* vocabulary of the report belongs on this side of that line.
|
|
198
218
|
*/
|
|
199
219
|
readonly onStream?: (diagnostic: StreamDiagnostic) => void,
|
|
220
|
+
/**
|
|
221
|
+
* For `prerender` only: whether a read of the request may be left for the
|
|
222
|
+
* request rather than fail the page.
|
|
223
|
+
*
|
|
224
|
+
* `uf build` passes it when `app.rendering.modes` allows `ppr` and the build
|
|
225
|
+
* leaves a server behind. A page that reads `cookies()`, `headers()` or
|
|
226
|
+
* `draftMode()` inside a `<Suspense>` boundary is then written as a static
|
|
227
|
+
* shell with that boundary as a hole — [`PrerenderResult`]'s `shell` — and a
|
|
228
|
+
* read outside every boundary still fails the page, naming what it read.
|
|
229
|
+
* The renderer for React Server Components honours it; a route rendered from
|
|
230
|
+
* its modules (`app.rsc: false`) is prerendered whole or not at all.
|
|
231
|
+
*/
|
|
232
|
+
readonly partial?: boolean,
|
|
200
233
|
|};
|
|
201
234
|
|
|
202
235
|
/** The two ids the server writes and the client reads. */
|
|
@@ -294,6 +327,17 @@ export type Renderer = {|
|
|
|
294
327
|
readonly interceptedFrom?: string,
|
|
295
328
|
|},
|
|
296
329
|
) => Promise<FlightResponse>,
|
|
330
|
+
/**
|
|
331
|
+
* A page `prerender` wrote as a static shell: the shell first, then its holes
|
|
332
|
+
* as this request renders them. Only a renderer for React Server Components
|
|
333
|
+
* has one, because only it writes a shell.
|
|
334
|
+
*/
|
|
335
|
+
readonly resume?: (
|
|
336
|
+
url: string,
|
|
337
|
+
assets: RenderAssets,
|
|
338
|
+
shell: PrerenderedShell,
|
|
339
|
+
options?: RenderOptions,
|
|
340
|
+
) => Promise<RenderResult>,
|
|
297
341
|
|};
|
|
298
342
|
|
|
299
343
|
export function createRenderer(options: {|
|