@uniflowed/router 0.0.0-alpha.40 → 0.0.0-alpha.42

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.
@@ -61,6 +61,12 @@ let staleTimeMs: number = 0;
61
61
  */
62
62
  export function installStaleTime(seconds: number): void {
63
63
  staleTimeMs = Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : 0;
64
+ if (import.meta.hot != null) {
65
+ Object.defineProperty((globalThis: $FlowFixMe), "__UF_NAVIGATION_CACHE__", {
66
+ configurable: true,
67
+ value: inspectNavigationCache,
68
+ });
69
+ }
64
70
  if (staleTimeMs === 0) {
65
71
  clearNavigationCache();
66
72
  }
@@ -87,6 +93,15 @@ export type NavigationCache<T> = {|
87
93
  readonly clear: () => void,
88
94
  /** How many routes are kept, fresh or not. */
89
95
  readonly size: () => number,
96
+ readonly inspect: () => $ReadOnlyArray<NavigationCacheEntry>,
97
+ |};
98
+
99
+ /** Metadata only: inspecting the cache never exposes loader or Flight data. */
100
+ export type NavigationCacheEntry = {|
101
+ readonly key: string,
102
+ readonly cachedAt: number,
103
+ readonly staleAt: number,
104
+ readonly fresh: boolean,
90
105
  |};
91
106
 
92
107
  function createNavigationCache<T>(): NavigationCache<T> {
@@ -128,6 +143,15 @@ function createNavigationCache<T>(): NavigationCache<T> {
128
143
  size() {
129
144
  return entries.size;
130
145
  },
146
+ inspect() {
147
+ const now = Date.now();
148
+ return Array.from(entries, ([key, entry]) => ({
149
+ key,
150
+ cachedAt: entry.at,
151
+ staleAt: entry.at + staleTimeMs,
152
+ fresh: now - entry.at < staleTimeMs,
153
+ }));
154
+ },
131
155
  };
132
156
  }
133
157
 
@@ -137,6 +161,19 @@ export const flightNavigations: NavigationCache<Promise<FetchedFlight>> = create
137
161
  /** Resolved routes, for an application rendered from its modules. */
138
162
  export const routeNavigations: NavigationCache<Promise<ResolvedRoute>> = createNavigationCache();
139
163
 
164
+ /** A fresh snapshot for the development console, without reading an entry. */
165
+ export function inspectNavigationCache(): {|
166
+ readonly staleTime: number,
167
+ readonly flight: $ReadOnlyArray<NavigationCacheEntry>,
168
+ readonly routes: $ReadOnlyArray<NavigationCacheEntry>,
169
+ |} {
170
+ return {
171
+ staleTime: staleTimeMs / 1000,
172
+ flight: flightNavigations.inspect(),
173
+ routes: routeNavigations.inspect(),
174
+ };
175
+ }
176
+
140
177
  /** Forget every kept route. `router.refresh()` and each server action call this. */
141
178
  export function clearNavigationCache(): void {
142
179
  flightNavigations.clear();
@@ -722,6 +722,8 @@ export type ResolveOptions = {|
722
722
  readonly defer?: boolean,
723
723
  /** The route pattern, the moment the URL matches one; see [`resolveMatch`]. */
724
724
  readonly onMatch?: (pattern: string) => void,
725
+ /** The server supplies tracing without importing server code into the browser. */
726
+ readonly runLoader?: (body: () => mixed | Promise<mixed>) => Promise<mixed>,
725
727
  |};
726
728
 
727
729
  async function resolveRoute(
@@ -799,7 +801,9 @@ async function resolveRoute(
799
801
  let data: mixed = options?.data;
800
802
  let deferred: ?Promise<mixed> = null;
801
803
  if (options?.skipLoader !== true && typeof page.loader === "function") {
802
- const running = page.loader({ params: matched.params, searchParams, pathname });
804
+ const loader = page.loader;
805
+ const loadData = () => loader({ params: matched.params, searchParams, pathname });
806
+ const running = options?.runLoader == null ? loadData() : options.runLoader(loadData);
803
807
  const canDefer =
804
808
  options?.defer === true &&
805
809
  (matched.route.loading ?? []).length > 0 &&
@@ -1043,15 +1047,13 @@ export function interceptingRoutes(
1043
1047
  * something to show for one URL, the way two slots each match one URL by their
1044
1048
  * own routes.
1045
1049
  *
1046
- * # Never on a server
1050
+ * # Never for a document
1047
1051
  *
1048
- * Nothing on the server calls this. A document request for an intercepted URL
1049
- * resolves the ordinary page, because a request carries where it is going and
1050
- * not what was on screen when it was made — which is what a reload, a shared
1051
- * link and a crawler all are. That includes the Flight renderer, so a browser
1052
- * holding a payload rather than a resolved route navigates to the page the URL
1053
- * names: interception is a feature of the router that resolves routes from
1054
- * their modules.
1052
+ * A document request for an intercepted URL resolves the ordinary page,
1053
+ * because a request carries where it is going and not what was on screen when
1054
+ * it was made — which is what a reload, a shared link and a crawler all are.
1055
+ * A Flight payload request may carry the page the browser is navigating from,
1056
+ * and the React Server Components renderer calls this for that request only.
1055
1057
  *
1056
1058
  * # When the interception cannot render
1057
1059
  *
@@ -139,6 +139,10 @@ export type RouteTable<
139
139
  TError = mixed,
140
140
  > = {|
141
141
  readonly routes: $ReadOnlyArray<RouteRecord<TPage, TLayout, TTemplate, TLoading, TError>>,
142
+ readonly nativeLinks?: {|
143
+ readonly origins: $ReadOnlyArray<string>,
144
+ readonly routes: $ReadOnlyArray<string>,
145
+ |},
142
146
  readonly notFound: $ReadOnlyArray<NotFoundBoundary<TPage, TLayout>>,
143
147
  readonly errors: $ReadOnlyArray<ErrorBoundary<TError, TLayout>>,
144
148
  |};
@@ -20,7 +20,9 @@
20
20
 
21
21
  "use client";
22
22
 
23
- import * as React from "react";
23
+ import { observeNavigation } from "../instrumentation.js";
24
+
25
+ import type * as React from "react";
24
26
  import {
25
27
  Suspense,
26
28
  createContext,
@@ -28,8 +30,10 @@ import {
28
30
  use,
29
31
  useContext,
30
32
  useEffect,
33
+ useRef,
31
34
  useState,
32
35
  useSyncExternalStore,
36
+ useTransition,
33
37
  } from "react";
34
38
  // The one thing in this module that only a browser can do, and the reason it
35
39
  // is imported here rather than from `../client.js`: a view transition needs
@@ -74,7 +78,13 @@ import {
74
78
  import { BoundaryReporter } from "./boundaries.js";
75
79
  import { routeBoundaries } from "./boundary-data.js";
76
80
  import { composeRoute, pageComponent } from "./compose.js";
77
- import { type FetchedFlight, type FlightRoot, type RouteState, routeState } from "./flight.js";
81
+ import {
82
+ type FetchedFlight,
83
+ type FlightFetchOptions,
84
+ type FlightRoot,
85
+ type RouteState,
86
+ routeState,
87
+ } from "./flight.js";
78
88
  import { Head } from "./head.js";
79
89
  import { addressOf, applicationPathOf, canonicalAddress } from "./base-path.js";
80
90
  import {
@@ -426,15 +436,19 @@ export function navigationMode(): Navigation {
426
436
  * shown, whether or not anything calls it, so an import here would put that
427
437
  * package in every one of those bundles, or fail the build where it is absent.
428
438
  */
429
- let installedFlightFetch: ((url: string) => Promise<FetchedFlight>) | null = null;
439
+ let installedFlightFetch:
440
+ | ((url: string, options?: FlightFetchOptions) => Promise<FetchedFlight>)
441
+ | null = null;
430
442
 
431
443
  /** Hand the router the payload fetch. Called once, by `hydrateFlight`, before the first render. */
432
- export function installFlightFetch(fetcher: (url: string) => Promise<FetchedFlight>): void {
444
+ export function installFlightFetch(
445
+ fetcher: (url: string, options?: FlightFetchOptions) => Promise<FetchedFlight>,
446
+ ): void {
433
447
  installedFlightFetch = fetcher;
434
448
  }
435
449
 
436
450
  /** The next route's payload, through the fetch `hydrateFlight` installed. */
437
- function fetchFlight(url: string): Promise<FetchedFlight> {
451
+ function fetchFlight(url: string, options?: FlightFetchOptions): Promise<FetchedFlight> {
438
452
  if (installedFlightFetch == null) {
439
453
  return Promise.reject(
440
454
  new Error(
@@ -444,7 +458,7 @@ function fetchFlight(url: string): Promise<FetchedFlight> {
444
458
  ),
445
459
  );
446
460
  }
447
- return installedFlightFetch(url);
461
+ return installedFlightFetch(url, options);
448
462
  }
449
463
 
450
464
  /** Register the generated route table. Called once by the client and server entries. */
@@ -570,6 +584,12 @@ function historyStateFor(resolved: ResolvedRoute): mixed {
570
584
  return { [INTERCEPTED_FROM]: interception.base.pathname + interception.base.search };
571
585
  }
572
586
 
587
+ /** The state a history entry for a Flight route is written with. */
588
+ function historyStateForRoute(route: RouteState): mixed {
589
+ const from = route.interception?.from;
590
+ return from == null ? null : { [INTERCEPTED_FROM]: from };
591
+ }
592
+
573
593
  /** Where the history entry holding `state` was intercepted from, if it was. */
574
594
  function interceptedFrom(state: mixed): ?string {
575
595
  if (state == null || typeof state !== "object" || Array.isArray(state)) {
@@ -599,6 +619,22 @@ function withoutInterception(state: mixed): mixed {
599
619
  return Object.keys(rest).length === 0 ? null : rest;
600
620
  }
601
621
 
622
+ /** The page a Flight navigation is leaving from, as an application path and query. */
623
+ function flightNavigationOrigin(root: FlightRoot): string {
624
+ return root.route.interception?.from ?? root.route.pathname + root.route.search;
625
+ }
626
+
627
+ /** The optional fetch settings for an intercepted payload request. */
628
+ function flightFetchOptions(from: ?string): FlightFetchOptions | void {
629
+ return from == null ? undefined : { interceptedFrom: from };
630
+ }
631
+
632
+ /** The cache key for a Flight payload, separated by the page it renders over. */
633
+ function flightNavigationKey(pathname: string, search: string, from: ?string): string {
634
+ const key = navigationKey(pathname, search);
635
+ return from == null ? key : `${key}\0${from}`;
636
+ }
637
+
602
638
  /**
603
639
  * The provider for a route resolved from its modules: a single-page
604
640
  * application, and a project that turned `app.rsc` off.
@@ -625,13 +661,15 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
625
661
  // whether a navigation is intercepted is a question about the page it starts
626
662
  // on — and `show` writes both in the same breath, so the two cannot disagree
627
663
  // about what was last committed.
628
- const shown = React.useRef<ResolvedRoute>(initial);
664
+ const shown = useRef<ResolvedRoute>(initial);
629
665
  const show = (next: ResolvedRoute) => {
630
666
  shown.current = next;
631
667
  setResolved(next);
632
668
  };
633
669
 
634
- const navigate = async (to: string, options?: NavigateOptions): Promise<void> => {
670
+ const navigate = (to: string, options?: NavigateOptions): Promise<void> =>
671
+ observeNavigation(to, () => navigateTo(to, options));
672
+ const navigateTo = async (to: string, options?: NavigateOptions): Promise<void> => {
635
673
  if (!isBrowser()) {
636
674
  return;
637
675
  }
@@ -943,10 +981,17 @@ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
943
981
  const [current, setCurrent] = useState<Promise<FlightRoot>>(flight);
944
982
  const [pending, setPending] = useState<boolean>(false);
945
983
  const root = use(current);
984
+ const shown = useRef<FlightRoot>(root);
985
+ const show = (payload: Promise<FlightRoot>, nextRoot: FlightRoot) => {
986
+ shown.current = nextRoot;
987
+ setCurrent(payload);
988
+ };
946
989
  // Read once per render, for the reason `ModuleRouter` reads it once.
947
990
  const navigation = navigationMode();
948
991
 
949
- const navigate = async (to: string, options?: NavigateOptions): Promise<void> => {
992
+ const navigate = (to: string, options?: NavigateOptions): Promise<void> =>
993
+ observeNavigation(to, () => navigateTo(to, options));
994
+ const navigateTo = async (to: string, options?: NavigateOptions): Promise<void> => {
950
995
  if (!isBrowser()) {
951
996
  return;
952
997
  }
@@ -967,11 +1012,13 @@ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
967
1012
  try {
968
1013
  // The route this page already has while it is fresh, then a prefetch
969
1014
  // still in hand, then the network. See `./navigation-cache.js`.
970
- const key = navigationKey(target.pathname, target.search);
1015
+ const from = flightNavigationOrigin(shown.current);
1016
+ const ordinaryKey = navigationKey(target.pathname, target.search);
1017
+ const key = flightNavigationKey(target.pathname, target.search, from);
971
1018
  const fetched = await (
972
1019
  flightNavigations.read(key) ??
973
- takePrefetched(next) ??
974
- keepFlight(key, fetchFlight(next))
1020
+ takePrefetched(next, from) ??
1021
+ keepFlight(key, fetchFlight(next, flightFetchOptions(from)), ordinaryKey)
975
1022
  );
976
1023
  // Not a payload: a redirect off this origin, or a host that has no payload
977
1024
  // for this URL. The browser loads it as a document, which is what the
@@ -989,13 +1036,14 @@ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
989
1036
  // address the server answers without a redirect.
990
1037
  const arrived = new URL(fetched.url, window.location.href);
991
1038
  const landed = canonicalAddress(arrived.pathname) + arrived.search + target.hash;
1039
+ const state = historyStateForRoute(nextRoot.route);
992
1040
  if (options?.replace === true) {
993
- window.history.replaceState(null, "", landed);
1041
+ window.history.replaceState(state, "", landed);
994
1042
  } else {
995
- window.history.pushState(null, "", landed);
1043
+ window.history.pushState(state, "", landed);
996
1044
  }
997
1045
  const commit = () => {
998
- setCurrent(payload);
1046
+ show(payload, nextRoot);
999
1047
  setPending(false);
1000
1048
  };
1001
1049
  if (options?.transition === false) {
@@ -1003,7 +1051,9 @@ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
1003
1051
  } else {
1004
1052
  withViewTransition(nextRoot.route.viewTransition, commit);
1005
1053
  }
1006
- if (options?.scroll !== false) {
1054
+ const scroll =
1055
+ nextRoot.route.interception == null ? options?.scroll !== false : options?.scroll === true;
1056
+ if (scroll) {
1007
1057
  if (target.hash !== "") {
1008
1058
  const element = document.getElementById(target.hash.slice(1));
1009
1059
  if (element != null) {
@@ -1023,6 +1073,13 @@ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
1023
1073
  if (!isBrowser()) {
1024
1074
  return undefined;
1025
1075
  }
1076
+ // A document reload rendered the URL's ordinary page. If the browser kept
1077
+ // an intercepted history marker for it, clear that marker before a later
1078
+ // back/forward asks for a modal over a page this document did not show.
1079
+ const restored = window.history.state;
1080
+ if (interceptedFrom(restored) != null) {
1081
+ window.history.replaceState(withoutInterception(restored), "", window.location.href);
1082
+ }
1026
1083
  // No history entry was pushed, so there is nothing to pop back into; see
1027
1084
  // `ModuleRouter`.
1028
1085
  if (navigation === "document") {
@@ -1033,8 +1090,13 @@ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
1033
1090
  // The history entry already moved; a payload that cannot be had for it is
1034
1091
  // a document to load, and a reload is the browser's way to load it. While
1035
1092
  // 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(
1093
+ const from = interceptedFrom(window.history.state);
1094
+ const ordinaryKey = navigationKey(window.location.pathname, window.location.search);
1095
+ const key = flightNavigationKey(window.location.pathname, window.location.search, from);
1096
+ (
1097
+ flightNavigations.read(key) ??
1098
+ keepFlight(key, fetchFlight(next, flightFetchOptions(from)), ordinaryKey)
1099
+ ).then(
1038
1100
  (fetched) => {
1039
1101
  if (fetched.kind === "document") {
1040
1102
  window.location.reload();
@@ -1044,7 +1106,7 @@ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
1044
1106
  payload.then(
1045
1107
  (nextRoot) => {
1046
1108
  withViewTransition(nextRoot.route.viewTransition, () => {
1047
- setCurrent(payload);
1109
+ show(payload, nextRoot);
1048
1110
  });
1049
1111
  },
1050
1112
  () => {
@@ -1077,14 +1139,19 @@ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
1077
1139
  return;
1078
1140
  }
1079
1141
  const next = target.pathname + target.search;
1142
+ const from = flightNavigationOrigin(shown.current);
1080
1143
  // Kept for every navigation to it while it is fresh, when a project set
1081
1144
  // `app.rendering.staleTime`; otherwise held for the one click after it.
1082
1145
  if (keepsNavigations()) {
1083
- const key = navigationKey(target.pathname, target.search);
1084
- await (flightNavigations.read(key) ?? keepFlight(key, fetchFlight(next)));
1146
+ const ordinaryKey = navigationKey(target.pathname, target.search);
1147
+ const key = flightNavigationKey(target.pathname, target.search, from);
1148
+ await (
1149
+ flightNavigations.read(key) ??
1150
+ keepFlight(key, fetchFlight(next, flightFetchOptions(from)), ordinaryKey)
1151
+ );
1085
1152
  return;
1086
1153
  }
1087
- await prefetchFlight(next);
1154
+ await prefetchFlight(next, from);
1088
1155
  },
1089
1156
  refresh: async () => {
1090
1157
  if (!isBrowser()) {
@@ -1097,20 +1164,23 @@ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
1097
1164
  // Everything a navigation kept is older than what this asks for, so none
1098
1165
  // of it is shown again; see `./navigation-cache.js`.
1099
1166
  clearNavigationCache();
1167
+ const from = root.route.interception?.from ?? null;
1168
+ const ordinaryKey = navigationKey(window.location.pathname, window.location.search);
1100
1169
  const fetched = await keepFlight(
1101
- navigationKey(window.location.pathname, window.location.search),
1102
- fetchFlight(window.location.pathname + window.location.search),
1170
+ flightNavigationKey(window.location.pathname, window.location.search, from),
1171
+ fetchFlight(window.location.pathname + window.location.search, flightFetchOptions(from)),
1172
+ ordinaryKey,
1103
1173
  );
1104
1174
  if (fetched.kind === "document") {
1105
1175
  window.location.reload();
1106
1176
  return;
1107
1177
  }
1108
1178
  const payload = fetched.root;
1109
- await payload;
1179
+ const nextRoot = await payload;
1110
1180
  // No view transition: a refresh is the same URL rendered again. See
1111
1181
  // `ModuleRouter`'s refresh.
1112
1182
  startTransition(() => {
1113
- setCurrent(payload);
1183
+ show(payload, nextRoot);
1114
1184
  });
1115
1185
  },
1116
1186
  back: () => {
@@ -1157,13 +1227,20 @@ const prefetchedFlights: Map<
1157
1227
  * request that failed, an answer that was a document rather than a payload, or
1158
1228
  * a payload React could not read.
1159
1229
  */
1160
- function keepFlight(key: string, fetched: Promise<FetchedFlight>): Promise<FetchedFlight> {
1230
+ function keepFlight(
1231
+ key: string,
1232
+ fetched: Promise<FetchedFlight>,
1233
+ ordinaryKey?: string,
1234
+ ): Promise<FetchedFlight> {
1161
1235
  if (!keepsNavigations()) {
1162
1236
  return fetched;
1163
1237
  }
1164
1238
  flightNavigations.store(key, fetched);
1165
1239
  const forget = () => {
1166
1240
  flightNavigations.forget(key, fetched);
1241
+ if (ordinaryKey != null) {
1242
+ flightNavigations.forget(ordinaryKey, fetched);
1243
+ }
1167
1244
  };
1168
1245
  void fetched.then((answer) => {
1169
1246
  if (answer.kind === "document") {
@@ -1175,6 +1252,10 @@ function keepFlight(key: string, fetched: Promise<FetchedFlight>): Promise<Fetch
1175
1252
  void answer.root.then((root) => {
1176
1253
  if (root.route.status !== 200) {
1177
1254
  forget();
1255
+ return;
1256
+ }
1257
+ if (ordinaryKey != null && root.route.interception == null) {
1258
+ flightNavigations.store(ordinaryKey, fetched);
1178
1259
  }
1179
1260
  }, forget);
1180
1261
  }, forget);
@@ -1202,8 +1283,13 @@ function keepRoute(key: string, resolved: Promise<ResolvedRoute>): Promise<Resol
1202
1283
  return resolved;
1203
1284
  }
1204
1285
 
1205
- function prefetchFlight(url: string): Promise<FetchedFlight> {
1206
- const existing = prefetchedFlights.get(url);
1286
+ function prefetchedFlightKey(url: string, from: ?string): string {
1287
+ return from == null ? url : `${url}\0${from}`;
1288
+ }
1289
+
1290
+ function prefetchFlight(url: string, from: ?string): Promise<FetchedFlight> {
1291
+ const key = prefetchedFlightKey(url, from);
1292
+ const existing = prefetchedFlights.get(key);
1207
1293
  if (existing != null && Date.now() - existing.at < PREFETCH_LIFETIME_MS) {
1208
1294
  return existing.fetched;
1209
1295
  }
@@ -1213,17 +1299,18 @@ function prefetchFlight(url: string): Promise<FetchedFlight> {
1213
1299
  prefetchedFlights.delete(oldest.value);
1214
1300
  }
1215
1301
  }
1216
- const fetched = fetchFlight(url);
1217
- prefetchedFlights.set(url, { fetched, at: Date.now() });
1302
+ const fetched = fetchFlight(url, flightFetchOptions(from));
1303
+ prefetchedFlights.set(key, { fetched, at: Date.now() });
1218
1304
  fetched.catch(() => {
1219
- prefetchedFlights.delete(url);
1305
+ prefetchedFlights.delete(key);
1220
1306
  });
1221
1307
  return fetched;
1222
1308
  }
1223
1309
 
1224
- function takePrefetched(url: string): Promise<FetchedFlight> | null {
1225
- const entry = prefetchedFlights.get(url);
1226
- prefetchedFlights.delete(url);
1310
+ function takePrefetched(url: string, from: ?string): Promise<FetchedFlight> | null {
1311
+ const key = prefetchedFlightKey(url, from);
1312
+ const entry = prefetchedFlights.get(key);
1313
+ prefetchedFlights.delete(key);
1227
1314
  if (entry == null || Date.now() - entry.at >= PREFETCH_LIFETIME_MS) {
1228
1315
  return null;
1229
1316
  }
@@ -1639,6 +1726,13 @@ export hook useSeo(seo: Metadata): React.Node {
1639
1726
  /** When a `Link` loads the route it points at. */
1640
1727
  export type LinkPrefetch = "off" | "intent" | "render";
1641
1728
 
1729
+ const LinkStatusContext: React.Context<boolean> = createContext(false);
1730
+
1731
+ /** Whether the containing Link is waiting for the navigation it started. */
1732
+ export hook useLinkStatus(): {| readonly pending: boolean |} {
1733
+ return { pending: useContext(LinkStatusContext) };
1734
+ }
1735
+
1642
1736
  /**
1643
1737
  * A client-side navigation.
1644
1738
  *
@@ -1675,7 +1769,8 @@ export component Link(
1675
1769
  ...rest: { readonly [string]: mixed }
1676
1770
  ) {
1677
1771
  const { router, navigation } = useRouterState();
1678
- const prefetched = React.useRef(false);
1772
+ const [linkPending, startLinkTransition] = useTransition();
1773
+ const prefetched = useRef(false);
1679
1774
  const drives = navigation === "client";
1680
1775
 
1681
1776
  const doPrefetch = () => {
@@ -1703,15 +1798,21 @@ export component Link(
1703
1798
  event.ctrlKey ||
1704
1799
  event.shiftKey ||
1705
1800
  event.altKey ||
1801
+ (rest.target != null && rest.target !== "_self") ||
1802
+ rest.download != null ||
1706
1803
  isExternal(to)
1707
1804
  ) {
1708
1805
  return;
1709
1806
  }
1710
1807
  event.preventDefault();
1711
- router.push(to, { replace, transition }).catch((error) => {
1712
- // A failed navigation falls back to the browser doing it.
1713
- console.error(error);
1714
- window.location.assign(addressOf(to));
1808
+ startLinkTransition(async () => {
1809
+ try {
1810
+ await router.push(to, { replace, transition });
1811
+ } catch (error) {
1812
+ // A failed navigation falls back to the browser doing it.
1813
+ console.error(error);
1814
+ window.location.assign(addressOf(to));
1815
+ }
1715
1816
  });
1716
1817
  };
1717
1818
 
@@ -1727,11 +1828,13 @@ export component Link(
1727
1828
  // works before hydration and for a right click goes where this one does.
1728
1829
  href={addressOf(to)}
1729
1830
  className={className}
1831
+ aria-busy={linkPending || undefined}
1832
+ data-pending={linkPending ? "" : undefined}
1730
1833
  onClick={drives ? handleClick : onClick}
1731
1834
  onMouseEnter={drives && prefetch === "intent" ? doPrefetch : undefined}
1732
1835
  onFocus={drives && prefetch === "intent" ? doPrefetch : undefined}
1733
1836
  >
1734
- {children}
1837
+ <LinkStatusContext value={linkPending}>{children}</LinkStatusContext>
1735
1838
  </a>
1736
1839
  );
1737
1840
  }
@@ -0,0 +1,12 @@
1
+ // @flow
2
+
3
+ import { traceRequestPhase } from "@uniflowed/server/instrumentation";
4
+ import { NotFoundError, RedirectError } from "./routing.js";
5
+
6
+ export function traceLoader(body: () => mixed | Promise<mixed>): Promise<mixed> {
7
+ return traceRequestPhase(
8
+ "loader",
9
+ async () => body(),
10
+ (error) => error instanceof RedirectError || error instanceof NotFoundError,
11
+ );
12
+ }
@@ -906,6 +906,11 @@ function withPayload(
906
906
  * from chunk to chunk, and otherwise waits for the HTML that finishes what is
907
907
  * open.
908
908
  *
909
+ * **Outside replaceable boundaries.** Even between tags, a payload inside a
910
+ * Suspense fallback disappears when React reveals its content. A browser that
911
+ * loads the Flight reader afterwards then sees an incomplete stream. React's
912
+ * boundary comments keep those ranges closed to injection until their end.
913
+ *
909
914
  * **`</body></html>` waits for the end of the payload.** The HTML can finish
910
915
  * first — the last boundary's markup is rendered from rows that are already
911
916
  * written — and a payload element after `</html>` is one the parser moves
@@ -1020,11 +1025,13 @@ type Boundary = {|
1020
1025
  readonly safe: boolean,
1021
1026
  /** The `script` or `style` element the HTML is inside, if it is inside one. */
1022
1027
  readonly rawText: string | null,
1028
+ /** Suspense/Activity ranges React can replace before hydration starts. */
1029
+ readonly replaceable: number,
1023
1030
  /** A tag, or a comment, the HTML has started and not yet finished. */
1024
1031
  readonly open: string,
1025
1032
  |};
1026
1033
 
1027
- const NOTHING_WRITTEN: Boundary = { safe: false, rawText: null, open: "" };
1034
+ const NOTHING_WRITTEN: Boundary = { safe: false, rawText: null, replaceable: 0, open: "" };
1028
1035
 
1029
1036
  /**
1030
1037
  * `boundary`, once `html` has been written after it.
@@ -1041,23 +1048,31 @@ const NOTHING_WRITTEN: Boundary = { safe: false, rawText: null, open: "" };
1041
1048
  function advanced(boundary: Boundary, html: string): Boundary {
1042
1049
  const text = boundary.open + html;
1043
1050
  let rawText = boundary.rawText;
1044
- const tags = /<(\/?)(script|style)(?=[\s/>])[^>]*>/gi;
1051
+ let replaceable = boundary.replaceable;
1052
+ const tags = /<!--([\s\S]*?)-->|<(\/?)(script|style)(?=[\s/>])[^>]*>/gi;
1045
1053
  let tag = tags.exec(text);
1046
1054
  while (tag != null) {
1047
- const name = tag[2].toLowerCase();
1048
- if (tag[1] === "/") {
1049
- if (rawText === name) {
1050
- rawText = null;
1055
+ const comment = tag[1];
1056
+ if (comment != null) {
1057
+ if (rawText == null) {
1058
+ if (/^[\$&][?!~]?$/.test(comment)) replaceable += 1;
1059
+ else if (comment === "/$" || comment === "/&") replaceable = Math.max(0, replaceable - 1);
1060
+ }
1061
+ } else {
1062
+ const name = tag[3].toLowerCase();
1063
+ if (tag[2] === "/") {
1064
+ if (rawText === name) rawText = null;
1065
+ } else if (rawText == null) {
1066
+ rawText = name;
1051
1067
  }
1052
- } else if (rawText == null) {
1053
- rawText = name;
1054
1068
  }
1055
1069
  tag = tags.exec(text);
1056
1070
  }
1057
1071
  const start = text.lastIndexOf("<");
1058
1072
  return {
1059
- safe: rawText == null && text.endsWith(">"),
1073
+ safe: rawText == null && replaceable === 0 && text.endsWith(">"),
1060
1074
  rawText,
1075
+ replaceable,
1061
1076
  open: start > text.lastIndexOf(">") ? text.slice(start) : "",
1062
1077
  };
1063
1078
  }