@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.
@@ -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: $FlowFixMe).then;
245
- return typeof then === "function" ? (value: $FlowFixMe) : null;
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: { +[string]: mixed } = (value: $FlowFixMe);
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: { +[string]: mixed } = (value: $FlowFixMe);
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: { +[string]: mixed } = (value: $FlowFixMe);
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: { +[string]: mixed } = (value: $FlowFixMe);
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
- return JSON.stringify(value)
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: { +[string]: mixed } = (parsed: $FlowFixMe);
664
- const hasValue = Object.prototype.hasOwnProperty.call(message, "value");
665
- const hasError = Object.prototype.hasOwnProperty.call(message, "error");
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);
@@ -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-return] the cache is keyed by the loader, whose result type it stores.
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
- const [page, ...layouts] = await Promise.all([
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
- ...matched.route.layouts.map((layout) => loadOnce(layout)),
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, ...layouts] = await Promise.all([
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
- ...(record?.layouts ?? []).map((layout) => loadOnce(layout)),
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
- merged = {
1531
- ...merged,
1532
- ...(title != null ? { title } : {}),
1533
- ...(description != null ? { description } : {}),
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
 
@@ -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: { +path: string, ... } = UnknownRouteRecord> = {|
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
- /** Thrown by `redirect()`; the renderer answers with a redirect. */
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: { +page?: mixed, ... }): boolean {
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: { +path: string, ... }>(
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: { +path: string, ... }>(
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: { readonly path: string, ... }>(
469
+ export function nearestBoundary<TBoundary extends { readonly path: string, ... }>(
405
470
  boundaries: $ReadOnlyArray<TBoundary>,
406
471
  pathname: string,
407
472
  ): ?TBoundary {
@@ -100,7 +100,13 @@ import {
100
100
  navigationKey,
101
101
  routeNavigations,
102
102
  } from "./navigation-cache.js";
103
- import { hasClientPage, matchRoute, nearestBoundary } from "./routing.js";
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: SyntheticMouseEvent<HTMLAnchorElement>) => mixed,
1817
- ...rest: { readonly [string]: mixed }
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: SyntheticMouseEvent<HTMLAnchorElement>) => {
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
- window.location.assign(addressOf(to));
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(assets: RenderAssets, nonce?: string | null): DocumentShell {
83
- const head = headTags(assets, nonce);
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">`,
@@ -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
- type StreamController = {
103
- readonly enqueue: (chunk: Uint8Array) => mixed,
104
- readonly close: () => mixed,
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
- readonly getReader: () => {
117
- readonly read: () => Promise<{ readonly done?: boolean, readonly value?: Uint8Array, ... }>,
118
- readonly releaseLock: () => mixed,
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, and `getReader`
1347
- * is what tells them apart.
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: ByteSource | AsyncIterable<string | Uint8Array>,
1377
+ prelude: ReadableStream | AsyncIterable<string | Uint8Array>,
1351
1378
  ): AsyncGenerator<string, void, void> {
1352
- if (typeof prelude.getReader === "function") {
1379
+ if (prelude instanceof ReadableStream) {
1353
1380
  yield* decoded(prelude);
1354
1381
  return;
1355
1382
  }