@uniflowed/router 0.2.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.
@@ -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
  };
@@ -100,12 +100,25 @@ type NodeDestination = {
100
100
  };
101
101
 
102
102
  /** The controller a `ReadableStream` source is handed. */
103
- type StreamController = {
104
- readonly enqueue: (chunk: Uint8Array) => mixed,
105
- 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,
106
112
  ...
107
113
  };
108
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
+
109
122
  /**
110
123
  * A stream of bytes, as much of one as this module reads.
111
124
  *
@@ -113,13 +126,13 @@ type StreamController = {
113
126
  * result and `react-dom/static`'s `prelude` — and neither is typed by anything
114
127
  * uf can import, so the shape it is used through is stated here.
115
128
  */
116
- type ByteSource = {
117
- readonly getReader: () => {
118
- readonly read: () => Promise<{ readonly done?: boolean, readonly value?: Uint8Array, ... }>,
119
- readonly releaseLock: () => mixed,
120
- ...
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,
121
135
  },
122
- ...
123
136
  };
124
137
 
125
138
  /** How the document is assembled around the app's markup. */
@@ -732,7 +745,7 @@ export function renderDocument(node: React.Node, options: RenderOptions): Promis
732
745
  const queue = new ChunkQueue();
733
746
  return new Promise((resolve, reject) => {
734
747
  if (typeof ReactDOMServer.renderToPipeableStream === "function") {
735
- const { pipe, abort } = ReactDOMServer.renderToPipeableStream(node, {
748
+ const { pipe, abort }: PipeableStream = ReactDOMServer.renderToPipeableStream(node, {
736
749
  onShellReady() {
737
750
  pipe(queueDestination(queue));
738
751
  resolve(
@@ -1355,13 +1368,15 @@ function advanced(boundary: Boundary, html: string, rootDepth: number): Boundary
1355
1368
  *
1356
1369
  * `prerenderToNodeStream` hands back a Node `Readable` and `prerender` a web
1357
1370
  * `ReadableStream`, and which one a build has depends on which React entry
1358
- * point exists — so the union is real rather than defensive, and `getReader`
1359
- * 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.
1360
1375
  */
1361
1376
  async function* preludeChunks(
1362
- prelude: ByteSource | AsyncIterable<string | Uint8Array>,
1377
+ prelude: ReadableStream | AsyncIterable<string | Uint8Array>,
1363
1378
  ): AsyncGenerator<string, void, void> {
1364
- if (typeof prelude.getReader === "function") {
1379
+ if (prelude instanceof ReadableStream) {
1365
1380
  yield* decoded(prelude);
1366
1381
  return;
1367
1382
  }
package/middleware.js CHANGED
@@ -395,12 +395,28 @@ function withPathname(url: URL, pathname: string): URL {
395
395
  /**
396
396
  * `request` at another URL: same method, headers, signal and body.
397
397
  *
398
- * A `Request` is a valid `RequestInit`, so a streamed body is handed on rather
399
- * than read.
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.
400
402
  */
401
403
  function requestAt(request: Request, url: URL): Request {
402
- // $FlowFixMe[incompatible-call] - a `Request` is read as the `RequestInit` it satisfies.
403
- return new Request(url.href, request);
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
+ });
404
420
  }
405
421
 
406
422
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/router",
3
- "version": "0.2.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.2.0",
75
- "@uniflowed/server": "0.2.0"
76
+ "@uniflowed/hooks": "0.3.0",
77
+ "@uniflowed/server": "0.3.0"
76
78
  }
77
79
  }
package/rsc-ssr.js CHANGED
@@ -144,7 +144,7 @@ export function createDocumentRenderer(options: DocumentRendererOptions): Render
144
144
  assets: RenderAssets,
145
145
  settings?: RenderOptions,
146
146
  ): Promise<RenderResult> {
147
- const report = settings?.onError ?? (() => {});
147
+ const report = settings?.onError ?? ((_error: mixed) => {});
148
148
  const send = settings?.onStream;
149
149
  const onStream = send == null ? undefined : streamReporter(url, send);
150
150
  const transformHead = settings?.transformHead;
@@ -248,7 +248,7 @@ export function createDocumentRenderer(options: DocumentRendererOptions): Render
248
248
  assets: RenderAssets,
249
249
  settings?: RenderOptions,
250
250
  ): Promise<PrerenderResult> {
251
- const report = settings?.onError ?? (() => {});
251
+ const report = settings?.onError ?? ((_error: mixed) => {});
252
252
  const ledger = createErrorLedger();
253
253
  // A read of the request, while the build may leave it for the request, is
254
254
  // not an exception to report: it is how the build finds the holes. Its row
@@ -405,7 +405,7 @@ export function createDocumentRenderer(options: DocumentRendererOptions): Render
405
405
  shell: PrerenderedShell,
406
406
  settings?: RenderOptions,
407
407
  ): Promise<RenderResult> {
408
- const report = settings?.onError ?? (() => {});
408
+ const report = settings?.onError ?? ((_error: mixed) => {});
409
409
  const ledger = createErrorLedger();
410
410
  const stop = new AbortController();
411
411
  const rendered = await renderFlight(url, {
@@ -477,7 +477,16 @@ export function createDocumentRenderer(options: DocumentRendererOptions): Render
477
477
  }
478
478
  return {
479
479
  status: rendered.status,
480
- headers: { "content-type": FLIGHT_CONTENT_TYPE, vary: INTERCEPTED_FROM_HEADER },
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
+ },
481
490
  stream: rendered.stream,
482
491
  error: rendered.failure,
483
492
  };
@@ -601,7 +610,7 @@ function digestOf(error: mixed): string | null {
601
610
  if (error == null || typeof error !== "object") {
602
611
  return null;
603
612
  }
604
- const tagged: { +digest?: mixed, ... } = (error: $FlowFixMe);
613
+ const tagged: { readonly digest?: mixed, ... } = error as $FlowFixMe;
605
614
  return typeof tagged.digest === "string" ? tagged.digest : null;
606
615
  }
607
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 { ErrorModule, PageModule, ResolvedRoute, ResolvedSlot } from "./internal/resolve.js";
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, RouteTable, SearchParams } from "./internal/routing.js";
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
- const failure = renderFailure(route);
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: renderFailure(route) };
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: { +$$typeof?: mixed, ... } = (value: $FlowFixMe);
404
+ const tagged: { readonly $$typeof?: mixed, ... } = value as $FlowFixMe;
389
405
  return tagged.$$typeof === CLIENT_REFERENCE;
390
406
  }
391
407
 
package/server.js CHANGED
@@ -412,7 +412,7 @@ export function createRenderer(options: {|
412
412
  return redirectDocument(resolution.error);
413
413
  }
414
414
  let resolved: ResolvedRoute = resolution.route;
415
- const report = settings?.onError ?? (() => {});
415
+ const report = settings?.onError ?? ((_error: mixed) => {});
416
416
  // Built once and shared by both renders below, so a page that threw its
417
417
  // shell away and rendered its error boundary instead reports the stream the
418
418
  // browser was actually sent rather than the one that was abandoned.
@@ -505,7 +505,7 @@ export function createRenderer(options: {|
505
505
  return redirectResult(redirectDocument(resolution.error));
506
506
  }
507
507
  let resolved: ResolvedRoute = resolution.route;
508
- const report = settings?.onError ?? (() => {});
508
+ const report = settings?.onError ?? ((_error: mixed) => {});
509
509
 
510
510
  let html: string;
511
511
  try {