@uniflowed/router 0.0.0-alpha.45 → 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 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
- const headers = new Headers({ accept: FLIGHT_CONTENT_TYPE });
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
+ }
@@ -71,8 +71,21 @@ export function installServerModules(load: ClientModuleLoader): void {
71
71
  });
72
72
  }
73
73
 
74
- /** Read a payload into its root value, as React's client does in the browser. */
75
- export function readPayload(stream: ReadableStream<Uint8Array>): Promise<FlightRoot> {
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 createFromReadableStream(stream);
88
+ return options?.partial === true
89
+ ? createFromReadableStream(stream, { unstable_allowPartialStream: true })
90
+ : createFromReadableStream(stream);
78
91
  }
@@ -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) {
@@ -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
  }
@@ -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.45",
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.45",
75
- "@uniflowed/server": "0.0.0-alpha.45"
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 { currentNonce } from "@uniflowed/server/host";
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 { type DocumentBody, prerenderDocument, renderDocument } from "./internal/stream.js";
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 rendered = await renderFlight(url, { defer: false, onError: onServerError });
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: {|