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

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,16 @@ 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
+ import {
81
+ clearNavigationCache,
82
+ flightNavigations,
83
+ keepsNavigations,
84
+ navigationKey,
85
+ routeNavigations,
86
+ } from "./navigation-cache.js";
80
87
  import { hasClientPage, matchRoute, nearestBoundary } from "./routing.js";
81
88
  import type { RouteParams, SearchParams } from "./routing.js";
82
89
  import {
@@ -137,6 +144,15 @@ export type {
137
144
 
138
145
  export { resolveFailure, resolveMatch } from "./resolve.js";
139
146
 
147
+ // `app.router.basePath` and `trailingSlash`, installed by the entry that starts
148
+ // the application; see `./base-path.js`.
149
+ export type { RoutingSettings, TrailingSlash } from "./base-path.js";
150
+ export { basePath, installRouting } from "./base-path.js";
151
+
152
+ // `app.rendering.staleTime`, installed by the same entry; see
153
+ // `./navigation-cache.js`.
154
+ export { installStaleTime } from "./navigation-cache.js";
155
+
140
156
  // ---------------------------------------------------------------------------
141
157
  // View transitions
142
158
  // ---------------------------------------------------------------------------
@@ -397,6 +413,40 @@ export function navigationMode(): Navigation {
397
413
  return installedNavigation;
398
414
  }
399
415
 
416
+ /**
417
+ * How a page that React Server Components rendered fetches the next route's
418
+ * payload. `hydrateFlight` in `../rsc-client.js` installs it.
419
+ *
420
+ * Handed in rather than imported, because this module is in every
421
+ * application's bundle: one rendered from its modules, a single-page one, and
422
+ * the server's. The fetch reads its answer with React's Flight client,
423
+ * `react-server-dom-parcel`, which only an application that renders Server
424
+ * Components installs, and which needs React 19.3 while the rest of the router
425
+ * runs on 19.2.3 (ubugeeei-prod/uf#992). A bundler resolves every import it is
426
+ * shown, whether or not anything calls it, so an import here would put that
427
+ * package in every one of those bundles, or fail the build where it is absent.
428
+ */
429
+ let installedFlightFetch: ((url: string) => Promise<FetchedFlight>) | null = null;
430
+
431
+ /** Hand the router the payload fetch. Called once, by `hydrateFlight`, before the first render. */
432
+ export function installFlightFetch(fetcher: (url: string) => Promise<FetchedFlight>): void {
433
+ installedFlightFetch = fetcher;
434
+ }
435
+
436
+ /** The next route's payload, through the fetch `hydrateFlight` installed. */
437
+ function fetchFlight(url: string): Promise<FetchedFlight> {
438
+ if (installedFlightFetch == null) {
439
+ return Promise.reject(
440
+ new Error(
441
+ "@uniflowed/router: a page rendered from a Flight payload navigated before anything " +
442
+ "installed the payload fetch. `hydrateFlight` from `@uniflowed/router/rsc/client` " +
443
+ "installs it before it hydrates, so an entry that hydrates a payload has to call that.",
444
+ ),
445
+ );
446
+ }
447
+ return installedFlightFetch(url);
448
+ }
449
+
400
450
  /** Register the generated route table. Called once by the client and server entries. */
401
451
  export function installRoutes(table: RouteTable): void {
402
452
  installedTable = table;
@@ -585,8 +635,12 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
585
635
  if (!isBrowser()) {
586
636
  return;
587
637
  }
588
- const target = new URL(to, window.location.href);
589
- const next = target.pathname + target.search;
638
+ const target = new URL(addressOf(to), window.location.href);
639
+ // The application path the route table is asked about, and the address the
640
+ // history entry keeps: one URL, with and without `app.router.basePath`.
641
+ const applicationPath = applicationPathOf(target.pathname);
642
+ const next = (applicationPath ?? target.pathname) + target.search;
643
+ const address = target.pathname + target.search;
590
644
  // The browser's job in this application. `assign` and `replace` rather
591
645
  // than the history API, because the point is a document request: the
592
646
  // history entry, the scroll position, the `Referer` and the unload
@@ -605,8 +659,13 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
605
659
  // screen that intercepts the URL renders a page of its own, so whether the
606
660
  // URL's ordinary page is in this bundle — the paragraph below — is not a
607
661
  // question this navigation has to ask.
662
+ // An address outside the base path is not this application's to render.
663
+ if (applicationPath == null) {
664
+ window.location.assign(target.href);
665
+ return;
666
+ }
608
667
  const origin = beneath(shown.current);
609
- const intercepting = interceptingRoutes(origin.slots, target.pathname).length > 0;
668
+ const intercepting = interceptingRoutes(origin.slots, applicationPath).length > 0;
610
669
  // The half of the split that is not about bytes. A route whose page is not
611
670
  // in this bundle is not a route this router can render, and pretending
612
671
  // otherwise is the silent break: the navigation would resolve to nothing
@@ -615,7 +674,7 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
615
674
  // link does when there is no JavaScript at all, and what the anchor
616
675
  // `Link` renders would have done on its own.
617
676
  if (!intercepting) {
618
- const matched = matchRoute(routeTable().routes, target.pathname);
677
+ const matched = matchRoute(routeTable().routes, applicationPath);
619
678
  if (matched != null && !hasClientPage(matched.route)) {
620
679
  window.location.assign(target.href);
621
680
  return;
@@ -623,17 +682,21 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
623
682
  }
624
683
  setPending(true);
625
684
  try {
685
+ // An interception depends on the page it starts from, so it is resolved
686
+ // every time; any other navigation reads what this page kept while it is
687
+ // fresh. See `./navigation-cache.js`.
688
+ const key = navigationKey(target.pathname, target.search);
626
689
  const nextResolved =
627
690
  (intercepting ? await resolveInterception(routeTable(), origin, next) : null) ??
628
- (await resolveMatch(routeTable(), next));
691
+ (await (routeNavigations.read(key) ?? keepRoute(key, resolveMatch(routeTable(), next))));
629
692
  // An intercepted entry remembers where it was intercepted from, so back
630
693
  // and forward can put the page underneath under it again. Every other
631
694
  // entry is written the way it always was.
632
695
  const state = historyStateFor(nextResolved);
633
696
  if (options?.replace === true) {
634
- window.history.replaceState(state, "", next + target.hash);
697
+ window.history.replaceState(state, "", address + target.hash);
635
698
  } else {
636
- window.history.pushState(state, "", next + target.hash);
699
+ window.history.pushState(state, "", address + target.hash);
637
700
  }
638
701
  const commit = () => {
639
702
  show(nextResolved);
@@ -698,7 +761,9 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
698
761
  });
699
762
  };
700
763
  const onPopState = () => {
701
- const next = window.location.pathname + window.location.search;
764
+ const next =
765
+ (applicationPathOf(window.location.pathname) ?? window.location.pathname) +
766
+ window.location.search;
702
767
  // Back or forward into an entry an interception wrote: the page it was
703
768
  // intercepted from, with the interception over it again. That page is
704
769
  // resolved afresh only when it is not already the one underneath, so
@@ -723,12 +788,16 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
723
788
  // Back into a route this bundle has no page for. The history entry is
724
789
  // already the browser's — it moved before this listener ran — so the
725
790
  // document that belongs to it is what has to be fetched.
726
- const matched = matchRoute(routeTable().routes, window.location.pathname);
791
+ const matched = matchRoute(
792
+ routeTable().routes,
793
+ applicationPathOf(window.location.pathname) ?? window.location.pathname,
794
+ );
727
795
  if (matched != null && !hasClientPage(matched.route)) {
728
796
  window.location.reload();
729
797
  return;
730
798
  }
731
- resolveMatch(routeTable(), next).then(arrive);
799
+ const key = navigationKey(window.location.pathname, window.location.search);
800
+ (routeNavigations.read(key) ?? keepRoute(key, resolveMatch(routeTable(), next))).then(arrive);
732
801
  };
733
802
  window.addEventListener("popstate", onPopState);
734
803
  return () => {
@@ -748,11 +817,15 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
748
817
  if (!isBrowser() || navigation === "document") {
749
818
  return;
750
819
  }
751
- const target = new URL(to, window.location.href);
820
+ const target = new URL(addressOf(to), window.location.href);
821
+ const applicationPath = applicationPathOf(target.pathname);
822
+ if (applicationPath == null) {
823
+ return;
824
+ }
752
825
  // What the next render will need is decided the way the navigation will
753
826
  // decide it: a URL a slot on screen intercepts renders that slot's page,
754
827
  // 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);
828
+ const intercepting = interceptingRoutes(beneath(shown.current).slots, applicationPath);
756
829
  if (intercepting.length > 0) {
757
830
  await Promise.all(
758
831
  intercepting.flatMap((route) => [
@@ -762,11 +835,22 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
762
835
  );
763
836
  return;
764
837
  }
765
- const matched = matchRoute(routeTable().routes, target.pathname);
838
+ const matched = matchRoute(routeTable().routes, applicationPath);
766
839
  const load = matched?.route.page;
767
840
  if (matched == null || load == null) {
768
841
  return;
769
842
  }
843
+ // With `app.rendering.staleTime` set, the whole route: its loader runs
844
+ // now, and the click, a later visit and the back button read what it
845
+ // answered while it is fresh. Otherwise only the modules it will need.
846
+ if (keepsNavigations()) {
847
+ const key = navigationKey(target.pathname, target.search);
848
+ await (
849
+ routeNavigations.read(key) ??
850
+ keepRoute(key, resolveMatch(routeTable(), applicationPath + target.search))
851
+ );
852
+ return;
853
+ }
770
854
  await Promise.all([
771
855
  loadOnce(load),
772
856
  ...matched.route.layouts.map((layout) => loadOnce(layout)),
@@ -784,6 +868,9 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
784
868
  window.location.reload();
785
869
  return;
786
870
  }
871
+ // Everything a navigation kept is older than what this asks for, so none
872
+ // of it is shown again; see `./navigation-cache.js`.
873
+ clearNavigationCache();
787
874
  // A refresh of an intercepted page refreshes both of its halves: the
788
875
  // page underneath, resolved again for its own URL, and the interception
789
876
  // resolved again over it. Resolving only the address bar's URL would
@@ -791,7 +878,11 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
791
878
  const interception = shown.current.interception;
792
879
  const nextResolved =
793
880
  interception == null
794
- ? await resolveMatch(routeTable(), window.location.pathname + window.location.search)
881
+ ? await resolveMatch(
882
+ routeTable(),
883
+ (applicationPathOf(window.location.pathname) ?? window.location.pathname) +
884
+ window.location.search,
885
+ )
795
886
  : ((await resolveInterception(
796
887
  routeTable(),
797
888
  await resolveMatch(
@@ -859,7 +950,9 @@ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
859
950
  if (!isBrowser()) {
860
951
  return;
861
952
  }
862
- const target = new URL(to, window.location.href);
953
+ // A payload URL is an address, so it keeps the base path; the server takes
954
+ // it off.
955
+ const target = new URL(addressOf(to), window.location.href);
863
956
  const next = target.pathname + target.search;
864
957
  // The browser's job in this application; `ModuleRouter` has the argument.
865
958
  if (navigation === "document") {
@@ -872,7 +965,14 @@ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
872
965
  }
873
966
  setPending(true);
874
967
  try {
875
- const fetched = await (takePrefetched(next) ?? fetchFlight(next));
968
+ // The route this page already has while it is fresh, then a prefetch
969
+ // still in hand, then the network. See `./navigation-cache.js`.
970
+ const key = navigationKey(target.pathname, target.search);
971
+ const fetched = await (
972
+ flightNavigations.read(key) ??
973
+ takePrefetched(next) ??
974
+ keepFlight(key, fetchFlight(next))
975
+ );
876
976
  // Not a payload: a redirect off this origin, or a host that has no payload
877
977
  // for this URL. The browser loads it as a document, which is what the
878
978
  // anchor would have done.
@@ -884,7 +984,11 @@ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
884
984
  const nextRoot = await payload;
885
985
  // The URL the payload came from, which is a redirect's target when the
886
986
  // route redirected: the history entry is where the visitor ended up.
887
- const landed = fetched.url + target.hash;
987
+ // In the trailing-slash policy's spelling: a payload URL names its
988
+ // document without the slash, and the history entry should be the
989
+ // address the server answers without a redirect.
990
+ const arrived = new URL(fetched.url, window.location.href);
991
+ const landed = canonicalAddress(arrived.pathname) + arrived.search + target.hash;
888
992
  if (options?.replace === true) {
889
993
  window.history.replaceState(null, "", landed);
890
994
  } else {
@@ -927,8 +1031,10 @@ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
927
1031
  const onPopState = () => {
928
1032
  const next = window.location.pathname + window.location.search;
929
1033
  // The history entry already moved; a payload that cannot be had for it is
930
- // a document to load, and a reload is the browser's way to load it.
931
- fetchFlight(next).then(
1034
+ // a document to load, and a reload is the browser's way to load it. While
1035
+ // this page keeps the route fresh, what it kept is what comes back.
1036
+ const key = navigationKey(window.location.pathname, window.location.search);
1037
+ (flightNavigations.read(key) ?? keepFlight(key, fetchFlight(next))).then(
932
1038
  (fetched) => {
933
1039
  if (fetched.kind === "document") {
934
1040
  window.location.reload();
@@ -966,11 +1072,19 @@ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
966
1072
  if (!isBrowser() || navigation === "document") {
967
1073
  return;
968
1074
  }
969
- const target = new URL(to, window.location.href);
1075
+ const target = new URL(addressOf(to), window.location.href);
970
1076
  if (target.origin !== window.location.origin) {
971
1077
  return;
972
1078
  }
973
- await prefetchFlight(target.pathname + target.search);
1079
+ const next = target.pathname + target.search;
1080
+ // Kept for every navigation to it while it is fresh, when a project set
1081
+ // `app.rendering.staleTime`; otherwise held for the one click after it.
1082
+ if (keepsNavigations()) {
1083
+ const key = navigationKey(target.pathname, target.search);
1084
+ await (flightNavigations.read(key) ?? keepFlight(key, fetchFlight(next)));
1085
+ return;
1086
+ }
1087
+ await prefetchFlight(next);
974
1088
  },
975
1089
  refresh: async () => {
976
1090
  if (!isBrowser()) {
@@ -980,7 +1094,13 @@ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
980
1094
  window.location.reload();
981
1095
  return;
982
1096
  }
983
- const fetched = await fetchFlight(window.location.pathname + window.location.search);
1097
+ // Everything a navigation kept is older than what this asks for, so none
1098
+ // of it is shown again; see `./navigation-cache.js`.
1099
+ clearNavigationCache();
1100
+ const fetched = await keepFlight(
1101
+ navigationKey(window.location.pathname, window.location.search),
1102
+ fetchFlight(window.location.pathname + window.location.search),
1103
+ );
984
1104
  if (fetched.kind === "document") {
985
1105
  window.location.reload();
986
1106
  return;
@@ -1031,6 +1151,57 @@ const prefetchedFlights: Map<
1031
1151
  {| readonly fetched: Promise<FetchedFlight>, readonly at: number |},
1032
1152
  > = new Map();
1033
1153
 
1154
+ /**
1155
+ * `fetched`, kept under `key` for as long as `app.rendering.staleTime` says,
1156
+ * and forgotten again if it turns out not to be a route to show twice: a
1157
+ * request that failed, an answer that was a document rather than a payload, or
1158
+ * a payload React could not read.
1159
+ */
1160
+ function keepFlight(key: string, fetched: Promise<FetchedFlight>): Promise<FetchedFlight> {
1161
+ if (!keepsNavigations()) {
1162
+ return fetched;
1163
+ }
1164
+ flightNavigations.store(key, fetched);
1165
+ const forget = () => {
1166
+ flightNavigations.forget(key, fetched);
1167
+ };
1168
+ void fetched.then((answer) => {
1169
+ if (answer.kind === "document") {
1170
+ forget();
1171
+ return;
1172
+ }
1173
+ // A route that answered with its error or not-found boundary is shown this
1174
+ // once: asked again, it may have recovered.
1175
+ void answer.root.then((root) => {
1176
+ if (root.route.status !== 200) {
1177
+ forget();
1178
+ }
1179
+ }, forget);
1180
+ }, forget);
1181
+ return fetched;
1182
+ }
1183
+
1184
+ /**
1185
+ * `resolved`, kept under `key` for as long as `app.rendering.staleTime` says,
1186
+ * and forgotten again if it did not resolve to a page: a loader that threw or
1187
+ * redirected, or a route that answered with its error or not-found boundary.
1188
+ */
1189
+ function keepRoute(key: string, resolved: Promise<ResolvedRoute>): Promise<ResolvedRoute> {
1190
+ if (!keepsNavigations()) {
1191
+ return resolved;
1192
+ }
1193
+ routeNavigations.store(key, resolved);
1194
+ const forget = () => {
1195
+ routeNavigations.forget(key, resolved);
1196
+ };
1197
+ void resolved.then((route) => {
1198
+ if (route.status !== 200) {
1199
+ forget();
1200
+ }
1201
+ }, forget);
1202
+ return resolved;
1203
+ }
1204
+
1034
1205
  function prefetchFlight(url: string): Promise<FetchedFlight> {
1035
1206
  const existing = prefetchedFlights.get(url);
1036
1207
  if (existing != null && Date.now() - existing.at < PREFETCH_LIFETIME_MS) {
@@ -1222,12 +1393,20 @@ component RenderedPage(data: mixed) {
1222
1393
  }
1223
1394
  const resolved = view.resolved;
1224
1395
  const Page = pageComponent(resolved.page);
1396
+ // The route module's own export, looked up by route: `pageComponent` hands
1397
+ // back its `default` or `Page` as it is, so this is the same component on
1398
+ // every render of the same route. The React Compiler cannot see through the
1399
+ // lookup and reports a component created during render. The block form,
1400
+ // because the finding is on a JSX child and a `//` comment cannot stand
1401
+ // between JSX children without becoming text.
1402
+ // uf-lint-disable react-compiler/static-components
1225
1403
  return (
1226
1404
  <>
1227
1405
  <Page params={resolved.params} searchParams={resolved.searchParams} data={data} />
1228
1406
  {payloadElements(data)}
1229
1407
  </>
1230
1408
  );
1409
+ // uf-lint-enable react-compiler/static-components
1231
1410
  }
1232
1411
 
1233
1412
  /**
@@ -1532,7 +1711,7 @@ export component Link(
1532
1711
  router.push(to, { replace, transition }).catch((error) => {
1533
1712
  // A failed navigation falls back to the browser doing it.
1534
1713
  console.error(error);
1535
- window.location.assign(to);
1714
+ window.location.assign(addressOf(to));
1536
1715
  });
1537
1716
  };
1538
1717
 
@@ -1543,7 +1722,10 @@ export component Link(
1543
1722
  return (
1544
1723
  <a
1545
1724
  {...rest}
1546
- href={to}
1725
+ // `to` is an application path; the anchor is the address, with the base
1726
+ // path in front and the trailing-slash policy's spelling, so a link that
1727
+ // works before hydration and for a right click goes where this one does.
1728
+ href={addressOf(to)}
1547
1729
  className={className}
1548
1730
  onClick={drives ? handleClick : onClick}
1549
1731
  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,115 @@
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, nonce?: string | null): DocumentShell {
82
+ const head = headTags(assets, nonce);
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, nonce?: string | null): 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
+ // The nonce goes on the client entry even though it is `src` rather than
100
+ // inline, because a policy of `script-src 'nonce-…'` admits *no* script
101
+ // without one — a nonce policy is not an inline policy with an exception in
102
+ // it. A project whose policy also names `'self'` pays nothing for the
103
+ // attribute being here, and one whose policy is `'strict-dynamic'` needs it:
104
+ // that is the directive under which this script is the root of trust every
105
+ // chunk it imports inherits from.
106
+ const carried = nonce == null ? "" : ` nonce="${escapeAttribute(nonce)}"`;
107
+ for (const src of assets.scripts) {
108
+ tags += `<script type="module" src="${escapeAttribute(src)}"${carried}></script>`;
109
+ }
110
+ return tags;
111
+ }
112
+
113
+ function escapeAttribute(value: string): string {
114
+ return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
115
+ }