@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/internal/payload.js
CHANGED
|
@@ -241,8 +241,8 @@ function asThenable(value: mixed): Promise<mixed> | null {
|
|
|
241
241
|
if (value == null || (typeof value !== "object" && typeof value !== "function")) {
|
|
242
242
|
return null;
|
|
243
243
|
}
|
|
244
|
-
const then: mixed = (value
|
|
245
|
-
return typeof then === "function" ? (value
|
|
244
|
+
const then: mixed = (value as $FlowFixMe).then;
|
|
245
|
+
return typeof then === "function" ? (value as $FlowFixMe) : null;
|
|
246
246
|
}
|
|
247
247
|
|
|
248
248
|
/** Whether a string would be read as a reference and so has to be escaped. */
|
|
@@ -315,7 +315,7 @@ function needsEncoding(root: mixed, label: string): boolean {
|
|
|
315
315
|
continue;
|
|
316
316
|
}
|
|
317
317
|
if (isPlainObject(value)) {
|
|
318
|
-
const source: {
|
|
318
|
+
const source: { readonly [string]: mixed } = value as $FlowFixMe;
|
|
319
319
|
for (const key of Object.keys(source)) {
|
|
320
320
|
stack.push({ value: source[key], path: `${path}.${key}`, depth: depth + 1 });
|
|
321
321
|
}
|
|
@@ -381,7 +381,7 @@ export function encodePayload(root: mixed, label: string): EncodedPayload {
|
|
|
381
381
|
continue;
|
|
382
382
|
}
|
|
383
383
|
if (Array.isArray(value)) {
|
|
384
|
-
const encoded: Array<mixed> = new Array(value.length).fill(null);
|
|
384
|
+
const encoded: Array<mixed> = new Array<mixed>(value.length).fill(null);
|
|
385
385
|
emit(encoded);
|
|
386
386
|
// Pushed in reverse so that popping visits index 0 first: the id a
|
|
387
387
|
// promise is given has to follow the order the payload reads in.
|
|
@@ -403,7 +403,7 @@ export function encodePayload(root: mixed, label: string): EncodedPayload {
|
|
|
403
403
|
emit(value);
|
|
404
404
|
continue;
|
|
405
405
|
}
|
|
406
|
-
const source: {
|
|
406
|
+
const source: { readonly [string]: mixed } = value as $FlowFixMe;
|
|
407
407
|
const encoded: { [string]: mixed } = {};
|
|
408
408
|
emit(encoded);
|
|
409
409
|
const keys = Object.keys(source);
|
|
@@ -491,7 +491,7 @@ export function decodePayload(root: mixed, resolve: RowResolver, label: string):
|
|
|
491
491
|
continue;
|
|
492
492
|
}
|
|
493
493
|
if (Array.isArray(value)) {
|
|
494
|
-
const rebuilt: Array<mixed> = new Array(value.length).fill(null);
|
|
494
|
+
const rebuilt: Array<mixed> = new Array<mixed>(value.length).fill(null);
|
|
495
495
|
emit(rebuilt);
|
|
496
496
|
for (let index = value.length - 1; index >= 0; index -= 1) {
|
|
497
497
|
stack.push({
|
|
@@ -509,7 +509,7 @@ export function decodePayload(root: mixed, resolve: RowResolver, label: string):
|
|
|
509
509
|
emit(value);
|
|
510
510
|
continue;
|
|
511
511
|
}
|
|
512
|
-
const source: {
|
|
512
|
+
const source: { readonly [string]: mixed } = value as $FlowFixMe;
|
|
513
513
|
const rebuilt: { [string]: mixed } = {};
|
|
514
514
|
emit(rebuilt);
|
|
515
515
|
const keys = Object.keys(source);
|
|
@@ -553,7 +553,7 @@ function hasReference(root: mixed, label: string): boolean {
|
|
|
553
553
|
continue;
|
|
554
554
|
}
|
|
555
555
|
if (isPlainObject(value)) {
|
|
556
|
-
const source: {
|
|
556
|
+
const source: { readonly [string]: mixed } = value as $FlowFixMe;
|
|
557
557
|
for (const key of Object.keys(source)) {
|
|
558
558
|
stack.push({ value: source[key], path: `${path}.${key}`, depth: depth + 1 });
|
|
559
559
|
}
|
|
@@ -632,7 +632,13 @@ function parsePayloadRowId(digits: string): number | null {
|
|
|
632
632
|
* only one of them.
|
|
633
633
|
*/
|
|
634
634
|
export function payloadJson(value: mixed): string {
|
|
635
|
-
|
|
635
|
+
const text = JSON.stringify(value);
|
|
636
|
+
if (text === undefined) {
|
|
637
|
+
// `undefined`, a function or a symbol: nothing JSON can say. This threw
|
|
638
|
+
// before too, as a `TypeError` about `replace`; now it says what it is.
|
|
639
|
+
throw new TypeError("@uniflowed/router: a payload value has no JSON form");
|
|
640
|
+
}
|
|
641
|
+
return text
|
|
636
642
|
.replace(/</g, "\\u003c")
|
|
637
643
|
.replace(/\u2028/g, "\\u2028")
|
|
638
644
|
.replace(/\u2029/g, "\\u2029");
|
|
@@ -660,9 +666,9 @@ export function parseRowMessage(text: string, id: number): PayloadRowMessage {
|
|
|
660
666
|
if (!isPlainObject(parsed)) {
|
|
661
667
|
throw new PayloadValueError(label, "is not a row message");
|
|
662
668
|
}
|
|
663
|
-
const message: {
|
|
664
|
-
const hasValue = Object.
|
|
665
|
-
const hasError = Object.
|
|
669
|
+
const message: { readonly [string]: mixed } = parsed as $FlowFixMe;
|
|
670
|
+
const hasValue = Object.hasOwn(message, "value");
|
|
671
|
+
const hasError = Object.hasOwn(message, "error");
|
|
666
672
|
if (hasValue === hasError) {
|
|
667
673
|
throw new PayloadValueError(label, "must carry exactly one of `value` and `error`");
|
|
668
674
|
}
|
|
@@ -19,6 +19,12 @@ export function prepareDocumentForHydration(document: Document): void {
|
|
|
19
19
|
// document that arrived. See `./deployment.js`.
|
|
20
20
|
rememberDeployment(document);
|
|
21
21
|
const head = document.head;
|
|
22
|
+
if (head == null) {
|
|
23
|
+
// Nothing a server wrote into a head to put right; the forms still are.
|
|
24
|
+
document.getElementById("_R_")?.remove();
|
|
25
|
+
normalizeReactFormActions(document);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
22
28
|
const envelope = head.querySelector('meta[name="uf:render"]');
|
|
23
29
|
if (envelope != null && head.firstChild !== envelope) {
|
|
24
30
|
head.insertBefore(envelope, head.firstChild);
|
package/internal/resolve.js
CHANGED
|
@@ -639,7 +639,7 @@ export function loadOnce<T>(load: () => Promise<T>): Promise<T> {
|
|
|
639
639
|
pending = load();
|
|
640
640
|
moduleCache.set(load, pending);
|
|
641
641
|
}
|
|
642
|
-
// $FlowFixMe[incompatible-
|
|
642
|
+
// $FlowFixMe[incompatible-type] the cache is keyed by the loader, whose result type it stores.
|
|
643
643
|
return pending;
|
|
644
644
|
}
|
|
645
645
|
|
|
@@ -755,9 +755,12 @@ async function resolveRoute(
|
|
|
755
755
|
"client JavaScript, so the browser navigates to it rather than rendering it",
|
|
756
756
|
);
|
|
757
757
|
}
|
|
758
|
-
|
|
758
|
+
// A pair rather than one spread array, so that the page stays a
|
|
759
|
+
// `PageModule` and the layouts `LayoutModule`s: `Promise.all` of a mixed
|
|
760
|
+
// list answers with the union of the two.
|
|
761
|
+
const [page, layouts] = await Promise.all([
|
|
759
762
|
loadOnce(load),
|
|
760
|
-
|
|
763
|
+
Promise.all(matched.route.layouts.map((layout) => loadOnce(layout))),
|
|
761
764
|
]);
|
|
762
765
|
// Started here and awaited at the end: the boundary's module does not depend
|
|
763
766
|
// on the loader, so importing it alongside costs a navigation nothing. It
|
|
@@ -1460,11 +1463,11 @@ async function resolveNotFound(
|
|
|
1460
1463
|
): Promise<ResolvedRoute> {
|
|
1461
1464
|
const record = nearestBoundary(table.notFound, pathname);
|
|
1462
1465
|
const load = record?.page;
|
|
1463
|
-
const [page,
|
|
1466
|
+
const [page, layouts] = await Promise.all([
|
|
1464
1467
|
load == null
|
|
1465
1468
|
? Promise.resolve<PageModule>({ default: DefaultNotFound, metadata: { title: "Not found" } })
|
|
1466
1469
|
: loadOnce(load),
|
|
1467
|
-
|
|
1470
|
+
Promise.all((record?.layouts ?? []).map((layout) => loadOnce(layout))),
|
|
1468
1471
|
]);
|
|
1469
1472
|
const metadata = await resolveMetadata(page, layouts, {
|
|
1470
1473
|
params: {},
|
|
@@ -1527,11 +1530,14 @@ async function resolveMetadata(
|
|
|
1527
1530
|
}
|
|
1528
1531
|
if (page.frontmatter != null) {
|
|
1529
1532
|
const { title, description } = page.frontmatter;
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
}
|
|
1533
|
+
// One field at a time: spreading two conditional objects is a union per
|
|
1534
|
+
// spread, and Flow refuses to multiply them out.
|
|
1535
|
+
if (title != null) {
|
|
1536
|
+
merged = { ...merged, title };
|
|
1537
|
+
}
|
|
1538
|
+
if (description != null) {
|
|
1539
|
+
merged = { ...merged, description };
|
|
1540
|
+
}
|
|
1535
1541
|
}
|
|
1536
1542
|
if (page.metadata != null) {
|
|
1537
1543
|
take(page.metadata);
|
|
@@ -1587,7 +1593,7 @@ export function errorTitle(error: RouteError): string {
|
|
|
1587
1593
|
return match (error) {
|
|
1588
1594
|
{kind: "unauthorized"} => "Sign in required",
|
|
1589
1595
|
{kind: "forbidden"} => "Not allowed",
|
|
1590
|
-
{kind: "thrown"} => "Something went wrong",
|
|
1596
|
+
{kind: "thrown", ...} => "Something went wrong",
|
|
1591
1597
|
};
|
|
1592
1598
|
}
|
|
1593
1599
|
|
package/internal/routing.js
CHANGED
|
@@ -150,7 +150,7 @@ export type RouteTable<
|
|
|
150
150
|
type UnknownRouteRecord = RouteRecord<mixed, mixed, mixed, mixed, mixed>;
|
|
151
151
|
|
|
152
152
|
/** A URL matched against a table. */
|
|
153
|
-
export type RouteMatch<TRoute
|
|
153
|
+
export type RouteMatch<TRoute extends { readonly path: string, ... } = UnknownRouteRecord> = {|
|
|
154
154
|
readonly route: TRoute,
|
|
155
155
|
readonly params: RouteParams,
|
|
156
156
|
|};
|
|
@@ -172,7 +172,7 @@ export function routeErrorStatus(error: RouteError): 401 | 403 | 500 {
|
|
|
172
172
|
return match (error) {
|
|
173
173
|
{kind: "unauthorized"} => 401,
|
|
174
174
|
{kind: "forbidden"} => 403,
|
|
175
|
-
{kind: "thrown"} => 500,
|
|
175
|
+
{kind: "thrown", ...} => 500,
|
|
176
176
|
};
|
|
177
177
|
}
|
|
178
178
|
|
|
@@ -200,12 +200,77 @@ export class ForbiddenError extends Error {
|
|
|
200
200
|
}
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
-
/**
|
|
203
|
+
/**
|
|
204
|
+
* The URL schemes a navigation never follows, because following one runs
|
|
205
|
+
* script in the page rather than loading one.
|
|
206
|
+
*
|
|
207
|
+
* `javascript:` is the reason. `location.assign("javascript:…")` and
|
|
208
|
+
* `location.replace("javascript:…")` execute it in the current document, with
|
|
209
|
+
* the page's origin and its cookies, so a router that hands a caller's string
|
|
210
|
+
* to either is a DOM XSS sink the moment that string comes from a query
|
|
211
|
+
* parameter: `redirect(searchParams.get("next"))` is the sign-in flow in half
|
|
212
|
+
* the applications ever written. React 19 refuses the same scheme in `href`
|
|
213
|
+
* for the same reason, and `Link` renders through that; this is the half of
|
|
214
|
+
* the router React never sees. `vbscript:` is the same thing in an older
|
|
215
|
+
* browser, and `data:` a document of the sender's choosing that browsers now
|
|
216
|
+
* refuse to navigate to at the top level anyway — refused here too, so the
|
|
217
|
+
* answer does not depend on which browser was asked.
|
|
218
|
+
*/
|
|
219
|
+
const SCRIPT_SCHEMES: $ReadOnlyArray<string> = ["javascript:", "vbscript:", "data:"];
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The scheme `to` would run as script when navigated to, or `null` when it is
|
|
223
|
+
* a URL a navigation may follow.
|
|
224
|
+
*
|
|
225
|
+
* Parsed with the WHATWG URL parser rather than compared as text, because the
|
|
226
|
+
* browser is what decides and it forgives a great deal: leading spaces and
|
|
227
|
+
* control characters are stripped, tabs and newlines anywhere are dropped, and
|
|
228
|
+
* the scheme is case-insensitive — ` JaVa\tScRiPt:` is `javascript:`. The base
|
|
229
|
+
* only resolves a relative path, which is never one of these.
|
|
230
|
+
*/
|
|
231
|
+
export function scriptSchemeOf(to: string): string | null {
|
|
232
|
+
let parsed: URL;
|
|
233
|
+
try {
|
|
234
|
+
parsed = new URL(to, "http://uf.invalid/");
|
|
235
|
+
} catch {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
return SCRIPT_SCHEMES.includes(parsed.protocol) ? parsed.protocol : null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Throw unless `to` is a URL a navigation may follow.
|
|
243
|
+
*
|
|
244
|
+
* Named after the call that asked (`redirect()`, `router.push()`), so the
|
|
245
|
+
* stack a developer reads points at the line that built the URL. The URL
|
|
246
|
+
* itself is not repeated: it is often a value a visitor chose, and an error
|
|
247
|
+
* message is something applications log.
|
|
248
|
+
*/
|
|
249
|
+
export function refuseScriptUrl(to: string, caller: string): void {
|
|
250
|
+
const scheme = scriptSchemeOf(to);
|
|
251
|
+
if (scheme != null) {
|
|
252
|
+
throw new Error(
|
|
253
|
+
`@uniflowed/router: ${caller} refused a ${scheme} URL, which a browser would run as ` +
|
|
254
|
+
"script in this page rather than load. If the URL came from a query parameter, allow " +
|
|
255
|
+
"only paths on this site before navigating to it.",
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Thrown by `redirect()`; the renderer answers with a redirect.
|
|
262
|
+
*
|
|
263
|
+
* Refuses a script URL when it is built (see [`refuseScriptUrl`]), so every
|
|
264
|
+
* place that follows one — the server's `Location`, the browser's
|
|
265
|
+
* `location.replace` in a single-page application, a form's `303` — is
|
|
266
|
+
* covered by one check rather than by one each.
|
|
267
|
+
*/
|
|
204
268
|
export class RedirectError extends Error {
|
|
205
269
|
to: string;
|
|
206
270
|
permanent: boolean;
|
|
207
271
|
|
|
208
272
|
constructor(to: string, permanent: boolean) {
|
|
273
|
+
refuseScriptUrl(to, permanent ? "permanentRedirect()" : "redirect()");
|
|
209
274
|
super(`redirect to ${to}`);
|
|
210
275
|
this.name = "RedirectError";
|
|
211
276
|
this.to = to;
|
|
@@ -241,9 +306,9 @@ function specificity(segments: $ReadOnlyArray<Segment>): number {
|
|
|
241
306
|
let score = 0;
|
|
242
307
|
for (const segment of segments) {
|
|
243
308
|
score += match (segment) {
|
|
244
|
-
{kind: "static"} => 3,
|
|
245
|
-
{kind: "param"} => 2,
|
|
246
|
-
{kind: "catchAll"} => 1,
|
|
309
|
+
{kind: "static", ...} => 3,
|
|
310
|
+
{kind: "param", ...} => 2,
|
|
311
|
+
{kind: "catchAll", ...} => 1,
|
|
247
312
|
};
|
|
248
313
|
}
|
|
249
314
|
return score;
|
|
@@ -338,12 +403,12 @@ function decodeSegment(segment: string): string {
|
|
|
338
403
|
}
|
|
339
404
|
|
|
340
405
|
/** Whether this table can render the route in the browser. */
|
|
341
|
-
export function hasClientPage(route: {
|
|
406
|
+
export function hasClientPage(route: { readonly page?: mixed, ... }): boolean {
|
|
342
407
|
return route.page != null;
|
|
343
408
|
}
|
|
344
409
|
|
|
345
410
|
/** Match a pathname against the table, preferring the most specific route. */
|
|
346
|
-
export function matchRoute<TRoute
|
|
411
|
+
export function matchRoute<TRoute extends { readonly path: string, ... }>(
|
|
347
412
|
routes: $ReadOnlyArray<TRoute>,
|
|
348
413
|
pathname: string,
|
|
349
414
|
): ?RouteMatch<TRoute> {
|
|
@@ -356,7 +421,7 @@ export function matchRoute<TRoute: { +path: string, ... }>(
|
|
|
356
421
|
* A slot is a second table matched against the same URL, and it has to be
|
|
357
422
|
* matched by this function rather than by one of its own.
|
|
358
423
|
*/
|
|
359
|
-
export function matchIn<TRoute
|
|
424
|
+
export function matchIn<TRoute extends { readonly path: string, ... }>(
|
|
360
425
|
routes: $ReadOnlyArray<TRoute>,
|
|
361
426
|
pathname: string,
|
|
362
427
|
): ?RouteMatch<TRoute> {
|
|
@@ -389,8 +454,8 @@ function covers(segments: $ReadOnlyArray<Segment>, parts: $ReadOnlyArray<string>
|
|
|
389
454
|
for (const segment of segments) {
|
|
390
455
|
const next = match (segment) {
|
|
391
456
|
{kind: "static", value: const value} => parts[index] === value ? index + 1 : -1,
|
|
392
|
-
{kind: "param"} => index < parts.length ? index + 1 : -1,
|
|
393
|
-
{kind: "catchAll"} => parts.length,
|
|
457
|
+
{kind: "param", ...} => index < parts.length ? index + 1 : -1,
|
|
458
|
+
{kind: "catchAll", ...} => parts.length,
|
|
394
459
|
};
|
|
395
460
|
if (next === -1) {
|
|
396
461
|
return false;
|
|
@@ -401,7 +466,7 @@ function covers(segments: $ReadOnlyArray<Segment>, parts: $ReadOnlyArray<string>
|
|
|
401
466
|
}
|
|
402
467
|
|
|
403
468
|
/** The nearest boundary above `pathname`, or `null` when none covers it. */
|
|
404
|
-
export function nearestBoundary<TBoundary
|
|
469
|
+
export function nearestBoundary<TBoundary extends { readonly path: string, ... }>(
|
|
405
470
|
boundaries: $ReadOnlyArray<TBoundary>,
|
|
406
471
|
pathname: string,
|
|
407
472
|
): ?TBoundary {
|
package/internal/runtime.js
CHANGED
|
@@ -100,7 +100,13 @@ import {
|
|
|
100
100
|
navigationKey,
|
|
101
101
|
routeNavigations,
|
|
102
102
|
} from "./navigation-cache.js";
|
|
103
|
-
import {
|
|
103
|
+
import {
|
|
104
|
+
hasClientPage,
|
|
105
|
+
matchRoute,
|
|
106
|
+
nearestBoundary,
|
|
107
|
+
refuseScriptUrl,
|
|
108
|
+
scriptSchemeOf,
|
|
109
|
+
} from "./routing.js";
|
|
104
110
|
import type { RouteParams, SearchParams } from "./routing.js";
|
|
105
111
|
import {
|
|
106
112
|
beneath,
|
|
@@ -352,6 +358,70 @@ export type Router = {|
|
|
|
352
358
|
readonly forward: () => void,
|
|
353
359
|
|};
|
|
354
360
|
|
|
361
|
+
/**
|
|
362
|
+
* The router of the `RouterProvider` on screen, or `null` when none is mounted.
|
|
363
|
+
*
|
|
364
|
+
* Module state, for the one caller that is not a component and still has to
|
|
365
|
+
* navigate: a server action reference whose action called `redirect()`
|
|
366
|
+
* (`../action.js`). A reference is a plain function that React calls from a
|
|
367
|
+
* form or a transition, with no hook to read the context through, and the
|
|
368
|
+
* navigation it owes is the one `router.push` would make — a client render
|
|
369
|
+
* under client navigation, a document load under document navigation.
|
|
370
|
+
*/
|
|
371
|
+
let mountedRouter: Router | null = null;
|
|
372
|
+
|
|
373
|
+
/** Publish `router` as [`mountedRouter`] while its provider is mounted. */
|
|
374
|
+
hook useMountedRouter(router: Router): void {
|
|
375
|
+
useEffect(() => {
|
|
376
|
+
mountedRouter = router;
|
|
377
|
+
return () => {
|
|
378
|
+
if (mountedRouter === router) {
|
|
379
|
+
mountedRouter = null;
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Go where a server action's `redirect()` pointed.
|
|
387
|
+
*
|
|
388
|
+
* `location` is the address the action endpoint answered with — already under
|
|
389
|
+
* `app.router.basePath` for a path on this application — and `from` is the page
|
|
390
|
+
* the call was made from, which a relative `location` is resolved against: the
|
|
391
|
+
* visitor may have navigated while the action ran, and `redirect("next")` means
|
|
392
|
+
* next to the page that called it. A path on this origin is the mounted
|
|
393
|
+
* router's `push`, so the page underneath stays hydrated and a layout keeps its
|
|
394
|
+
* state, exactly as a `Link` to it would; another origin, or no router on
|
|
395
|
+
* screen, is the browser's document load. The promise settles when the
|
|
396
|
+
* navigation has, so a `useActionState` that awaited the action is not left
|
|
397
|
+
* pending on a page that has moved on.
|
|
398
|
+
*
|
|
399
|
+
* Only `http:` and `https:` are followed. `redirect()` refuses a script URL
|
|
400
|
+
* where it is built, but `location` arrived over the network, and
|
|
401
|
+
* `location.assign("javascript:…")` runs it in this page; a scheme this
|
|
402
|
+
* function would not follow is thrown as an error instead.
|
|
403
|
+
*/
|
|
404
|
+
export async function followActionRedirect(location: string, from: string): Promise<void> {
|
|
405
|
+
const target = new URL(location, new URL(from, window.location.href));
|
|
406
|
+
if (target.protocol !== "http:" && target.protocol !== "https:") {
|
|
407
|
+
throw new Error(
|
|
408
|
+
`@uniflowed/router: a server action redirected to a ${target.protocol} URL, which is not ` +
|
|
409
|
+
"followed. A redirect goes to an http or https address.",
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
const router = mountedRouter;
|
|
413
|
+
if (router == null || target.origin !== window.location.origin) {
|
|
414
|
+
window.location.assign(target.href);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
const applicationPath = applicationPathOf(target.pathname);
|
|
418
|
+
if (applicationPath == null) {
|
|
419
|
+
window.location.assign(target.href);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
await router.push(applicationPath + target.search + target.hash);
|
|
423
|
+
}
|
|
424
|
+
|
|
355
425
|
/** What `useRoute()` returns. */
|
|
356
426
|
export type RouteInfo = {|
|
|
357
427
|
readonly path: string,
|
|
@@ -679,6 +749,9 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
|
|
|
679
749
|
if (!isBrowser()) {
|
|
680
750
|
return;
|
|
681
751
|
}
|
|
752
|
+
// Before anything else: every path below may end in `location.assign`,
|
|
753
|
+
// which runs a `javascript:` URL in this page. See `refuseScriptUrl`.
|
|
754
|
+
refuseScriptUrl(to, "a router navigation");
|
|
682
755
|
const target = new URL(addressOf(to), window.location.href);
|
|
683
756
|
// The application path the route table is asked about, and the address the
|
|
684
757
|
// history entry keeps: one URL, with and without `app.router.basePath`.
|
|
@@ -973,6 +1046,8 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
|
|
|
973
1046
|
},
|
|
974
1047
|
};
|
|
975
1048
|
|
|
1049
|
+
useMountedRouter(router);
|
|
1050
|
+
|
|
976
1051
|
const value: RouterState = {
|
|
977
1052
|
route: routeState(resolved),
|
|
978
1053
|
view: { kind: "modules", resolved },
|
|
@@ -1019,6 +1094,8 @@ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
|
|
|
1019
1094
|
if (!isBrowser()) {
|
|
1020
1095
|
return;
|
|
1021
1096
|
}
|
|
1097
|
+
// As in `ModuleRouter`: a `javascript:` URL never reaches `location`.
|
|
1098
|
+
refuseScriptUrl(to, "a router navigation");
|
|
1022
1099
|
// A payload URL is an address, so it keeps the base path; the server takes
|
|
1023
1100
|
// it off.
|
|
1024
1101
|
const target = new URL(addressOf(to), window.location.href);
|
|
@@ -1243,6 +1320,8 @@ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
|
|
|
1243
1320
|
},
|
|
1244
1321
|
};
|
|
1245
1322
|
|
|
1323
|
+
useMountedRouter(router);
|
|
1324
|
+
|
|
1246
1325
|
const value: RouterState = {
|
|
1247
1326
|
route: root.route,
|
|
1248
1327
|
view: { kind: "flight", tree: root.tree },
|
|
@@ -1781,6 +1860,28 @@ export hook useLinkStatus(): {| readonly pending: boolean |} {
|
|
|
1781
1860
|
return { pending: useContext(LinkStatusContext) };
|
|
1782
1861
|
}
|
|
1783
1862
|
|
|
1863
|
+
/**
|
|
1864
|
+
* The click a `Link` hands its `onClick`: React's synthetic mouse event, as
|
|
1865
|
+
* much of it as a link reads.
|
|
1866
|
+
*
|
|
1867
|
+
* Flow's React library no longer declares `SyntheticMouseEvent`, so the name
|
|
1868
|
+
* this prop used did not resolve and every handler passed to it was unchecked.
|
|
1869
|
+
* Stated structurally, as `@uniflowed/ui`'s `PartEvent` is; the event React
|
|
1870
|
+
* passes has every member below.
|
|
1871
|
+
*/
|
|
1872
|
+
export type LinkClickEvent = {
|
|
1873
|
+
readonly defaultPrevented: boolean,
|
|
1874
|
+
readonly button: number,
|
|
1875
|
+
readonly altKey: boolean,
|
|
1876
|
+
readonly ctrlKey: boolean,
|
|
1877
|
+
readonly metaKey: boolean,
|
|
1878
|
+
readonly shiftKey: boolean,
|
|
1879
|
+
readonly currentTarget: mixed,
|
|
1880
|
+
readonly preventDefault: () => mixed,
|
|
1881
|
+
readonly stopPropagation: () => mixed,
|
|
1882
|
+
...
|
|
1883
|
+
};
|
|
1884
|
+
|
|
1784
1885
|
/**
|
|
1785
1886
|
* A client-side navigation.
|
|
1786
1887
|
*
|
|
@@ -1813,8 +1914,10 @@ export component Link(
|
|
|
1813
1914
|
transition?: boolean = true,
|
|
1814
1915
|
children?: React.Node,
|
|
1815
1916
|
className?: string,
|
|
1816
|
-
onClick?: (event:
|
|
1817
|
-
|
|
1917
|
+
onClick?: (event: LinkClickEvent) => mixed,
|
|
1918
|
+
// `key` named out of the indexer: React never hands a component its key, and
|
|
1919
|
+
// the rest is spread onto the anchor, where a `mixed` key would not do.
|
|
1920
|
+
...rest: { readonly key?: empty, readonly [string]: mixed }
|
|
1818
1921
|
) {
|
|
1819
1922
|
const { router, navigation } = useRouterState();
|
|
1820
1923
|
const [linkPending, startLinkTransition] = useTransition();
|
|
@@ -1835,7 +1938,7 @@ export component Link(
|
|
|
1835
1938
|
}
|
|
1836
1939
|
});
|
|
1837
1940
|
|
|
1838
|
-
const handleClick = (event:
|
|
1941
|
+
const handleClick = (event: LinkClickEvent) => {
|
|
1839
1942
|
if (onClick != null) {
|
|
1840
1943
|
onClick(event);
|
|
1841
1944
|
}
|
|
@@ -1857,9 +1960,14 @@ export component Link(
|
|
|
1857
1960
|
try {
|
|
1858
1961
|
await router.push(to, { replace, transition });
|
|
1859
1962
|
} catch (error) {
|
|
1860
|
-
// A failed navigation falls back to the browser doing it
|
|
1963
|
+
// A failed navigation falls back to the browser doing it — unless what
|
|
1964
|
+
// failed was the router refusing a script URL, which the browser would
|
|
1965
|
+
// run rather than load. `isExternal` above does not catch every
|
|
1966
|
+
// spelling of one: ` javascript:` with a leading space is a path to it.
|
|
1861
1967
|
console.error(error);
|
|
1862
|
-
|
|
1968
|
+
if (scriptSchemeOf(to) == null) {
|
|
1969
|
+
window.location.assign(addressOf(to));
|
|
1970
|
+
}
|
|
1863
1971
|
}
|
|
1864
1972
|
});
|
|
1865
1973
|
};
|
package/internal/shell.js
CHANGED
|
@@ -15,6 +15,7 @@ import type { PrerenderResult, RenderAssets, RenderResult } from "../server.js";
|
|
|
15
15
|
import { addressOf } from "./base-path.js";
|
|
16
16
|
import { DEPLOYMENT_META } from "./deployment.js";
|
|
17
17
|
import { ROOT_ID } from "./document.js";
|
|
18
|
+
import { type FormState, formStateScript } from "./form-action.js";
|
|
18
19
|
import type { RedirectError } from "./routing.js";
|
|
19
20
|
import { type DocumentShell, bodyOfText } from "./stream.js";
|
|
20
21
|
|
|
@@ -79,8 +80,14 @@ export function redirectDocument(error: RedirectError): RenderResult {
|
|
|
79
80
|
* carried two of them, one in each place, and only one was where a browser
|
|
80
81
|
* looks. Hoisting the rendered one leaves the metadata with a single source.
|
|
81
82
|
*/
|
|
82
|
-
export function shellFor(
|
|
83
|
-
|
|
83
|
+
export function shellFor(
|
|
84
|
+
assets: RenderAssets,
|
|
85
|
+
nonce?: string | null,
|
|
86
|
+
formState?: FormState,
|
|
87
|
+
): DocumentShell {
|
|
88
|
+
// A postback's form state goes first, before the client entry that reads it:
|
|
89
|
+
// data rather than a script, so it needs no nonce. See `./form-action.js`.
|
|
90
|
+
const head = (formState == null ? "" : formStateScript(formState)) + headTags(assets, nonce);
|
|
84
91
|
return {
|
|
85
92
|
head,
|
|
86
93
|
open: `<!doctype html>\n<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">`,
|
package/internal/stream.js
CHANGED
|
@@ -44,6 +44,7 @@ import * as ReactDOMServer from "react-dom/server";
|
|
|
44
44
|
import * as ReactDOMStatic from "react-dom/static";
|
|
45
45
|
|
|
46
46
|
import { createChunkEncoder } from "./flight-chunks.js";
|
|
47
|
+
import type { FormState } from "./form-action.js";
|
|
47
48
|
import { type StreamRecord, inspected } from "./inspector.js";
|
|
48
49
|
|
|
49
50
|
/**
|
|
@@ -99,12 +100,25 @@ type NodeDestination = {
|
|
|
99
100
|
};
|
|
100
101
|
|
|
101
102
|
/** The controller a `ReadableStream` source is handed. */
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
103
|
+
/**
|
|
104
|
+
* What `renderToPipeableStream` hands back, as much of it as is used here.
|
|
105
|
+
*
|
|
106
|
+
* Stated because the callbacks passed to the call use its result, and Flow
|
|
107
|
+
* cannot type a value whose definition depends on itself without being told.
|
|
108
|
+
*/
|
|
109
|
+
type PipeableStream = {
|
|
110
|
+
readonly pipe: (destination: NodeDestination) => mixed,
|
|
111
|
+
readonly abort: (reason?: mixed) => void,
|
|
105
112
|
...
|
|
106
113
|
};
|
|
107
114
|
|
|
115
|
+
type StreamController = interface {
|
|
116
|
+
// Methods, as a `ReadableStreamDefaultController`'s are: a method cannot be
|
|
117
|
+
// read off its object as a function-valued property.
|
|
118
|
+
enqueue(chunk: Uint8Array): mixed,
|
|
119
|
+
close(): mixed,
|
|
120
|
+
};
|
|
121
|
+
|
|
108
122
|
/**
|
|
109
123
|
* A stream of bytes, as much of one as this module reads.
|
|
110
124
|
*
|
|
@@ -112,13 +126,13 @@ type StreamController = {
|
|
|
112
126
|
* result and `react-dom/static`'s `prelude` — and neither is typed by anything
|
|
113
127
|
* uf can import, so the shape it is used through is stated here.
|
|
114
128
|
*/
|
|
115
|
-
type ByteSource = {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
...
|
|
129
|
+
type ByteSource = interface {
|
|
130
|
+
// Methods, as a `ReadableStream`'s are: a class instance is not a subtype of
|
|
131
|
+
// an object type with function-valued properties.
|
|
132
|
+
getReader(): interface {
|
|
133
|
+
read(): Promise<{ readonly done?: boolean, readonly value?: Uint8Array, ... }>,
|
|
134
|
+
releaseLock(): mixed,
|
|
120
135
|
},
|
|
121
|
-
...
|
|
122
136
|
};
|
|
123
137
|
|
|
124
138
|
/** How the document is assembled around the app's markup. */
|
|
@@ -693,6 +707,14 @@ export type RenderOptions = {|
|
|
|
693
707
|
* `prerenderDocument` is deliberately never given one. See its own paragraph.
|
|
694
708
|
*/
|
|
695
709
|
readonly nonce?: string | null,
|
|
710
|
+
/**
|
|
711
|
+
* React's `formState`, for a document that answers a form posted before
|
|
712
|
+
* hydration: the `useActionState` that submitted starts from the action's
|
|
713
|
+
* result, and React marks it so the browser's hydration picks the same one.
|
|
714
|
+
* The shell carries the same value for `hydrateRoot`; see
|
|
715
|
+
* `./form-action.js`. Absent for every other render.
|
|
716
|
+
*/
|
|
717
|
+
readonly formState?: FormState,
|
|
696
718
|
|};
|
|
697
719
|
|
|
698
720
|
/**
|
|
@@ -723,7 +745,7 @@ export function renderDocument(node: React.Node, options: RenderOptions): Promis
|
|
|
723
745
|
const queue = new ChunkQueue();
|
|
724
746
|
return new Promise((resolve, reject) => {
|
|
725
747
|
if (typeof ReactDOMServer.renderToPipeableStream === "function") {
|
|
726
|
-
const { pipe, abort } = ReactDOMServer.renderToPipeableStream(node, {
|
|
748
|
+
const { pipe, abort }: PipeableStream = ReactDOMServer.renderToPipeableStream(node, {
|
|
727
749
|
onShellReady() {
|
|
728
750
|
pipe(queueDestination(queue));
|
|
729
751
|
resolve(
|
|
@@ -751,6 +773,7 @@ export function renderDocument(node: React.Node, options: RenderOptions): Promis
|
|
|
751
773
|
// and the bootstrap. uf nonces the scripts it writes itself; these are
|
|
752
774
|
// React's, and there is no other way to reach them.
|
|
753
775
|
nonce: options.nonce ?? undefined,
|
|
776
|
+
formState: options.formState ?? null,
|
|
754
777
|
});
|
|
755
778
|
return;
|
|
756
779
|
}
|
|
@@ -779,6 +802,7 @@ type ReadableStreamRenderer = (
|
|
|
779
802
|
readonly onError: (error: mixed) => void,
|
|
780
803
|
readonly signal: AbortSignal,
|
|
781
804
|
readonly nonce?: string,
|
|
805
|
+
readonly formState?: FormState | null,
|
|
782
806
|
|},
|
|
783
807
|
) => Promise<ByteSource>;
|
|
784
808
|
|
|
@@ -808,6 +832,7 @@ export function renderWithReadableStream(
|
|
|
808
832
|
onError: options.onError,
|
|
809
833
|
signal: controller.signal,
|
|
810
834
|
nonce: options.nonce ?? undefined,
|
|
835
|
+
formState: options.formState ?? null,
|
|
811
836
|
}).then((stream: ByteSource) =>
|
|
812
837
|
bodyOf(
|
|
813
838
|
outgoing(
|
|
@@ -1343,13 +1368,15 @@ function advanced(boundary: Boundary, html: string, rootDepth: number): Boundary
|
|
|
1343
1368
|
*
|
|
1344
1369
|
* `prerenderToNodeStream` hands back a Node `Readable` and `prerender` a web
|
|
1345
1370
|
* `ReadableStream`, and which one a build has depends on which React entry
|
|
1346
|
-
* point exists — so the union is real rather than defensive
|
|
1347
|
-
* is
|
|
1371
|
+
* point exists — so the union is real rather than defensive. Which one it is
|
|
1372
|
+
* is asked of the class: a web stream is a `ReadableStream`, and a Node
|
|
1373
|
+
* `Readable` is not. A test of a `getReader` property answered the same at run
|
|
1374
|
+
* time, but a union of two structural types cannot be narrowed by one.
|
|
1348
1375
|
*/
|
|
1349
1376
|
async function* preludeChunks(
|
|
1350
|
-
prelude:
|
|
1377
|
+
prelude: ReadableStream | AsyncIterable<string | Uint8Array>,
|
|
1351
1378
|
): AsyncGenerator<string, void, void> {
|
|
1352
|
-
if (
|
|
1379
|
+
if (prelude instanceof ReadableStream) {
|
|
1353
1380
|
yield* decoded(prelude);
|
|
1354
1381
|
return;
|
|
1355
1382
|
}
|