@uniflowed/router 0.0.0-alpha.35 → 0.0.0-alpha.37

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.
@@ -74,9 +74,9 @@ import {
74
74
  import { BoundaryReporter } from "./boundaries.js";
75
75
  import { routeBoundaries } from "./boundary-data.js";
76
76
  import { composeRoute, pageComponent } from "./compose.js";
77
- import { type FetchedFlight, fetchFlight } from "./flight-browser.js";
78
- import { type FlightRoot, type RouteState, routeState } from "./flight.js";
77
+ import { type FetchedFlight, type FlightRoot, type RouteState, routeState } from "./flight.js";
79
78
  import { Head } from "./head.js";
79
+ import { addressOf, applicationPathOf, canonicalAddress } from "./base-path.js";
80
80
  import { hasClientPage, matchRoute, nearestBoundary } from "./routing.js";
81
81
  import type { RouteParams, SearchParams } from "./routing.js";
82
82
  import {
@@ -137,6 +137,11 @@ export type {
137
137
 
138
138
  export { resolveFailure, resolveMatch } from "./resolve.js";
139
139
 
140
+ // `app.router.basePath` and `trailingSlash`, installed by the entry that starts
141
+ // the application; see `./base-path.js`.
142
+ export type { RoutingSettings, TrailingSlash } from "./base-path.js";
143
+ export { basePath, installRouting } from "./base-path.js";
144
+
140
145
  // ---------------------------------------------------------------------------
141
146
  // View transitions
142
147
  // ---------------------------------------------------------------------------
@@ -397,6 +402,40 @@ export function navigationMode(): Navigation {
397
402
  return installedNavigation;
398
403
  }
399
404
 
405
+ /**
406
+ * How a page that React Server Components rendered fetches the next route's
407
+ * payload. `hydrateFlight` in `../rsc-client.js` installs it.
408
+ *
409
+ * Handed in rather than imported, because this module is in every
410
+ * application's bundle: one rendered from its modules, a single-page one, and
411
+ * the server's. The fetch reads its answer with React's Flight client,
412
+ * `react-server-dom-parcel`, which only an application that renders Server
413
+ * Components installs, and which needs React 19.3 while the rest of the router
414
+ * runs on 19.2.3 (ubugeeei-prod/uf#992). A bundler resolves every import it is
415
+ * shown, whether or not anything calls it, so an import here would put that
416
+ * package in every one of those bundles, or fail the build where it is absent.
417
+ */
418
+ let installedFlightFetch: ((url: string) => Promise<FetchedFlight>) | null = null;
419
+
420
+ /** Hand the router the payload fetch. Called once, by `hydrateFlight`, before the first render. */
421
+ export function installFlightFetch(fetcher: (url: string) => Promise<FetchedFlight>): void {
422
+ installedFlightFetch = fetcher;
423
+ }
424
+
425
+ /** The next route's payload, through the fetch `hydrateFlight` installed. */
426
+ function fetchFlight(url: string): Promise<FetchedFlight> {
427
+ if (installedFlightFetch == null) {
428
+ return Promise.reject(
429
+ new Error(
430
+ "@uniflowed/router: a page rendered from a Flight payload navigated before anything " +
431
+ "installed the payload fetch. `hydrateFlight` from `@uniflowed/router/rsc/client` " +
432
+ "installs it before it hydrates, so an entry that hydrates a payload has to call that.",
433
+ ),
434
+ );
435
+ }
436
+ return installedFlightFetch(url);
437
+ }
438
+
400
439
  /** Register the generated route table. Called once by the client and server entries. */
401
440
  export function installRoutes(table: RouteTable): void {
402
441
  installedTable = table;
@@ -585,8 +624,12 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
585
624
  if (!isBrowser()) {
586
625
  return;
587
626
  }
588
- const target = new URL(to, window.location.href);
589
- const next = target.pathname + target.search;
627
+ const target = new URL(addressOf(to), window.location.href);
628
+ // The application path the route table is asked about, and the address the
629
+ // history entry keeps: one URL, with and without `app.router.basePath`.
630
+ const applicationPath = applicationPathOf(target.pathname);
631
+ const next = (applicationPath ?? target.pathname) + target.search;
632
+ const address = target.pathname + target.search;
590
633
  // The browser's job in this application. `assign` and `replace` rather
591
634
  // than the history API, because the point is a document request: the
592
635
  // history entry, the scroll position, the `Referer` and the unload
@@ -605,8 +648,13 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
605
648
  // screen that intercepts the URL renders a page of its own, so whether the
606
649
  // URL's ordinary page is in this bundle — the paragraph below — is not a
607
650
  // question this navigation has to ask.
651
+ // An address outside the base path is not this application's to render.
652
+ if (applicationPath == null) {
653
+ window.location.assign(target.href);
654
+ return;
655
+ }
608
656
  const origin = beneath(shown.current);
609
- const intercepting = interceptingRoutes(origin.slots, target.pathname).length > 0;
657
+ const intercepting = interceptingRoutes(origin.slots, applicationPath).length > 0;
610
658
  // The half of the split that is not about bytes. A route whose page is not
611
659
  // in this bundle is not a route this router can render, and pretending
612
660
  // otherwise is the silent break: the navigation would resolve to nothing
@@ -615,7 +663,7 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
615
663
  // link does when there is no JavaScript at all, and what the anchor
616
664
  // `Link` renders would have done on its own.
617
665
  if (!intercepting) {
618
- const matched = matchRoute(routeTable().routes, target.pathname);
666
+ const matched = matchRoute(routeTable().routes, applicationPath);
619
667
  if (matched != null && !hasClientPage(matched.route)) {
620
668
  window.location.assign(target.href);
621
669
  return;
@@ -631,9 +679,9 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
631
679
  // entry is written the way it always was.
632
680
  const state = historyStateFor(nextResolved);
633
681
  if (options?.replace === true) {
634
- window.history.replaceState(state, "", next + target.hash);
682
+ window.history.replaceState(state, "", address + target.hash);
635
683
  } else {
636
- window.history.pushState(state, "", next + target.hash);
684
+ window.history.pushState(state, "", address + target.hash);
637
685
  }
638
686
  const commit = () => {
639
687
  show(nextResolved);
@@ -698,7 +746,9 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
698
746
  });
699
747
  };
700
748
  const onPopState = () => {
701
- const next = window.location.pathname + window.location.search;
749
+ const next =
750
+ (applicationPathOf(window.location.pathname) ?? window.location.pathname) +
751
+ window.location.search;
702
752
  // Back or forward into an entry an interception wrote: the page it was
703
753
  // intercepted from, with the interception over it again. That page is
704
754
  // resolved afresh only when it is not already the one underneath, so
@@ -723,7 +773,10 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
723
773
  // Back into a route this bundle has no page for. The history entry is
724
774
  // already the browser's — it moved before this listener ran — so the
725
775
  // document that belongs to it is what has to be fetched.
726
- const matched = matchRoute(routeTable().routes, window.location.pathname);
776
+ const matched = matchRoute(
777
+ routeTable().routes,
778
+ applicationPathOf(window.location.pathname) ?? window.location.pathname,
779
+ );
727
780
  if (matched != null && !hasClientPage(matched.route)) {
728
781
  window.location.reload();
729
782
  return;
@@ -748,11 +801,15 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
748
801
  if (!isBrowser() || navigation === "document") {
749
802
  return;
750
803
  }
751
- const target = new URL(to, window.location.href);
804
+ const target = new URL(addressOf(to), window.location.href);
805
+ const applicationPath = applicationPathOf(target.pathname);
806
+ if (applicationPath == null) {
807
+ return;
808
+ }
752
809
  // What the next render will need is decided the way the navigation will
753
810
  // decide it: a URL a slot on screen intercepts renders that slot's page,
754
811
  // so that is the module worth having, and the page the URL names is not.
755
- const intercepting = interceptingRoutes(beneath(shown.current).slots, target.pathname);
812
+ const intercepting = interceptingRoutes(beneath(shown.current).slots, applicationPath);
756
813
  if (intercepting.length > 0) {
757
814
  await Promise.all(
758
815
  intercepting.flatMap((route) => [
@@ -762,7 +819,7 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
762
819
  );
763
820
  return;
764
821
  }
765
- const matched = matchRoute(routeTable().routes, target.pathname);
822
+ const matched = matchRoute(routeTable().routes, applicationPath);
766
823
  const load = matched?.route.page;
767
824
  if (matched == null || load == null) {
768
825
  return;
@@ -791,7 +848,11 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
791
848
  const interception = shown.current.interception;
792
849
  const nextResolved =
793
850
  interception == null
794
- ? await resolveMatch(routeTable(), window.location.pathname + window.location.search)
851
+ ? await resolveMatch(
852
+ routeTable(),
853
+ (applicationPathOf(window.location.pathname) ?? window.location.pathname) +
854
+ window.location.search,
855
+ )
795
856
  : ((await resolveInterception(
796
857
  routeTable(),
797
858
  await resolveMatch(
@@ -859,7 +920,9 @@ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
859
920
  if (!isBrowser()) {
860
921
  return;
861
922
  }
862
- const target = new URL(to, window.location.href);
923
+ // A payload URL is an address, so it keeps the base path; the server takes
924
+ // it off.
925
+ const target = new URL(addressOf(to), window.location.href);
863
926
  const next = target.pathname + target.search;
864
927
  // The browser's job in this application; `ModuleRouter` has the argument.
865
928
  if (navigation === "document") {
@@ -884,7 +947,11 @@ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
884
947
  const nextRoot = await payload;
885
948
  // The URL the payload came from, which is a redirect's target when the
886
949
  // route redirected: the history entry is where the visitor ended up.
887
- const landed = fetched.url + target.hash;
950
+ // In the trailing-slash policy's spelling: a payload URL names its
951
+ // document without the slash, and the history entry should be the
952
+ // address the server answers without a redirect.
953
+ const arrived = new URL(fetched.url, window.location.href);
954
+ const landed = canonicalAddress(arrived.pathname) + arrived.search + target.hash;
888
955
  if (options?.replace === true) {
889
956
  window.history.replaceState(null, "", landed);
890
957
  } else {
@@ -966,7 +1033,7 @@ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
966
1033
  if (!isBrowser() || navigation === "document") {
967
1034
  return;
968
1035
  }
969
- const target = new URL(to, window.location.href);
1036
+ const target = new URL(addressOf(to), window.location.href);
970
1037
  if (target.origin !== window.location.origin) {
971
1038
  return;
972
1039
  }
@@ -1532,7 +1599,7 @@ export component Link(
1532
1599
  router.push(to, { replace, transition }).catch((error) => {
1533
1600
  // A failed navigation falls back to the browser doing it.
1534
1601
  console.error(error);
1535
- window.location.assign(to);
1602
+ window.location.assign(addressOf(to));
1536
1603
  });
1537
1604
  };
1538
1605
 
@@ -1543,7 +1610,10 @@ export component Link(
1543
1610
  return (
1544
1611
  <a
1545
1612
  {...rest}
1546
- href={to}
1613
+ // `to` is an application path; the anchor is the address, with the base
1614
+ // path in front and the trailing-slash policy's spelling, so a link that
1615
+ // works before hydration and for a right click goes where this one does.
1616
+ href={addressOf(to)}
1547
1617
  className={className}
1548
1618
  onClick={drives ? handleClick : onClick}
1549
1619
  onMouseEnter={drives && prefetch === "intent" ? doPrefetch : undefined}
@@ -23,6 +23,7 @@
23
23
  import { AsyncLocalStorage } from "node:async_hooks";
24
24
 
25
25
  import type { RouteState } from "./flight.js";
26
+ import { requireServerComponentsReact } from "./react-version.js";
26
27
 
27
28
  const storage: AsyncLocalStorage<RouteState> = new AsyncLocalStorage();
28
29
 
@@ -38,8 +39,13 @@ export function withServerRoute<T>(route: RouteState, body: () => T): T {
38
39
  * route is to call a router hook from a server module that is not being
39
40
  * rendered by the router — a script, a route handler, a module evaluated at
40
41
  * import time — and each of those is a mistake worth a sentence.
42
+ *
43
+ * The React version is checked first. On a React older than 19.3 the Flight
44
+ * renderer that would have put the hook inside a route refuses to start, so the
45
+ * sentence worth reading is that one, not "outside a route".
41
46
  */
42
47
  export function serverRoute(caller: string): RouteState {
48
+ requireServerComponentsReact(`${caller}()`);
43
49
  const route = storage.getStore();
44
50
  if (route == null) {
45
51
  throw new Error(
@@ -0,0 +1,107 @@
1
+ // @flow
2
+ //
3
+ // Internal to `@uniflowed/router`: the documents an HTML renderer answers with
4
+ // that are not a route's own markup — uf's shell around that markup, and a
5
+ // redirect.
6
+ //
7
+ // `../server.js` renders a route from its modules, and `../rsc-ssr.js` renders
8
+ // one from the payload React Server Components wrote. They are two entries so
9
+ // that the second, which loads React's Flight client, stays out of every bundle
10
+ // that renders no Server Component (ubugeeei-prod/uf#992). Both write the same
11
+ // documents around what they render, which is why those documents live here and
12
+ // not in either entry.
13
+
14
+ import type { PrerenderResult, RenderAssets, RenderResult } from "../server.js";
15
+ import { addressOf } from "./base-path.js";
16
+ import { ROOT_ID } from "./document.js";
17
+ import type { RedirectError } from "./routing.js";
18
+ import { type DocumentShell, bodyOfText } from "./stream.js";
19
+
20
+ /** A redirect, as the finished document `prerender` answers with. */
21
+ export async function redirectResult(document: RenderResult): Promise<PrerenderResult> {
22
+ return {
23
+ status: document.status,
24
+ headers: document.headers,
25
+ html: await document.text(),
26
+ };
27
+ }
28
+
29
+ export function redirectDocument(error: RedirectError): RenderResult {
30
+ // Under `app.router.basePath`, and in the trailing-slash policy's spelling:
31
+ // `redirect("/sign-in")` names an application path, and a browser follows
32
+ // an address.
33
+ const address = addressOf(error.to);
34
+ const target = escapeAttribute(address);
35
+ // A document rather than an empty body, because a redirect is still an answer
36
+ // a browser may be shown; it goes through the same three methods as a
37
+ // rendered one so that a host has one shape to write, not two.
38
+ const body = bodyOfText(
39
+ `<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="refresh" content="0; url=${target}"><title>Redirecting</title></head><body><a href="${target}">Redirecting…</a></body></html>\n`,
40
+ );
41
+ return {
42
+ status: error.permanent ? 308 : 307,
43
+ headers: { Location: address },
44
+ pipe: body.pipe,
45
+ stream: body.stream,
46
+ text: body.text,
47
+ };
48
+ }
49
+
50
+ /**
51
+ * The document uf writes around the app's markup.
52
+ *
53
+ * The same two shapes `assemble` chose between, decided from the same evidence
54
+ * — whether the markup opens with `<html>` — but stated up front instead of
55
+ * afterwards, because a stream has no "afterwards" in which to splice a head.
56
+ * An app whose root layout renders `<html>` owns the whole document and the
57
+ * client hydrates `document`, so uf contributes only the tags that go before
58
+ * `</head>`. An app that renders only content is wrapped in a minimal shell
59
+ * around `<div id="uf-root">`, which is what the client hydrates instead.
60
+ *
61
+ * `internal/stream.js` picks between them on the opening bytes React writes;
62
+ * everything either shape is made of is here, so what a uf document contains is
63
+ * still readable in one place.
64
+ *
65
+ * # Why the shell is three strings and not one
66
+ *
67
+ * Because uf's own `<head>` has to still be open when React's head tags arrive.
68
+ * React hoists a `<title>`, a `<meta>` and a `<link>` into the head it wrote
69
+ * itself, and here it wrote none — so with one string this shell closed its
70
+ * head before the app had rendered a byte, and every `og:` tag and the
71
+ * `<link rel="canonical">` landed in the body, where a crawler ignores them.
72
+ * `open` is uf's head up to that point, `body` is the rest of it and the
73
+ * wrapper, and what goes between them is whatever `assembled` lifts out of the
74
+ * app's own markup. See ubugeeei-prod/uf#547.
75
+ *
76
+ * That is also why no `<title>` is written here any more. It was, from
77
+ * `resolved.metadata.title` — the same string `Head` renders — so a document
78
+ * carried two of them, one in each place, and only one was where a browser
79
+ * looks. Hoisting the rendered one leaves the metadata with a single source.
80
+ */
81
+ export function shellFor(assets: RenderAssets): DocumentShell {
82
+ const head = headTags(assets);
83
+ return {
84
+ head,
85
+ open: `<!doctype html>\n<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">`,
86
+ body: `${head}</head><body><div id="${ROOT_ID}">`,
87
+ close: `</div></body></html>\n`,
88
+ };
89
+ }
90
+
91
+ function headTags(assets: RenderAssets): string {
92
+ let tags = "";
93
+ for (const href of assets.styles) {
94
+ tags += `<link rel="stylesheet" href="${escapeAttribute(href)}">`;
95
+ }
96
+ for (const href of assets.preloads) {
97
+ tags += `<link rel="modulepreload" href="${escapeAttribute(href)}">`;
98
+ }
99
+ for (const src of assets.scripts) {
100
+ tags += `<script type="module" src="${escapeAttribute(src)}"></script>`;
101
+ }
102
+ return tags;
103
+ }
104
+
105
+ function escapeAttribute(value: string): string {
106
+ return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
107
+ }
@@ -851,7 +851,7 @@ function withPayload(
851
851
  * A document's chunks, with the Flight payload it was rendered from written
852
852
  * into it as it arrives.
853
853
  *
854
- * Three rules, and each is the answer to a way the obvious version is wrong.
854
+ * Four rules, and each is the answer to a way the obvious version is wrong.
855
855
  *
856
856
  * **Nothing before the head.** The first chunk this is handed is the whole
857
857
  * opening of the document — `assembled` does not let one go until the head is
@@ -862,9 +862,20 @@ function withPayload(
862
862
  * **Written as soon as it exists.** A payload row usually exists before the
863
863
  * HTML rendered from it — React's client reads the row, then the boundary
864
864
  * renders — so waiting for the next HTML chunk would put the browser's copy
865
- * behind the markup it hydrates. Each HTML chunk is followed by whatever
866
- * payload is waiting, and a payload that arrives while the HTML is idle is
867
- * written then, without a chunk of HTML to follow.
865
+ * behind the markup it hydrates. Each HTML chunk that ends between elements is
866
+ * followed by whatever payload is waiting, and a payload that arrives while the
867
+ * HTML is idle there is written then, without a chunk of HTML to follow.
868
+ *
869
+ * **Only between elements.** React hands its HTML on through a fixed-size
870
+ * buffer, so a chunk can end anywhere: inside a tag, an attribute's value, a
871
+ * comment, a character reference or an inline `<script>`. A payload element
872
+ * written after such a chunk is not an element. It is part of the attribute,
873
+ * the comment or the text it landed in, the browser never reads it, and React's
874
+ * client closes the payload with rows missing, which is the "Connection closed"
875
+ * a page reports instead of hydrating. So a waiting payload goes out only where
876
+ * the HTML written so far ends between elements, which [`advanced`] follows
877
+ * from chunk to chunk, and otherwise waits for the HTML that finishes what is
878
+ * open.
868
879
  *
869
880
  * **`</body></html>` waits for the end of the payload.** The HTML can finish
870
881
  * first — the last boundary's markup is rendered from rows that are already
@@ -917,12 +928,13 @@ async function* interleaved(
917
928
  }
918
929
  })();
919
930
 
920
- let opened = false;
931
+ // Nothing written yet, and nothing may go before the head.
932
+ let boundary: Boundary = NOTHING_WRITTEN;
921
933
  let closing = "";
922
934
  try {
923
935
  let next = chunks.next();
924
936
  while (true) {
925
- if (opened && written !== "") {
937
+ if (boundary.safe && written !== "") {
926
938
  const out = written;
927
939
  written = "";
928
940
  yield out;
@@ -945,8 +957,8 @@ async function* interleaved(
945
957
  }
946
958
  if (text !== "") {
947
959
  yield text;
960
+ boundary = advanced(boundary, text);
948
961
  }
949
- opened = true;
950
962
  next = chunks.next();
951
963
  }
952
964
  while (!ended || written !== "") {
@@ -972,6 +984,54 @@ async function* interleaved(
972
984
  }
973
985
  }
974
986
 
987
+ /** Where the HTML written so far leaves the next payload element. */
988
+ type Boundary = {|
989
+ /** Whether a payload element may be written now. */
990
+ readonly safe: boolean,
991
+ /** The `script` or `style` element the HTML is inside, if it is inside one. */
992
+ readonly rawText: string | null,
993
+ /** A tag, or a comment, the HTML has started and not yet finished. */
994
+ readonly open: string,
995
+ |};
996
+
997
+ const NOTHING_WRITTEN: Boundary = { safe: false, rawText: null, open: "" };
998
+
999
+ /**
1000
+ * `boundary`, once `html` has been written after it.
1001
+ *
1002
+ * A payload element may follow HTML that ends with a `>` outside a `<script>`
1003
+ * and a `<style>`. The test can be that short because React wrote the HTML: it
1004
+ * escapes `<` and `>` in text and in attribute values, so a `>` it wrote closes
1005
+ * a tag or a comment, and HTML that ends any other way ends inside one, or
1006
+ * inside a character reference or a run of text. What React does not escape is
1007
+ * the content of an inline script or stylesheet, where a `>` is code, so an
1008
+ * opening `<script>` or `<style>` is followed to its closing tag. A tag split
1009
+ * across two chunks is carried in `open` and read whole with the next one.
1010
+ */
1011
+ function advanced(boundary: Boundary, html: string): Boundary {
1012
+ const text = boundary.open + html;
1013
+ let rawText = boundary.rawText;
1014
+ const tags = /<(\/?)(script|style)(?=[\s/>])[^>]*>/gi;
1015
+ let tag = tags.exec(text);
1016
+ while (tag != null) {
1017
+ const name = tag[2].toLowerCase();
1018
+ if (tag[1] === "/") {
1019
+ if (rawText === name) {
1020
+ rawText = null;
1021
+ }
1022
+ } else if (rawText == null) {
1023
+ rawText = name;
1024
+ }
1025
+ tag = tags.exec(text);
1026
+ }
1027
+ const start = text.lastIndexOf("<");
1028
+ return {
1029
+ safe: rawText == null && text.endsWith(">"),
1030
+ rawText,
1031
+ open: start > text.lastIndexOf(">") ? text.slice(start) : "",
1032
+ };
1033
+ }
1034
+
975
1035
  /**
976
1036
  * The prelude of a static prerender, whichever stream this build produced.
977
1037
  *