@uniflowed/router 0.1.0 → 0.3.0
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 +101 -24
- package/client.js +30 -9
- package/internal/action-endpoint.js +322 -14
- package/internal/action-wire.js +70 -8
- package/internal/compose.js +14 -4
- package/internal/deployment.js +3 -2
- package/internal/devtools.js +1 -1
- package/internal/error-view.js +1 -1
- package/internal/flight-browser.js +86 -10
- package/internal/flight-chunks.js +7 -7
- package/internal/flight-rows.js +7 -2
- package/internal/flight-ssr.js +10 -3
- package/internal/flight.js +32 -0
- package/internal/form-action.js +243 -0
- package/internal/hydrate-options.js +38 -0
- package/internal/hydration.js +11 -6
- package/internal/navigation-cache.js +1 -1
- package/internal/payload-rows.js +5 -4
- package/internal/payload.js +18 -12
- package/internal/prepare-document.js +6 -0
- package/internal/resolve.js +17 -11
- package/internal/routing.js +77 -12
- package/internal/runtime.js +114 -6
- package/internal/shell.js +9 -2
- package/internal/stream.js +41 -14
- package/middleware.js +140 -11
- package/package.json +6 -4
- package/rsc-client.js +3 -1
- package/rsc-ssr.js +19 -6
- package/rsc.js +23 -7
- package/server.js +13 -4
- package/testing.js +980 -0
package/middleware.js
CHANGED
|
@@ -60,6 +60,32 @@
|
|
|
60
60
|
// middleware writes against `/pricing` holds for a client navigation too, and
|
|
61
61
|
// a rewrite of the document becomes a rewrite of its payload.
|
|
62
62
|
//
|
|
63
|
+
// # A payload that renders over another page runs that page's guards too
|
|
64
|
+
//
|
|
65
|
+
// An intercepted navigation — a modal slot opening over the page it was clicked
|
|
66
|
+
// from — asks for the payload of the URL it opens, and names the page it
|
|
67
|
+
// renders over in `uf-intercepted-from`. The answer is *both* pages: the one
|
|
68
|
+
// the URL names, in the slot, and the one the header names, underneath it,
|
|
69
|
+
// with that page's loader run and its data in the payload.
|
|
70
|
+
//
|
|
71
|
+
// So the chain matched against the URL alone would be the wrong chain. A guard
|
|
72
|
+
// on `/feed` holds for a request *for* `/feed`, and a payload for `/photo/1`
|
|
73
|
+
// rendered over `/feed` is `/feed` as much as it is `/photo/1`. After the
|
|
74
|
+
// ordinary chain has admitted the request, every middleware guarding the page
|
|
75
|
+
// underneath runs as well — against a `GET` for that page, with its params and
|
|
76
|
+
// query — including one that already ran for the URL, because a middleware
|
|
77
|
+
// that decides by reading the pathname (one root `$middleware.js` guarding
|
|
78
|
+
// several sections is a common shape) has only been asked about the other
|
|
79
|
+
// path. If every one of them declines, the interception stands. If any
|
|
80
|
+
// answers or rewrites, the page underneath is not this request's to render:
|
|
81
|
+
// the header is taken off, and what renders is the page the URL names on its
|
|
82
|
+
// own — exactly what a reader who could not open the page underneath would
|
|
83
|
+
// see after a reload. The guard's own answer is not sent: the request was for
|
|
84
|
+
// `/photo/1`, which that guard does not cover.
|
|
85
|
+
//
|
|
86
|
+
// The header is taken off whenever it is not a path on this origin, too, so
|
|
87
|
+
// the renderer is never handed a value this module has not judged.
|
|
88
|
+
//
|
|
63
89
|
// # The request it runs inside
|
|
64
90
|
//
|
|
65
91
|
// The runner does not establish one. The host does — `beginRequest` in
|
|
@@ -96,7 +122,7 @@
|
|
|
96
122
|
// `routesModuleSource` keeps the middleware table in an export the client
|
|
97
123
|
// never imports, for the same reason it does that with route handlers.
|
|
98
124
|
|
|
99
|
-
import { documentPathOf, flightUrl } from "./internal/flight.js";
|
|
125
|
+
import { INTERCEPTED_FROM_HEADER, documentPathOf, flightUrl } from "./internal/flight.js";
|
|
100
126
|
import { requireRequest } from "./internal/request.js";
|
|
101
127
|
import type { RouteParams } from "./internal/runtime.js";
|
|
102
128
|
|
|
@@ -160,7 +186,10 @@ export type MiddlewareRecord = {|
|
|
|
160
186
|
* caller's signal to carry on to the handler or the page. Returns a `Request`
|
|
161
187
|
* when one of them rewrote: the same request at the destination, which the
|
|
162
188
|
* caller carries on with instead — and which has already been past the
|
|
163
|
-
* destination's middleware.
|
|
189
|
+
* destination's middleware. It also returns a `Request` when a payload request
|
|
190
|
+
* names a page to render over and that page's guards did not all admit it: the
|
|
191
|
+
* same request without `uf-intercepted-from`. The host must carry on with that
|
|
192
|
+
* request and read the header off nothing else, or the decision is undone.
|
|
164
193
|
*
|
|
165
194
|
* The runner is called once per request, above both the dispatcher and the
|
|
166
195
|
* renderer, rather than from inside each of them. Putting the call inside
|
|
@@ -242,15 +271,99 @@ export function createMiddlewareRunner(options: {|
|
|
|
242
271
|
return result;
|
|
243
272
|
}
|
|
244
273
|
|
|
245
|
-
|
|
246
|
-
|
|
274
|
+
const admitted: Request | null = !rewritten
|
|
275
|
+
? null
|
|
276
|
+
: document == null
|
|
277
|
+
? seen
|
|
278
|
+
: requestAt(seen, new URL(flightUrl(url.pathname + url.search), url));
|
|
279
|
+
|
|
280
|
+
// A payload rendered over another page: that page's guards have their say
|
|
281
|
+
// too. See "A payload that renders over another page" above.
|
|
282
|
+
if (document != null && request.headers.has(INTERCEPTED_FROM_HEADER)) {
|
|
283
|
+
const carried = admitted ?? request;
|
|
284
|
+
const underneath = interceptionBase(request.headers.get(INTERCEPTED_FROM_HEADER), url);
|
|
285
|
+
if (underneath == null || !(await guardsAdmit(table, carried, underneath))) {
|
|
286
|
+
return withoutInterception(carried);
|
|
287
|
+
}
|
|
247
288
|
}
|
|
248
|
-
return
|
|
249
|
-
? seen
|
|
250
|
-
: requestAt(seen, new URL(flightUrl(url.pathname + url.search), url));
|
|
289
|
+
return admitted;
|
|
251
290
|
};
|
|
252
291
|
}
|
|
253
292
|
|
|
293
|
+
/**
|
|
294
|
+
* The page an intercepted payload names in its header, as a URL on `base`'s
|
|
295
|
+
* origin, or `null` when the value is not a path on this origin.
|
|
296
|
+
*
|
|
297
|
+
* The same shape the renderer accepts (`usableInterceptionBase` in `./rsc.js`,
|
|
298
|
+
* `interceptedFrom` in `@uniflowed/server`): a path, not a network-path
|
|
299
|
+
* reference, with any fragment dropped. Parsed rather than compared as text,
|
|
300
|
+
* so the pathname the guards are matched against is the one the renderer's
|
|
301
|
+
* route matching will read.
|
|
302
|
+
*/
|
|
303
|
+
function interceptionBase(header: string | null, base: URL): URL | null {
|
|
304
|
+
if (header == null || !header.startsWith("/") || header.startsWith("//")) {
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
let parsed: URL;
|
|
308
|
+
try {
|
|
309
|
+
parsed = new URL(header, base);
|
|
310
|
+
} catch {
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
if (parsed.origin !== base.origin) {
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
parsed.hash = "";
|
|
317
|
+
return parsed;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Whether every middleware guarding `underneath` lets this request see it.
|
|
322
|
+
*
|
|
323
|
+
* Each one is asked exactly as it would be asked by a request for that page: a
|
|
324
|
+
* `GET` at its URL carrying this request's headers, with the params of the
|
|
325
|
+
* directory it guards and the page's query. All of them, root first, whether
|
|
326
|
+
* or not they already ran for the URL the request names — a guard that reads
|
|
327
|
+
* the pathname has so far only been asked about the other one.
|
|
328
|
+
*
|
|
329
|
+
* A `Response` is a refusal and so is a `rewrite()`: the page a guard would
|
|
330
|
+
* serve instead is not the page the header names, and the renderer has no way
|
|
331
|
+
* to render one under the other. Nothing either returns is sent; the caller
|
|
332
|
+
* only learns that the page underneath is not this request's to render.
|
|
333
|
+
*/
|
|
334
|
+
async function guardsAdmit(
|
|
335
|
+
table: $ReadOnlyArray<MiddlewareRecord>,
|
|
336
|
+
request: Request,
|
|
337
|
+
underneath: URL,
|
|
338
|
+
): Promise<boolean> {
|
|
339
|
+
const asked = new Request(underneath.href, { method: "GET", headers: request.headers });
|
|
340
|
+
for (const record of table) {
|
|
341
|
+
const params = matchPrefix(record.path, underneath.pathname);
|
|
342
|
+
if (params == null) {
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
const middleware = pick(await record.load(), record.file);
|
|
346
|
+
const result = await middleware(asked, { params, searchParams: underneath.searchParams });
|
|
347
|
+
if (result != null) {
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return true;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* `request` with its `uf-intercepted-from` header taken off, so the renderer
|
|
356
|
+
* answers with the page its URL names and nothing underneath it.
|
|
357
|
+
*
|
|
358
|
+
* Only a payload request reaches here, and a payload is a `GET` or a `HEAD`,
|
|
359
|
+
* so there is no body to hand on.
|
|
360
|
+
*/
|
|
361
|
+
function withoutInterception(request: Request): Request {
|
|
362
|
+
const headers = new Headers(request.headers);
|
|
363
|
+
headers.delete(INTERCEPTED_FROM_HEADER);
|
|
364
|
+
return new Request(request.url, { method: request.method, headers, signal: request.signal });
|
|
365
|
+
}
|
|
366
|
+
|
|
254
367
|
/**
|
|
255
368
|
* Where a rewrite goes, resolved against the URL the middleware was handed.
|
|
256
369
|
*
|
|
@@ -282,12 +395,28 @@ function withPathname(url: URL, pathname: string): URL {
|
|
|
282
395
|
/**
|
|
283
396
|
* `request` at another URL: same method, headers, signal and body.
|
|
284
397
|
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
398
|
+
* Every member of `RequestInit` that a `Request` carries, named — what passing
|
|
399
|
+
* the request itself as the init would read, spelled out so that the checker
|
|
400
|
+
* can see it. A streamed body is handed on rather than read, which is why a
|
|
401
|
+
* body comes with `duplex: "half"`, the one mode a streamed request body has.
|
|
287
402
|
*/
|
|
288
403
|
function requestAt(request: Request, url: URL): Request {
|
|
289
|
-
|
|
290
|
-
return new Request(url.href,
|
|
404
|
+
const body = request.body;
|
|
405
|
+
return new Request(url.href, {
|
|
406
|
+
method: request.method,
|
|
407
|
+
headers: request.headers,
|
|
408
|
+
body,
|
|
409
|
+
...(body == null ? {} : { duplex: "half" }),
|
|
410
|
+
signal: request.signal,
|
|
411
|
+
cache: request.cache,
|
|
412
|
+
credentials: request.credentials,
|
|
413
|
+
integrity: request.integrity,
|
|
414
|
+
keepalive: request.keepalive,
|
|
415
|
+
mode: request.mode,
|
|
416
|
+
redirect: request.redirect,
|
|
417
|
+
referrer: request.referrer,
|
|
418
|
+
referrerPolicy: request.referrerPolicy,
|
|
419
|
+
});
|
|
291
420
|
}
|
|
292
421
|
|
|
293
422
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniflowed/router",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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",
|
|
@@ -28,7 +28,8 @@
|
|
|
28
28
|
"./middleware": "./middleware.js",
|
|
29
29
|
"./native-navigation": "./native-navigation.js",
|
|
30
30
|
"./http-client": "./http-client.js",
|
|
31
|
-
"./instrumentation": "./instrumentation.js"
|
|
31
|
+
"./instrumentation": "./instrumentation.js",
|
|
32
|
+
"./testing": "./testing.js"
|
|
32
33
|
},
|
|
33
34
|
"files": [
|
|
34
35
|
"action.js",
|
|
@@ -47,6 +48,7 @@
|
|
|
47
48
|
"native-navigation.js",
|
|
48
49
|
"http-client.js",
|
|
49
50
|
"instrumentation.js",
|
|
51
|
+
"testing.js",
|
|
50
52
|
"!*.test.js"
|
|
51
53
|
],
|
|
52
54
|
"peerDependencies": {
|
|
@@ -71,7 +73,7 @@
|
|
|
71
73
|
}
|
|
72
74
|
},
|
|
73
75
|
"dependencies": {
|
|
74
|
-
"@uniflowed/hooks": "0.
|
|
75
|
-
"@uniflowed/server": "0.
|
|
76
|
+
"@uniflowed/hooks": "0.3.0",
|
|
77
|
+
"@uniflowed/server": "0.3.0"
|
|
76
78
|
}
|
|
77
79
|
}
|
package/rsc-client.js
CHANGED
|
@@ -41,6 +41,8 @@ import {
|
|
|
41
41
|
installRouting,
|
|
42
42
|
installStaleTime,
|
|
43
43
|
} from "./internal/runtime.js";
|
|
44
|
+
import { hydrationOptions } from "./internal/hydrate-options.js";
|
|
45
|
+
import { readFormState } from "./internal/form-action.js";
|
|
44
46
|
|
|
45
47
|
/**
|
|
46
48
|
* Hydrate a document React Server Components rendered.
|
|
@@ -106,7 +108,7 @@ export async function hydrateFlight(options: {|
|
|
|
106
108
|
hydrateRoot(
|
|
107
109
|
container,
|
|
108
110
|
options.strictMode === true ? <StrictMode>{tree}</StrictMode> : tree,
|
|
109
|
-
recovery
|
|
111
|
+
hydrationOptions(recovery, readFormState(document)),
|
|
110
112
|
);
|
|
111
113
|
if (restoreDevHead != null) {
|
|
112
114
|
setTimeout(restoreDevHead, 250);
|
package/rsc-ssr.js
CHANGED
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
runPartialPrerender,
|
|
41
41
|
} from "@uniflowed/server/host";
|
|
42
42
|
|
|
43
|
+
import type { FormState } from "./internal/form-action.js";
|
|
43
44
|
import { redirectDocument, redirectResult, shellFor } from "./internal/shell.js";
|
|
44
45
|
import {
|
|
45
46
|
type DocumentBody,
|
|
@@ -113,6 +114,7 @@ export function createDocumentRenderer(options: DocumentRendererOptions): Render
|
|
|
113
114
|
readonly onError: (error: mixed) => void,
|
|
114
115
|
readonly transformHead?: (html: string) => Promise<string>,
|
|
115
116
|
readonly onStream?: (record: StreamRecord) => void,
|
|
117
|
+
readonly formState?: FormState,
|
|
116
118
|
|},
|
|
117
119
|
): Promise<DocumentBody> {
|
|
118
120
|
const [forHtml, forBrowser] = stream.tee();
|
|
@@ -123,12 +125,13 @@ export function createDocumentRenderer(options: DocumentRendererOptions): Render
|
|
|
123
125
|
const nonce = currentNonce();
|
|
124
126
|
try {
|
|
125
127
|
return await renderDocument(<App url={url} flight={readPayload(forHtml)} />, {
|
|
126
|
-
shell: shellFor(assets, nonce),
|
|
128
|
+
shell: shellFor(assets, nonce, settings.formState),
|
|
127
129
|
onError: settings.onError,
|
|
128
130
|
transformHead: settings.transformHead,
|
|
129
131
|
onStream: settings.onStream,
|
|
130
132
|
payload: forBrowser,
|
|
131
133
|
nonce,
|
|
134
|
+
formState: settings.formState,
|
|
132
135
|
});
|
|
133
136
|
} catch (error) {
|
|
134
137
|
void forBrowser.cancel();
|
|
@@ -141,7 +144,7 @@ export function createDocumentRenderer(options: DocumentRendererOptions): Render
|
|
|
141
144
|
assets: RenderAssets,
|
|
142
145
|
settings?: RenderOptions,
|
|
143
146
|
): Promise<RenderResult> {
|
|
144
|
-
const report = settings?.onError ?? (() => {});
|
|
147
|
+
const report = settings?.onError ?? ((_error: mixed) => {});
|
|
145
148
|
const send = settings?.onStream;
|
|
146
149
|
const onStream = send == null ? undefined : streamReporter(url, send);
|
|
147
150
|
const transformHead = settings?.transformHead;
|
|
@@ -189,6 +192,7 @@ export function createDocumentRenderer(options: DocumentRendererOptions): Render
|
|
|
189
192
|
},
|
|
190
193
|
transformHead,
|
|
191
194
|
onStream,
|
|
195
|
+
formState: settings?.formState,
|
|
192
196
|
});
|
|
193
197
|
streaming = true;
|
|
194
198
|
for (const error of held) {
|
|
@@ -244,7 +248,7 @@ export function createDocumentRenderer(options: DocumentRendererOptions): Render
|
|
|
244
248
|
assets: RenderAssets,
|
|
245
249
|
settings?: RenderOptions,
|
|
246
250
|
): Promise<PrerenderResult> {
|
|
247
|
-
const report = settings?.onError ?? (() => {});
|
|
251
|
+
const report = settings?.onError ?? ((_error: mixed) => {});
|
|
248
252
|
const ledger = createErrorLedger();
|
|
249
253
|
// A read of the request, while the build may leave it for the request, is
|
|
250
254
|
// not an exception to report: it is how the build finds the holes. Its row
|
|
@@ -401,7 +405,7 @@ export function createDocumentRenderer(options: DocumentRendererOptions): Render
|
|
|
401
405
|
shell: PrerenderedShell,
|
|
402
406
|
settings?: RenderOptions,
|
|
403
407
|
): Promise<RenderResult> {
|
|
404
|
-
const report = settings?.onError ?? (() => {});
|
|
408
|
+
const report = settings?.onError ?? ((_error: mixed) => {});
|
|
405
409
|
const ledger = createErrorLedger();
|
|
406
410
|
const stop = new AbortController();
|
|
407
411
|
const rendered = await renderFlight(url, {
|
|
@@ -473,7 +477,16 @@ export function createDocumentRenderer(options: DocumentRendererOptions): Render
|
|
|
473
477
|
}
|
|
474
478
|
return {
|
|
475
479
|
status: rendered.status,
|
|
476
|
-
headers: {
|
|
480
|
+
headers: {
|
|
481
|
+
"content-type": FLIGHT_CONTENT_TYPE,
|
|
482
|
+
vary: INTERCEPTED_FROM_HEADER,
|
|
483
|
+
// The payload carries the page's strings as they were rendered — a
|
|
484
|
+
// comment body with `<img onerror>` in it included — and its URL is one
|
|
485
|
+
// anybody can open as a document. `text/x-component` is not a type a
|
|
486
|
+
// browser sniffs into HTML today; `nosniff` makes that a promise rather
|
|
487
|
+
// than a property of today's browsers.
|
|
488
|
+
"x-content-type-options": "nosniff",
|
|
489
|
+
},
|
|
477
490
|
stream: rendered.stream,
|
|
478
491
|
error: rendered.failure,
|
|
479
492
|
};
|
|
@@ -597,7 +610,7 @@ function digestOf(error: mixed): string | null {
|
|
|
597
610
|
if (error == null || typeof error !== "object") {
|
|
598
611
|
return null;
|
|
599
612
|
}
|
|
600
|
-
const tagged: {
|
|
613
|
+
const tagged: { readonly digest?: mixed, ... } = error as $FlowFixMe;
|
|
601
614
|
return typeof tagged.digest === "string" ? tagged.digest : null;
|
|
602
615
|
}
|
|
603
616
|
|
package/rsc.js
CHANGED
|
@@ -59,17 +59,28 @@ import { BoundaryReporter } from "./internal/boundaries.js";
|
|
|
59
59
|
import { routeBoundaries } from "./internal/boundary-data.js";
|
|
60
60
|
import { composeRoute, pageComponent } from "./internal/compose.js";
|
|
61
61
|
import { ErrorRoutePage } from "./internal/error-view.js";
|
|
62
|
-
import { type FlightRoot, routeState } from "./internal/flight.js";
|
|
62
|
+
import { type FlightRoot, crossableRouteError, routeState } from "./internal/flight.js";
|
|
63
63
|
import { requireServerComponentsReact } from "./internal/react-version.js";
|
|
64
|
-
import type {
|
|
64
|
+
import type {
|
|
65
|
+
ErrorModule,
|
|
66
|
+
PageModule,
|
|
67
|
+
ResolvedRoute,
|
|
68
|
+
ResolvedSlot,
|
|
69
|
+
RouteTable,
|
|
70
|
+
} from "./internal/resolve.js";
|
|
65
71
|
import { resolveFailure, resolveInterception, resolveMatch } from "./internal/resolve.js";
|
|
66
|
-
import type { RouteParams,
|
|
72
|
+
import type { RouteParams, SearchParams } from "./internal/routing.js";
|
|
67
73
|
import { RedirectError, nearestBoundary } from "./internal/routing.js";
|
|
68
74
|
import { withServerRoute } from "./internal/server-route.js";
|
|
69
75
|
|
|
70
76
|
export type { FlightRoot, RouteState } from "./internal/flight.js";
|
|
71
77
|
// For `virtual:uf/rsc`, so a Server Component's `basePath()` is the project's.
|
|
72
78
|
export { installRouting } from "./internal/base-path.js";
|
|
79
|
+
// For `virtual:uf/rsc` too: the server-action endpoint is built in this graph,
|
|
80
|
+
// beside the pages, so an action and the page that shows what it wrote run the
|
|
81
|
+
// same instance of every module they share (ubugeeei-prod/uf#1469). The host
|
|
82
|
+
// reaches it through the bridge, as it reaches `renderFlight`.
|
|
83
|
+
export { createActionDispatcher } from "./internal/action-endpoint.js";
|
|
73
84
|
|
|
74
85
|
/** What a host may tell the renderer about one render. */
|
|
75
86
|
export type FlightOptions = {|
|
|
@@ -209,9 +220,11 @@ export function createFlightRenderer(options: {|
|
|
|
209
220
|
</>
|
|
210
221
|
);
|
|
211
222
|
const root: FlightRoot = { route: state, tree, deployment: options.deployment ?? null };
|
|
212
|
-
|
|
223
|
+
// From the route as it resolved, not the copy for the browser: the host is
|
|
224
|
+
// told what was actually thrown. See `crossableRouteError`.
|
|
225
|
+
const failure = renderFailure(resolved);
|
|
213
226
|
if (failure != null) reportRequestError(failure, "render");
|
|
214
|
-
const report = (error) => {
|
|
227
|
+
const report = (error: mixed) => {
|
|
215
228
|
reportRequestError(error, "render");
|
|
216
229
|
if (settings?.onError != null) return settings.onError(error);
|
|
217
230
|
console.error(error);
|
|
@@ -227,7 +240,7 @@ export function createFlightRenderer(options: {|
|
|
|
227
240
|
signal: settings?.signal,
|
|
228
241
|
}),
|
|
229
242
|
);
|
|
230
|
-
return { kind: "route", status: route.status, stream, failure
|
|
243
|
+
return { kind: "route", status: route.status, stream, failure };
|
|
231
244
|
};
|
|
232
245
|
}
|
|
233
246
|
|
|
@@ -343,6 +356,9 @@ function forTheBrowser(resolved: ResolvedRoute, file: string | null): ResolvedRo
|
|
|
343
356
|
const boundary = resolved.errorBoundary;
|
|
344
357
|
return {
|
|
345
358
|
...resolved,
|
|
359
|
+
// Only an `Error` has React's production rule of carrying no message; see
|
|
360
|
+
// `crossableRouteError`.
|
|
361
|
+
error: crossableRouteError(resolved.error),
|
|
346
362
|
errorBoundary: { above: boundary.above, module: clientErrorModule(boundary.module, file) },
|
|
347
363
|
slots: resolved.slots.map(slotForTheBrowser),
|
|
348
364
|
};
|
|
@@ -385,7 +401,7 @@ function isClientReference(value: mixed): boolean {
|
|
|
385
401
|
if (value == null || (typeof value !== "object" && typeof value !== "function")) {
|
|
386
402
|
return false;
|
|
387
403
|
}
|
|
388
|
-
const tagged: {
|
|
404
|
+
const tagged: { readonly $$typeof?: mixed, ... } = value as $FlowFixMe;
|
|
389
405
|
return tagged.$$typeof === CLIENT_REFERENCE;
|
|
390
406
|
}
|
|
391
407
|
|
package/server.js
CHANGED
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
resolveMatch,
|
|
49
49
|
} from "./internal/runtime.js";
|
|
50
50
|
|
|
51
|
+
import type { FormState } from "./internal/form-action.js";
|
|
51
52
|
import { type StreamDiagnostic, streamReporter } from "./internal/inspector.js";
|
|
52
53
|
|
|
53
54
|
import { redirectDocument, redirectResult, shellFor } from "./internal/shell.js";
|
|
@@ -198,6 +199,12 @@ export type RenderOptions = {|
|
|
|
198
199
|
* it changes is a failing test rather than a surprise.
|
|
199
200
|
*/
|
|
200
201
|
readonly transformHead?: (html: string) => Promise<string>,
|
|
202
|
+
/**
|
|
203
|
+
* React's `formState` for a page rendered in answer to a form posted before
|
|
204
|
+
* hydration; see `internal/form-action.js`. Only a host's `postback` passes
|
|
205
|
+
* one.
|
|
206
|
+
*/
|
|
207
|
+
readonly formState?: FormState,
|
|
201
208
|
/**
|
|
202
209
|
* Told, in words, when a document streamed differently than it did last time.
|
|
203
210
|
*
|
|
@@ -405,7 +412,7 @@ export function createRenderer(options: {|
|
|
|
405
412
|
return redirectDocument(resolution.error);
|
|
406
413
|
}
|
|
407
414
|
let resolved: ResolvedRoute = resolution.route;
|
|
408
|
-
const report = settings?.onError ?? (() => {});
|
|
415
|
+
const report = settings?.onError ?? ((_error: mixed) => {});
|
|
409
416
|
// Built once and shared by both renders below, so a page that threw its
|
|
410
417
|
// shell away and rendered its error boundary instead reports the stream the
|
|
411
418
|
// browser was actually sent rather than the one that was abandoned.
|
|
@@ -436,11 +443,12 @@ export function createRenderer(options: {|
|
|
|
436
443
|
let body: DocumentBody;
|
|
437
444
|
try {
|
|
438
445
|
body = await renderDocument(<App url={url} initial={resolved} />, {
|
|
439
|
-
shell: shellFor(assets, nonce),
|
|
446
|
+
shell: shellFor(assets, nonce, settings?.formState),
|
|
440
447
|
onError,
|
|
441
448
|
transformHead: settings?.transformHead,
|
|
442
449
|
onStream,
|
|
443
450
|
nonce,
|
|
451
|
+
formState: settings?.formState,
|
|
444
452
|
});
|
|
445
453
|
streaming = true;
|
|
446
454
|
// Recovered before the shell was ready: a `<Suspense>` boundary whose
|
|
@@ -469,11 +477,12 @@ export function createRenderer(options: {|
|
|
|
469
477
|
// where somebody can fix it.
|
|
470
478
|
streaming = true;
|
|
471
479
|
body = await renderDocument(<App url={url} initial={resolved} />, {
|
|
472
|
-
shell: shellFor(assets, nonce),
|
|
480
|
+
shell: shellFor(assets, nonce, settings?.formState),
|
|
473
481
|
onError,
|
|
474
482
|
transformHead: settings?.transformHead,
|
|
475
483
|
onStream,
|
|
476
484
|
nonce,
|
|
485
|
+
formState: settings?.formState,
|
|
477
486
|
});
|
|
478
487
|
}
|
|
479
488
|
|
|
@@ -496,7 +505,7 @@ export function createRenderer(options: {|
|
|
|
496
505
|
return redirectResult(redirectDocument(resolution.error));
|
|
497
506
|
}
|
|
498
507
|
let resolved: ResolvedRoute = resolution.route;
|
|
499
|
-
const report = settings?.onError ?? (() => {});
|
|
508
|
+
const report = settings?.onError ?? ((_error: mixed) => {});
|
|
500
509
|
|
|
501
510
|
let html: string;
|
|
502
511
|
try {
|