@uniflowed/router 0.0.0-alpha.37 → 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.
package/action.js CHANGED
@@ -111,6 +111,7 @@ import {
111
111
  decodeActionResult,
112
112
  encodeActionArguments,
113
113
  } from "./internal/action-wire.js";
114
+ import { clearNavigationCache } from "./internal/navigation-cache.js";
114
115
 
115
116
  export type { ActionArgument, ActionValue } from "./internal/action-wire.js";
116
117
  export {
@@ -264,6 +265,11 @@ export function createServerReference(id: string, name: string): ServerActionFun
264
265
  headers,
265
266
  body,
266
267
  });
268
+ // A route a navigation kept may no longer show what this action wrote, and
269
+ // which routes is the server's to know, so every kept route is asked for
270
+ // again. Whatever the status: an action that failed part-way may have
271
+ // written before it failed. See `./internal/navigation-cache.js`.
272
+ clearNavigationCache();
267
273
  if (!response.ok) {
268
274
  throw new ServerActionError(name, response.status);
269
275
  }
package/client.js CHANGED
@@ -109,6 +109,7 @@ import {
109
109
  installNavigation,
110
110
  installRoutes,
111
111
  installRouting,
112
+ installStaleTime,
112
113
  matchRoute,
113
114
  resolveFailure,
114
115
  resolveMatch,
@@ -135,6 +136,7 @@ export async function hydrate(options: {|
135
136
  readonly navigation?: Navigation,
136
137
  readonly basePath?: string,
137
138
  readonly trailingSlash?: TrailingSlash,
139
+ readonly staleTime?: number,
138
140
  |}): Promise<void> {
139
141
  const table: RouteTable = {
140
142
  routes: options.routes,
@@ -148,6 +150,7 @@ export async function hydrate(options: {|
148
150
  // means: the default is the behaviour, not the absence of one.
149
151
  installNavigation(options.navigation ?? "client");
150
152
  installRouting({ basePath: options.basePath, trailingSlash: options.trailingSlash });
153
+ installStaleTime(options.staleTime ?? 0);
151
154
  // The route table has no base path in it, and the address bar does.
152
155
  const applicationPath = applicationPathOf(window.location.pathname) ?? window.location.pathname;
153
156
 
@@ -270,6 +273,7 @@ export async function render(options: {|
270
273
  readonly navigation?: Navigation,
271
274
  readonly basePath?: string,
272
275
  readonly trailingSlash?: TrailingSlash,
276
+ readonly staleTime?: number,
273
277
  |}): Promise<void> {
274
278
  const table: RouteTable = {
275
279
  routes: options.routes,
@@ -279,6 +283,7 @@ export async function render(options: {|
279
283
  installRoutes(table);
280
284
  installNavigation(options.navigation ?? "client");
281
285
  installRouting({ basePath: options.basePath, trailingSlash: options.trailingSlash });
286
+ installStaleTime(options.staleTime ?? 0);
282
287
 
283
288
  const url =
284
289
  (applicationPathOf(window.location.pathname) ?? window.location.pathname) +
@@ -412,6 +412,11 @@ component SlotView(slot: ResolvedSlot, pathname: string, searchParams: SearchPar
412
412
  const at = slot.intercepted?.pathname ?? pathname;
413
413
  const query = slot.intercepted?.searchParams ?? searchParams;
414
414
  const Page = pageComponent(page);
415
+ // The slot module's own export, looked up by slot: `pageComponent` hands back
416
+ // its `default` or `Page` as it is, so this is the same component on every
417
+ // render of the same slot. The React Compiler cannot see through the lookup
418
+ // and reports a component created during render.
419
+ // uf-lint-disable-next-line react-compiler/static-components
415
420
  let element: React.Node = <Page params={slot.params} searchParams={query} data={undefined} />;
416
421
  const templateContext = {
417
422
  pathname: at,
@@ -425,6 +430,10 @@ component SlotView(slot: ResolvedSlot, pathname: string, searchParams: SearchPar
425
430
  continue;
426
431
  }
427
432
  const Fallback = loadingComponent(boundary.module);
433
+ // The same lookup as `Page` above: `loadingComponent` hands back the
434
+ // boundary module's own export as it is, so this is the same component
435
+ // on every render of that boundary.
436
+ // uf-lint-disable-next-line react-compiler/static-components
428
437
  element = <Suspense fallback={<Fallback />}>{element}</Suspense>;
429
438
  }
430
439
  const errorBoundary = slot.errorBoundary;
@@ -439,6 +448,9 @@ component SlotView(slot: ResolvedSlot, pathname: string, searchParams: SearchPar
439
448
  if (depth > 0) {
440
449
  const Layout = layoutComponent(slot.layouts[depth - 1]);
441
450
  element = (
451
+ // The layout module's own export, looked up by depth, for the same
452
+ // reason as `Page` above.
453
+ // uf-lint-disable-next-line react-compiler/static-components
442
454
  <Layout {...slotsAt(slot.slots, depth, { pathname, searchParams })} params={slot.params}>
443
455
  {element}
444
456
  </Layout>
@@ -78,6 +78,10 @@ component RouteErrorView(module: ?ErrorModule, error: RouteError, reset: () => v
78
78
  return <DefaultRouteError error={error} reset={reset} />;
79
79
  }
80
80
  const Boundary = errorComponent(module);
81
+ // The error module's own export, looked up by route, for the same reason as
82
+ // the page component in `compose.js`: the same component on every render of
83
+ // that route, through a lookup the compiler cannot see into.
84
+ // uf-lint-disable-next-line react-compiler/static-components
81
85
  return <Boundary error={error} reset={reset} />;
82
86
  }
83
87
 
@@ -55,9 +55,33 @@ function elementJson(value: FlightChunk): string {
55
55
  .replace(/\u2029/g, "\\u2029");
56
56
  }
57
57
 
58
- /** The element for one chunk, or the end marker for `null`. */
59
- export function flightChunkElement(chunk: FlightChunk): string {
60
- return `<script type="application/json" ${FLIGHT_CHUNK_ATTRIBUTE}>${elementJson(chunk)}</script>`;
58
+ /**
59
+ * The element for one chunk, or the end marker for `null`.
60
+ *
61
+ * `nonce` is this response's, and it is carried even though the element is
62
+ * `application/json` and therefore a data block the browser never executes.
63
+ * Two reasons, and neither is "in case the spec changes". A uf document's
64
+ * invariant is that every `<script>` in it carries the nonce — that is what
65
+ * `packages/router/streaming.test.js` pins, and an invariant with an exception
66
+ * in it is one a reader has to check every new script against. And these are
67
+ * written as text into a stream rather than rendered by React, so unlike the
68
+ * payload rows in `./runtime.js` there is no second render on the client to
69
+ * disagree with; the attribute costs a comparison nobody makes.
70
+ */
71
+ export function flightChunkElement(chunk: FlightChunk, nonce?: string | null): string {
72
+ const carried = nonce == null ? "" : ` nonce="${escapeNonce(nonce)}"`;
73
+ return `<script type="application/json"${carried} ${FLIGHT_CHUNK_ATTRIBUTE}>${elementJson(chunk)}</script>`;
74
+ }
75
+
76
+ /**
77
+ * A nonce, as an attribute value.
78
+ *
79
+ * uf's own nonces are base64 and hold none of these, so this is about the one a
80
+ * project supplied: the value reaches a document that a browser parses as
81
+ * HTML, and a `"` in it would end the attribute and open whatever follows.
82
+ */
83
+ function escapeNonce(value: string): string {
84
+ return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
61
85
  }
62
86
 
63
87
  /**
@@ -74,7 +98,7 @@ export type ChunkEncoder = {|
74
98
  readonly end: () => string,
75
99
  |};
76
100
 
77
- export function createChunkEncoder(): ChunkEncoder {
101
+ export function createChunkEncoder(nonce?: string | null): ChunkEncoder {
78
102
  const strict = new TextDecoder("utf-8", { fatal: true });
79
103
  let carry: Uint8Array = new Uint8Array(0);
80
104
 
@@ -87,17 +111,17 @@ export function createChunkEncoder(): ChunkEncoder {
87
111
  }
88
112
  const body = joined.subarray(0, complete);
89
113
  try {
90
- return flightChunkElement(strict.decode(body));
114
+ return flightChunkElement(strict.decode(body), nonce);
91
115
  } catch {
92
- return flightChunkElement({ bytes: base64(body) });
116
+ return flightChunkElement({ bytes: base64(body) }, nonce);
93
117
  }
94
118
  }
95
119
 
96
120
  function end(): string {
97
121
  const rest = carry;
98
122
  carry = new Uint8Array(0);
99
- const tail = rest.length === 0 ? "" : flightChunkElement({ bytes: base64(rest) });
100
- return `${tail}${flightChunkElement(null)}`;
123
+ const tail = rest.length === 0 ? "" : flightChunkElement({ bytes: base64(rest) }, nonce);
124
+ return `${tail}${flightChunkElement(null, nonce)}`;
101
125
  }
102
126
 
103
127
  return { encode, end };
@@ -0,0 +1,144 @@
1
+ // @flow
2
+ //
3
+ // Internal to `@uniflowed/router`: the routes a navigation already has.
4
+ //
5
+ // A navigation used to ask the server every time. A `Link` prefetched a route's
6
+ // payload on hover and handed it to exactly one click, and every other way back
7
+ // to a page the reader had just seen, a second visit or the back button, waited
8
+ // on the network again (ubugeeei-prod/uf#960). This module is what a navigation
9
+ // reads first instead: the route it fetched or prefetched, kept for
10
+ // `app.rendering.staleTime` seconds and asked for again after that.
11
+ //
12
+ // # Off until a project says a number
13
+ //
14
+ // uf's caches are opt-in, and this one is too. `staleTime` is `0` until a
15
+ // project writes one, and at `0` nothing is kept, so a navigation shows what the
16
+ // server answered for it just now, which is the guarantee every project had
17
+ // before the setting existed.
18
+ //
19
+ // # What is kept
20
+ //
21
+ // One promise per route, never a copy of what it resolved to:
22
+ //
23
+ // - an application React Server Components render keeps the payload fetch: the
24
+ // route's state and its tree, with the `$loading.js` fallbacks the tree
25
+ // carries, so going back to a page that was still streaming shows the loading
26
+ // shell the first visit did;
27
+ // - an application rendered from its modules keeps the resolved route: the
28
+ // loader's data, and the page, layouts and loading boundaries it loaded.
29
+ //
30
+ // A promise rather than its value, so a click on a link whose prefetch is still
31
+ // in flight waits on that request instead of making a second one. The router
32
+ // forgets an entry whose fetch failed or turned out not to be a route.
33
+ //
34
+ // # Keyed by the application path
35
+ //
36
+ // The path and query the route table is asked about, without
37
+ // `app.router.basePath`: `/docs/guide?tab=api` under `/docs` is kept as
38
+ // `/guide?tab=api`. A fragment is never part of a key, because a server never
39
+ // sees one.
40
+ //
41
+ // # Cleared, not revalidated in place
42
+ //
43
+ // `router.refresh()` and every server action clear the whole cache. An action
44
+ // is a write, and which pages it changed is the server's to know, so the next
45
+ // navigation to any of them asks again rather than showing what the write made
46
+ // untrue.
47
+
48
+ import { applicationPathOf } from "./base-path.js";
49
+ import type { FetchedFlight } from "./flight.js";
50
+ import type { ResolvedRoute } from "./resolve.js";
51
+
52
+ /** How many routes each cache keeps at once. Past it, the oldest is dropped. */
53
+ export const NAVIGATION_CACHE_LIMIT = 32;
54
+
55
+ let staleTimeMs: number = 0;
56
+
57
+ /**
58
+ * Say how long, in seconds, a route a navigation fetched is shown again without
59
+ * asking. `0`, the default, keeps nothing. Called once, by the entry that
60
+ * started the application.
61
+ */
62
+ export function installStaleTime(seconds: number): void {
63
+ staleTimeMs = Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : 0;
64
+ if (staleTimeMs === 0) {
65
+ clearNavigationCache();
66
+ }
67
+ }
68
+
69
+ /** Whether navigations keep what they fetch. */
70
+ export function keepsNavigations(): boolean {
71
+ return staleTimeMs > 0;
72
+ }
73
+
74
+ /** The key a URL's route is kept under: its application path, then its query. */
75
+ export function navigationKey(pathname: string, search: string): string {
76
+ return `${applicationPathOf(pathname) ?? pathname}${search}`;
77
+ }
78
+
79
+ /** One kind of kept route. */
80
+ export type NavigationCache<T> = {|
81
+ /** The route kept under `key`, while it is fresh. A stale one is dropped. */
82
+ readonly read: (key: string) => T | null,
83
+ /** Keep `value` under `key` from now. Nothing is kept while the stale time is `0`. */
84
+ readonly store: (key: string, value: T) => void,
85
+ /** Drop what `key` keeps, if it is still `value`. */
86
+ readonly forget: (key: string, value: T) => void,
87
+ readonly clear: () => void,
88
+ /** How many routes are kept, fresh or not. */
89
+ readonly size: () => number,
90
+ |};
91
+
92
+ function createNavigationCache<T>(): NavigationCache<T> {
93
+ const entries: Map<string, {| readonly value: T, readonly at: number |}> = new Map();
94
+ return {
95
+ read(key) {
96
+ const entry = entries.get(key);
97
+ if (entry == null) {
98
+ return null;
99
+ }
100
+ if (Date.now() - entry.at >= staleTimeMs) {
101
+ entries.delete(key);
102
+ return null;
103
+ }
104
+ return entry.value;
105
+ },
106
+ store(key, value) {
107
+ if (staleTimeMs === 0) {
108
+ return;
109
+ }
110
+ // Deleted first, so a route kept again moves to the young end.
111
+ entries.delete(key);
112
+ if (entries.size >= NAVIGATION_CACHE_LIMIT) {
113
+ const oldest = entries.keys().next();
114
+ if (oldest.done !== true) {
115
+ entries.delete(oldest.value);
116
+ }
117
+ }
118
+ entries.set(key, { value, at: Date.now() });
119
+ },
120
+ forget(key, value) {
121
+ if (entries.get(key)?.value === value) {
122
+ entries.delete(key);
123
+ }
124
+ },
125
+ clear() {
126
+ entries.clear();
127
+ },
128
+ size() {
129
+ return entries.size;
130
+ },
131
+ };
132
+ }
133
+
134
+ /** Payload fetches, for an application React Server Components render. */
135
+ export const flightNavigations: NavigationCache<Promise<FetchedFlight>> = createNavigationCache();
136
+
137
+ /** Resolved routes, for an application rendered from its modules. */
138
+ export const routeNavigations: NavigationCache<Promise<ResolvedRoute>> = createNavigationCache();
139
+
140
+ /** Forget every kept route. `router.refresh()` and each server action call this. */
141
+ export function clearNavigationCache(): void {
142
+ flightNavigations.clear();
143
+ routeNavigations.clear();
144
+ }
@@ -77,6 +77,13 @@ import { composeRoute, pageComponent } from "./compose.js";
77
77
  import { type FetchedFlight, type FlightRoot, type RouteState, routeState } from "./flight.js";
78
78
  import { Head } from "./head.js";
79
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 {
@@ -142,6 +149,10 @@ export { resolveFailure, resolveMatch } from "./resolve.js";
142
149
  export type { RoutingSettings, TrailingSlash } from "./base-path.js";
143
150
  export { basePath, installRouting } from "./base-path.js";
144
151
 
152
+ // `app.rendering.staleTime`, installed by the same entry; see
153
+ // `./navigation-cache.js`.
154
+ export { installStaleTime } from "./navigation-cache.js";
155
+
145
156
  // ---------------------------------------------------------------------------
146
157
  // View transitions
147
158
  // ---------------------------------------------------------------------------
@@ -671,9 +682,13 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
671
682
  }
672
683
  setPending(true);
673
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);
674
689
  const nextResolved =
675
690
  (intercepting ? await resolveInterception(routeTable(), origin, next) : null) ??
676
- (await resolveMatch(routeTable(), next));
691
+ (await (routeNavigations.read(key) ?? keepRoute(key, resolveMatch(routeTable(), next))));
677
692
  // An intercepted entry remembers where it was intercepted from, so back
678
693
  // and forward can put the page underneath under it again. Every other
679
694
  // entry is written the way it always was.
@@ -781,7 +796,8 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
781
796
  window.location.reload();
782
797
  return;
783
798
  }
784
- 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);
785
801
  };
786
802
  window.addEventListener("popstate", onPopState);
787
803
  return () => {
@@ -824,6 +840,17 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
824
840
  if (matched == null || load == null) {
825
841
  return;
826
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
+ }
827
854
  await Promise.all([
828
855
  loadOnce(load),
829
856
  ...matched.route.layouts.map((layout) => loadOnce(layout)),
@@ -841,6 +868,9 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
841
868
  window.location.reload();
842
869
  return;
843
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();
844
874
  // A refresh of an intercepted page refreshes both of its halves: the
845
875
  // page underneath, resolved again for its own URL, and the interception
846
876
  // resolved again over it. Resolving only the address bar's URL would
@@ -935,7 +965,14 @@ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
935
965
  }
936
966
  setPending(true);
937
967
  try {
938
- 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
+ );
939
976
  // Not a payload: a redirect off this origin, or a host that has no payload
940
977
  // for this URL. The browser loads it as a document, which is what the
941
978
  // anchor would have done.
@@ -994,8 +1031,10 @@ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
994
1031
  const onPopState = () => {
995
1032
  const next = window.location.pathname + window.location.search;
996
1033
  // The history entry already moved; a payload that cannot be had for it is
997
- // a document to load, and a reload is the browser's way to load it.
998
- 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(
999
1038
  (fetched) => {
1000
1039
  if (fetched.kind === "document") {
1001
1040
  window.location.reload();
@@ -1037,7 +1076,15 @@ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
1037
1076
  if (target.origin !== window.location.origin) {
1038
1077
  return;
1039
1078
  }
1040
- 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);
1041
1088
  },
1042
1089
  refresh: async () => {
1043
1090
  if (!isBrowser()) {
@@ -1047,7 +1094,13 @@ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
1047
1094
  window.location.reload();
1048
1095
  return;
1049
1096
  }
1050
- 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
+ );
1051
1104
  if (fetched.kind === "document") {
1052
1105
  window.location.reload();
1053
1106
  return;
@@ -1098,6 +1151,57 @@ const prefetchedFlights: Map<
1098
1151
  {| readonly fetched: Promise<FetchedFlight>, readonly at: number |},
1099
1152
  > = new Map();
1100
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
+
1101
1205
  function prefetchFlight(url: string): Promise<FetchedFlight> {
1102
1206
  const existing = prefetchedFlights.get(url);
1103
1207
  if (existing != null && Date.now() - existing.at < PREFETCH_LIFETIME_MS) {
@@ -1289,12 +1393,20 @@ component RenderedPage(data: mixed) {
1289
1393
  }
1290
1394
  const resolved = view.resolved;
1291
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
1292
1403
  return (
1293
1404
  <>
1294
1405
  <Page params={resolved.params} searchParams={resolved.searchParams} data={data} />
1295
1406
  {payloadElements(data)}
1296
1407
  </>
1297
1408
  );
1409
+ // uf-lint-enable react-compiler/static-components
1298
1410
  }
1299
1411
 
1300
1412
  /**
package/internal/shell.js CHANGED
@@ -78,8 +78,8 @@ export function redirectDocument(error: RedirectError): RenderResult {
78
78
  * carried two of them, one in each place, and only one was where a browser
79
79
  * looks. Hoisting the rendered one leaves the metadata with a single source.
80
80
  */
81
- export function shellFor(assets: RenderAssets): DocumentShell {
82
- const head = headTags(assets);
81
+ export function shellFor(assets: RenderAssets, nonce?: string | null): DocumentShell {
82
+ const head = headTags(assets, nonce);
83
83
  return {
84
84
  head,
85
85
  open: `<!doctype html>\n<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">`,
@@ -88,7 +88,7 @@ export function shellFor(assets: RenderAssets): DocumentShell {
88
88
  };
89
89
  }
90
90
 
91
- function headTags(assets: RenderAssets): string {
91
+ function headTags(assets: RenderAssets, nonce?: string | null): string {
92
92
  let tags = "";
93
93
  for (const href of assets.styles) {
94
94
  tags += `<link rel="stylesheet" href="${escapeAttribute(href)}">`;
@@ -96,8 +96,16 @@ function headTags(assets: RenderAssets): string {
96
96
  for (const href of assets.preloads) {
97
97
  tags += `<link rel="modulepreload" href="${escapeAttribute(href)}">`;
98
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)}"`;
99
107
  for (const src of assets.scripts) {
100
- tags += `<script type="module" src="${escapeAttribute(src)}"></script>`;
108
+ tags += `<script type="module" src="${escapeAttribute(src)}"${carried}></script>`;
101
109
  }
102
110
  return tags;
103
111
  }
@@ -667,6 +667,23 @@ export type RenderOptions = {|
667
667
  * [`interleaved`], which says where it may and may not go.
668
668
  */
669
669
  readonly payload?: ReadableStream<Uint8Array>,
670
+ /**
671
+ * This response's Content-Security-Policy nonce, or absent for none.
672
+ *
673
+ * Absent is what every render had before nonces existed and what every
674
+ * render still has in a project that has not asked for one, so a document
675
+ * written without it is byte-for-byte the document uf has always written.
676
+ *
677
+ * Present, it reaches three places, and it has to reach all three or the
678
+ * page is broken rather than merely unprotected: React's own option, which
679
+ * nonces every inline script React emits — the runtime that reveals a
680
+ * `<Suspense>` boundary, and the bootstrap; `shell.open`/`shell.body`, which
681
+ * carry the client entry; and the payload chunk elements, which
682
+ * [`interleaved`] writes as text.
683
+ *
684
+ * `prerenderDocument` is deliberately never given one. See its own paragraph.
685
+ */
686
+ readonly nonce?: string | null,
670
687
  |};
671
688
 
672
689
  /**
@@ -706,6 +723,7 @@ export function renderDocument(node: React.Node, options: RenderOptions): Promis
706
723
  withPayload(
707
724
  assembled(queue.chunks(), options.shell, options.transformHead),
708
725
  options.payload,
726
+ options.nonce,
709
727
  ),
710
728
  options.onStream,
711
729
  ),
@@ -717,6 +735,11 @@ export function renderDocument(node: React.Node, options: RenderOptions): Promis
717
735
  reject(error);
718
736
  },
719
737
  onError: options.onError,
738
+ // Every inline script React writes for this document, from one option:
739
+ // the streaming runtime that reveals a boundary and patches a segment,
740
+ // and the bootstrap. uf nonces the scripts it writes itself; these are
741
+ // React's, and there is no other way to reach them.
742
+ nonce: options.nonce ?? undefined,
720
743
  });
721
744
  return;
722
745
  }
@@ -744,6 +767,7 @@ type ReadableStreamRenderer = (
744
767
  settings: {|
745
768
  readonly onError: (error: mixed) => void,
746
769
  readonly signal: AbortSignal,
770
+ readonly nonce?: string,
747
771
  |},
748
772
  ) => Promise<ByteSource>;
749
773
 
@@ -769,20 +793,24 @@ export function renderWithReadableStream(
769
793
  options: RenderOptions,
770
794
  ): Promise<DocumentBody> {
771
795
  const controller = new AbortController();
772
- return render(node, { onError: options.onError, signal: controller.signal }).then(
773
- (stream: ByteSource) =>
774
- bodyOf(
775
- outgoing(
776
- withPayload(
777
- assembled(decoded(stream), options.shell, options.transformHead),
778
- options.payload,
779
- ),
780
- options.onStream,
796
+ return render(node, {
797
+ onError: options.onError,
798
+ signal: controller.signal,
799
+ nonce: options.nonce ?? undefined,
800
+ }).then((stream: ByteSource) =>
801
+ bodyOf(
802
+ outgoing(
803
+ withPayload(
804
+ assembled(decoded(stream), options.shell, options.transformHead),
805
+ options.payload,
806
+ options.nonce,
781
807
  ),
782
- () => {
783
- controller.abort();
784
- },
808
+ options.onStream,
785
809
  ),
810
+ () => {
811
+ controller.abort();
812
+ },
813
+ ),
786
814
  );
787
815
  }
788
816
 
@@ -843,8 +871,9 @@ export async function prerenderDocument(node: React.Node, options: RenderOptions
843
871
  function withPayload(
844
872
  chunks: AsyncGenerator<string, void, void>,
845
873
  payload: ?ReadableStream<Uint8Array>,
874
+ nonce?: string | null,
846
875
  ): AsyncGenerator<string, void, void> {
847
- return payload == null ? chunks : interleaved(chunks, payload);
876
+ return payload == null ? chunks : interleaved(chunks, payload, nonce);
848
877
  }
849
878
 
850
879
  /**
@@ -892,8 +921,9 @@ function withPayload(
892
921
  async function* interleaved(
893
922
  chunks: AsyncGenerator<string, void, void>,
894
923
  payload: ReadableStream<Uint8Array>,
924
+ nonce?: string | null,
895
925
  ): AsyncGenerator<string, void, void> {
896
- const encoder = createChunkEncoder();
926
+ const encoder = createChunkEncoder(nonce);
897
927
  const reader = payload.getReader();
898
928
  let written = "";
899
929
  let ended = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/router",
3
- "version": "0.0.0-alpha.37",
3
+ "version": "0.0.0-alpha.39",
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",
@@ -57,7 +57,7 @@
57
57
  }
58
58
  },
59
59
  "dependencies": {
60
- "@uniflowed/hooks": "0.0.0-alpha.37",
61
- "@uniflowed/server": "0.0.0-alpha.37"
60
+ "@uniflowed/hooks": "0.0.0-alpha.39",
61
+ "@uniflowed/server": "0.0.0-alpha.39"
62
62
  }
63
63
  }
package/rsc-client.js CHANGED
@@ -39,6 +39,7 @@ import {
39
39
  installFlightFetch,
40
40
  installNavigation,
41
41
  installRouting,
42
+ installStaleTime,
42
43
  } from "./internal/runtime.js";
43
44
 
44
45
  /**
@@ -72,10 +73,12 @@ export async function hydrateFlight(options: {|
72
73
  readonly navigation?: Navigation,
73
74
  readonly basePath?: string,
74
75
  readonly trailingSlash?: TrailingSlash,
76
+ readonly staleTime?: number,
75
77
  |}): Promise<void> {
76
78
  requireServerComponentsReact("@uniflowed/router/rsc/client");
77
79
  installNavigation(options.navigation ?? "client");
78
80
  installRouting({ basePath: options.basePath, trailingSlash: options.trailingSlash });
81
+ installStaleTime(options.staleTime ?? 0);
79
82
  installFlightFetch(fetchFlight);
80
83
  installBrowserModules();
81
84
  const flight = readDocumentPayload(document, domObserver(document));
package/rsc-ssr.js CHANGED
@@ -32,6 +32,8 @@ import { type StreamRecord, streamReporter } from "./internal/inspector.js";
32
32
  import { requireServerComponentsReact } from "./internal/react-version.js";
33
33
  import { RedirectError } from "./internal/routing.js";
34
34
  import type { AppProps } from "./internal/runtime.js";
35
+ import { currentNonce } from "@uniflowed/server/host";
36
+
35
37
  import { redirectDocument, redirectResult, shellFor } from "./internal/shell.js";
36
38
  import { type DocumentBody, prerenderDocument, renderDocument } from "./internal/stream.js";
37
39
  import type { FlightRenderer } from "./rsc.js";
@@ -101,13 +103,19 @@ export function createDocumentRenderer(options: DocumentRendererOptions): Render
101
103
  |},
102
104
  ): Promise<DocumentBody> {
103
105
  const [forHtml, forBrowser] = stream.tee();
106
+ // Read rather than minted, for the reason `../server.js` gives: a project
107
+ // that has not asked for a nonce gets `null` and the document it has
108
+ // always had. Read here rather than passed in because both of this
109
+ // renderer's callers reach `documentOf`, and a render is one response.
110
+ const nonce = currentNonce();
104
111
  try {
105
112
  return await renderDocument(<App url={url} flight={readPayload(forHtml)} />, {
106
- shell: shellFor(assets),
113
+ shell: shellFor(assets, nonce),
107
114
  onError: settings.onError,
108
115
  transformHead: settings.transformHead,
109
116
  onStream: settings.onStream,
110
117
  payload: forBrowser,
118
+ nonce,
111
119
  });
112
120
  } catch (error) {
113
121
  void forBrowser.cancel();
package/rsc.js CHANGED
@@ -238,6 +238,11 @@ component RoutePage(
238
238
  data: mixed,
239
239
  ) {
240
240
  const Page = pageComponent(page);
241
+ // The route module's own export, looked up by route: `pageComponent` hands
242
+ // back its `default` or `Page` as it is, so this is the same component on
243
+ // every render of the same route. The React Compiler cannot see through the
244
+ // lookup and reports a component created during render.
245
+ // uf-lint-disable-next-line react-compiler/static-components
241
246
  return <Page params={params} searchParams={searchParams} data={data} />;
242
247
  }
243
248
 
package/server.js CHANGED
@@ -44,6 +44,8 @@ import {
44
44
  } from "./internal/runtime.js";
45
45
 
46
46
  import { type StreamDiagnostic, streamReporter } from "./internal/inspector.js";
47
+ import { currentNonce } from "@uniflowed/server/host";
48
+
47
49
  import { redirectDocument, redirectResult, shellFor } from "./internal/shell.js";
48
50
 
49
51
  /** Asset URLs to reference from the document. */
@@ -357,6 +359,11 @@ export function createRenderer(options: {|
357
359
  // browser was actually sent rather than the one that was abandoned.
358
360
  const send = settings?.onStream;
359
361
  const onStream = send == null ? undefined : streamReporter(url, send);
362
+ // Read rather than minted: a project that has not asked for a nonce gets
363
+ // `null` and the document it has always had. Read once for both renders
364
+ // below, so the error document a failed shell falls back to carries the
365
+ // same nonce as the policy already on the response.
366
+ const nonce = currentNonce();
360
367
 
361
368
  // React reports an exception to `onError` *and*, if it was in the shell, to
362
369
  // `onShellError` — so forwarding both would tell the host about one failure
@@ -377,10 +384,11 @@ export function createRenderer(options: {|
377
384
  let body: DocumentBody;
378
385
  try {
379
386
  body = await renderDocument(<App url={url} initial={resolved} />, {
380
- shell: shellFor(assets),
387
+ shell: shellFor(assets, nonce),
381
388
  onError,
382
389
  transformHead: settings?.transformHead,
383
390
  onStream,
391
+ nonce,
384
392
  });
385
393
  streaming = true;
386
394
  // Recovered before the shell was ready: a `<Suspense>` boundary whose
@@ -409,10 +417,11 @@ export function createRenderer(options: {|
409
417
  // where somebody can fix it.
410
418
  streaming = true;
411
419
  body = await renderDocument(<App url={url} initial={resolved} />, {
412
- shell: shellFor(assets),
420
+ shell: shellFor(assets, nonce),
413
421
  onError,
414
422
  transformHead: settings?.transformHead,
415
423
  onStream,
424
+ nonce,
416
425
  });
417
426
  }
418
427