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

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.
@@ -20,7 +20,7 @@
20
20
 
21
21
  "use client";
22
22
 
23
- import * as React from "react";
23
+ import type * as React from "react";
24
24
  import {
25
25
  Suspense,
26
26
  createContext,
@@ -28,8 +28,10 @@ import {
28
28
  use,
29
29
  useContext,
30
30
  useEffect,
31
+ useRef,
31
32
  useState,
32
33
  useSyncExternalStore,
34
+ useTransition,
33
35
  } from "react";
34
36
  // The one thing in this module that only a browser can do, and the reason it
35
37
  // is imported here rather than from `../client.js`: a view transition needs
@@ -74,7 +76,13 @@ import {
74
76
  import { BoundaryReporter } from "./boundaries.js";
75
77
  import { routeBoundaries } from "./boundary-data.js";
76
78
  import { composeRoute, pageComponent } from "./compose.js";
77
- import { type FetchedFlight, type FlightRoot, type RouteState, routeState } from "./flight.js";
79
+ import {
80
+ type FetchedFlight,
81
+ type FlightFetchOptions,
82
+ type FlightRoot,
83
+ type RouteState,
84
+ routeState,
85
+ } from "./flight.js";
78
86
  import { Head } from "./head.js";
79
87
  import { addressOf, applicationPathOf, canonicalAddress } from "./base-path.js";
80
88
  import {
@@ -426,15 +434,19 @@ export function navigationMode(): Navigation {
426
434
  * shown, whether or not anything calls it, so an import here would put that
427
435
  * package in every one of those bundles, or fail the build where it is absent.
428
436
  */
429
- let installedFlightFetch: ((url: string) => Promise<FetchedFlight>) | null = null;
437
+ let installedFlightFetch:
438
+ | ((url: string, options?: FlightFetchOptions) => Promise<FetchedFlight>)
439
+ | null = null;
430
440
 
431
441
  /** Hand the router the payload fetch. Called once, by `hydrateFlight`, before the first render. */
432
- export function installFlightFetch(fetcher: (url: string) => Promise<FetchedFlight>): void {
442
+ export function installFlightFetch(
443
+ fetcher: (url: string, options?: FlightFetchOptions) => Promise<FetchedFlight>,
444
+ ): void {
433
445
  installedFlightFetch = fetcher;
434
446
  }
435
447
 
436
448
  /** The next route's payload, through the fetch `hydrateFlight` installed. */
437
- function fetchFlight(url: string): Promise<FetchedFlight> {
449
+ function fetchFlight(url: string, options?: FlightFetchOptions): Promise<FetchedFlight> {
438
450
  if (installedFlightFetch == null) {
439
451
  return Promise.reject(
440
452
  new Error(
@@ -444,7 +456,7 @@ function fetchFlight(url: string): Promise<FetchedFlight> {
444
456
  ),
445
457
  );
446
458
  }
447
- return installedFlightFetch(url);
459
+ return installedFlightFetch(url, options);
448
460
  }
449
461
 
450
462
  /** Register the generated route table. Called once by the client and server entries. */
@@ -570,6 +582,12 @@ function historyStateFor(resolved: ResolvedRoute): mixed {
570
582
  return { [INTERCEPTED_FROM]: interception.base.pathname + interception.base.search };
571
583
  }
572
584
 
585
+ /** The state a history entry for a Flight route is written with. */
586
+ function historyStateForRoute(route: RouteState): mixed {
587
+ const from = route.interception?.from;
588
+ return from == null ? null : { [INTERCEPTED_FROM]: from };
589
+ }
590
+
573
591
  /** Where the history entry holding `state` was intercepted from, if it was. */
574
592
  function interceptedFrom(state: mixed): ?string {
575
593
  if (state == null || typeof state !== "object" || Array.isArray(state)) {
@@ -599,6 +617,22 @@ function withoutInterception(state: mixed): mixed {
599
617
  return Object.keys(rest).length === 0 ? null : rest;
600
618
  }
601
619
 
620
+ /** The page a Flight navigation is leaving from, as an application path and query. */
621
+ function flightNavigationOrigin(root: FlightRoot): string {
622
+ return root.route.interception?.from ?? root.route.pathname + root.route.search;
623
+ }
624
+
625
+ /** The optional fetch settings for an intercepted payload request. */
626
+ function flightFetchOptions(from: ?string): FlightFetchOptions | void {
627
+ return from == null ? undefined : { interceptedFrom: from };
628
+ }
629
+
630
+ /** The cache key for a Flight payload, separated by the page it renders over. */
631
+ function flightNavigationKey(pathname: string, search: string, from: ?string): string {
632
+ const key = navigationKey(pathname, search);
633
+ return from == null ? key : `${key}\0${from}`;
634
+ }
635
+
602
636
  /**
603
637
  * The provider for a route resolved from its modules: a single-page
604
638
  * application, and a project that turned `app.rsc` off.
@@ -625,7 +659,7 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
625
659
  // whether a navigation is intercepted is a question about the page it starts
626
660
  // on — and `show` writes both in the same breath, so the two cannot disagree
627
661
  // about what was last committed.
628
- const shown = React.useRef<ResolvedRoute>(initial);
662
+ const shown = useRef<ResolvedRoute>(initial);
629
663
  const show = (next: ResolvedRoute) => {
630
664
  shown.current = next;
631
665
  setResolved(next);
@@ -943,6 +977,11 @@ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
943
977
  const [current, setCurrent] = useState<Promise<FlightRoot>>(flight);
944
978
  const [pending, setPending] = useState<boolean>(false);
945
979
  const root = use(current);
980
+ const shown = useRef<FlightRoot>(root);
981
+ const show = (payload: Promise<FlightRoot>, nextRoot: FlightRoot) => {
982
+ shown.current = nextRoot;
983
+ setCurrent(payload);
984
+ };
946
985
  // Read once per render, for the reason `ModuleRouter` reads it once.
947
986
  const navigation = navigationMode();
948
987
 
@@ -967,11 +1006,13 @@ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
967
1006
  try {
968
1007
  // The route this page already has while it is fresh, then a prefetch
969
1008
  // still in hand, then the network. See `./navigation-cache.js`.
970
- const key = navigationKey(target.pathname, target.search);
1009
+ const from = flightNavigationOrigin(shown.current);
1010
+ const ordinaryKey = navigationKey(target.pathname, target.search);
1011
+ const key = flightNavigationKey(target.pathname, target.search, from);
971
1012
  const fetched = await (
972
1013
  flightNavigations.read(key) ??
973
- takePrefetched(next) ??
974
- keepFlight(key, fetchFlight(next))
1014
+ takePrefetched(next, from) ??
1015
+ keepFlight(key, fetchFlight(next, flightFetchOptions(from)), ordinaryKey)
975
1016
  );
976
1017
  // Not a payload: a redirect off this origin, or a host that has no payload
977
1018
  // for this URL. The browser loads it as a document, which is what the
@@ -989,13 +1030,14 @@ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
989
1030
  // address the server answers without a redirect.
990
1031
  const arrived = new URL(fetched.url, window.location.href);
991
1032
  const landed = canonicalAddress(arrived.pathname) + arrived.search + target.hash;
1033
+ const state = historyStateForRoute(nextRoot.route);
992
1034
  if (options?.replace === true) {
993
- window.history.replaceState(null, "", landed);
1035
+ window.history.replaceState(state, "", landed);
994
1036
  } else {
995
- window.history.pushState(null, "", landed);
1037
+ window.history.pushState(state, "", landed);
996
1038
  }
997
1039
  const commit = () => {
998
- setCurrent(payload);
1040
+ show(payload, nextRoot);
999
1041
  setPending(false);
1000
1042
  };
1001
1043
  if (options?.transition === false) {
@@ -1003,7 +1045,9 @@ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
1003
1045
  } else {
1004
1046
  withViewTransition(nextRoot.route.viewTransition, commit);
1005
1047
  }
1006
- if (options?.scroll !== false) {
1048
+ const scroll =
1049
+ nextRoot.route.interception == null ? options?.scroll !== false : options?.scroll === true;
1050
+ if (scroll) {
1007
1051
  if (target.hash !== "") {
1008
1052
  const element = document.getElementById(target.hash.slice(1));
1009
1053
  if (element != null) {
@@ -1023,6 +1067,13 @@ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
1023
1067
  if (!isBrowser()) {
1024
1068
  return undefined;
1025
1069
  }
1070
+ // A document reload rendered the URL's ordinary page. If the browser kept
1071
+ // an intercepted history marker for it, clear that marker before a later
1072
+ // back/forward asks for a modal over a page this document did not show.
1073
+ const restored = window.history.state;
1074
+ if (interceptedFrom(restored) != null) {
1075
+ window.history.replaceState(withoutInterception(restored), "", window.location.href);
1076
+ }
1026
1077
  // No history entry was pushed, so there is nothing to pop back into; see
1027
1078
  // `ModuleRouter`.
1028
1079
  if (navigation === "document") {
@@ -1033,8 +1084,13 @@ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
1033
1084
  // The history entry already moved; a payload that cannot be had for it is
1034
1085
  // a document to load, and a reload is the browser's way to load it. While
1035
1086
  // 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(
1087
+ const from = interceptedFrom(window.history.state);
1088
+ const ordinaryKey = navigationKey(window.location.pathname, window.location.search);
1089
+ const key = flightNavigationKey(window.location.pathname, window.location.search, from);
1090
+ (
1091
+ flightNavigations.read(key) ??
1092
+ keepFlight(key, fetchFlight(next, flightFetchOptions(from)), ordinaryKey)
1093
+ ).then(
1038
1094
  (fetched) => {
1039
1095
  if (fetched.kind === "document") {
1040
1096
  window.location.reload();
@@ -1044,7 +1100,7 @@ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
1044
1100
  payload.then(
1045
1101
  (nextRoot) => {
1046
1102
  withViewTransition(nextRoot.route.viewTransition, () => {
1047
- setCurrent(payload);
1103
+ show(payload, nextRoot);
1048
1104
  });
1049
1105
  },
1050
1106
  () => {
@@ -1077,14 +1133,19 @@ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
1077
1133
  return;
1078
1134
  }
1079
1135
  const next = target.pathname + target.search;
1136
+ const from = flightNavigationOrigin(shown.current);
1080
1137
  // Kept for every navigation to it while it is fresh, when a project set
1081
1138
  // `app.rendering.staleTime`; otherwise held for the one click after it.
1082
1139
  if (keepsNavigations()) {
1083
- const key = navigationKey(target.pathname, target.search);
1084
- await (flightNavigations.read(key) ?? keepFlight(key, fetchFlight(next)));
1140
+ const ordinaryKey = navigationKey(target.pathname, target.search);
1141
+ const key = flightNavigationKey(target.pathname, target.search, from);
1142
+ await (
1143
+ flightNavigations.read(key) ??
1144
+ keepFlight(key, fetchFlight(next, flightFetchOptions(from)), ordinaryKey)
1145
+ );
1085
1146
  return;
1086
1147
  }
1087
- await prefetchFlight(next);
1148
+ await prefetchFlight(next, from);
1088
1149
  },
1089
1150
  refresh: async () => {
1090
1151
  if (!isBrowser()) {
@@ -1097,20 +1158,23 @@ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
1097
1158
  // Everything a navigation kept is older than what this asks for, so none
1098
1159
  // of it is shown again; see `./navigation-cache.js`.
1099
1160
  clearNavigationCache();
1161
+ const from = root.route.interception?.from ?? null;
1162
+ const ordinaryKey = navigationKey(window.location.pathname, window.location.search);
1100
1163
  const fetched = await keepFlight(
1101
- navigationKey(window.location.pathname, window.location.search),
1102
- fetchFlight(window.location.pathname + window.location.search),
1164
+ flightNavigationKey(window.location.pathname, window.location.search, from),
1165
+ fetchFlight(window.location.pathname + window.location.search, flightFetchOptions(from)),
1166
+ ordinaryKey,
1103
1167
  );
1104
1168
  if (fetched.kind === "document") {
1105
1169
  window.location.reload();
1106
1170
  return;
1107
1171
  }
1108
1172
  const payload = fetched.root;
1109
- await payload;
1173
+ const nextRoot = await payload;
1110
1174
  // No view transition: a refresh is the same URL rendered again. See
1111
1175
  // `ModuleRouter`'s refresh.
1112
1176
  startTransition(() => {
1113
- setCurrent(payload);
1177
+ show(payload, nextRoot);
1114
1178
  });
1115
1179
  },
1116
1180
  back: () => {
@@ -1157,13 +1221,20 @@ const prefetchedFlights: Map<
1157
1221
  * request that failed, an answer that was a document rather than a payload, or
1158
1222
  * a payload React could not read.
1159
1223
  */
1160
- function keepFlight(key: string, fetched: Promise<FetchedFlight>): Promise<FetchedFlight> {
1224
+ function keepFlight(
1225
+ key: string,
1226
+ fetched: Promise<FetchedFlight>,
1227
+ ordinaryKey?: string,
1228
+ ): Promise<FetchedFlight> {
1161
1229
  if (!keepsNavigations()) {
1162
1230
  return fetched;
1163
1231
  }
1164
1232
  flightNavigations.store(key, fetched);
1165
1233
  const forget = () => {
1166
1234
  flightNavigations.forget(key, fetched);
1235
+ if (ordinaryKey != null) {
1236
+ flightNavigations.forget(ordinaryKey, fetched);
1237
+ }
1167
1238
  };
1168
1239
  void fetched.then((answer) => {
1169
1240
  if (answer.kind === "document") {
@@ -1175,6 +1246,10 @@ function keepFlight(key: string, fetched: Promise<FetchedFlight>): Promise<Fetch
1175
1246
  void answer.root.then((root) => {
1176
1247
  if (root.route.status !== 200) {
1177
1248
  forget();
1249
+ return;
1250
+ }
1251
+ if (ordinaryKey != null && root.route.interception == null) {
1252
+ flightNavigations.store(ordinaryKey, fetched);
1178
1253
  }
1179
1254
  }, forget);
1180
1255
  }, forget);
@@ -1202,8 +1277,13 @@ function keepRoute(key: string, resolved: Promise<ResolvedRoute>): Promise<Resol
1202
1277
  return resolved;
1203
1278
  }
1204
1279
 
1205
- function prefetchFlight(url: string): Promise<FetchedFlight> {
1206
- const existing = prefetchedFlights.get(url);
1280
+ function prefetchedFlightKey(url: string, from: ?string): string {
1281
+ return from == null ? url : `${url}\0${from}`;
1282
+ }
1283
+
1284
+ function prefetchFlight(url: string, from: ?string): Promise<FetchedFlight> {
1285
+ const key = prefetchedFlightKey(url, from);
1286
+ const existing = prefetchedFlights.get(key);
1207
1287
  if (existing != null && Date.now() - existing.at < PREFETCH_LIFETIME_MS) {
1208
1288
  return existing.fetched;
1209
1289
  }
@@ -1213,17 +1293,18 @@ function prefetchFlight(url: string): Promise<FetchedFlight> {
1213
1293
  prefetchedFlights.delete(oldest.value);
1214
1294
  }
1215
1295
  }
1216
- const fetched = fetchFlight(url);
1217
- prefetchedFlights.set(url, { fetched, at: Date.now() });
1296
+ const fetched = fetchFlight(url, flightFetchOptions(from));
1297
+ prefetchedFlights.set(key, { fetched, at: Date.now() });
1218
1298
  fetched.catch(() => {
1219
- prefetchedFlights.delete(url);
1299
+ prefetchedFlights.delete(key);
1220
1300
  });
1221
1301
  return fetched;
1222
1302
  }
1223
1303
 
1224
- function takePrefetched(url: string): Promise<FetchedFlight> | null {
1225
- const entry = prefetchedFlights.get(url);
1226
- prefetchedFlights.delete(url);
1304
+ function takePrefetched(url: string, from: ?string): Promise<FetchedFlight> | null {
1305
+ const key = prefetchedFlightKey(url, from);
1306
+ const entry = prefetchedFlights.get(key);
1307
+ prefetchedFlights.delete(key);
1227
1308
  if (entry == null || Date.now() - entry.at >= PREFETCH_LIFETIME_MS) {
1228
1309
  return null;
1229
1310
  }
@@ -1639,6 +1720,13 @@ export hook useSeo(seo: Metadata): React.Node {
1639
1720
  /** When a `Link` loads the route it points at. */
1640
1721
  export type LinkPrefetch = "off" | "intent" | "render";
1641
1722
 
1723
+ const LinkStatusContext: React.Context<boolean> = createContext(false);
1724
+
1725
+ /** Whether the containing Link is waiting for the navigation it started. */
1726
+ export hook useLinkStatus(): {| readonly pending: boolean |} {
1727
+ return { pending: useContext(LinkStatusContext) };
1728
+ }
1729
+
1642
1730
  /**
1643
1731
  * A client-side navigation.
1644
1732
  *
@@ -1675,7 +1763,8 @@ export component Link(
1675
1763
  ...rest: { readonly [string]: mixed }
1676
1764
  ) {
1677
1765
  const { router, navigation } = useRouterState();
1678
- const prefetched = React.useRef(false);
1766
+ const [linkPending, startLinkTransition] = useTransition();
1767
+ const prefetched = useRef(false);
1679
1768
  const drives = navigation === "client";
1680
1769
 
1681
1770
  const doPrefetch = () => {
@@ -1703,15 +1792,21 @@ export component Link(
1703
1792
  event.ctrlKey ||
1704
1793
  event.shiftKey ||
1705
1794
  event.altKey ||
1795
+ (rest.target != null && rest.target !== "_self") ||
1796
+ rest.download != null ||
1706
1797
  isExternal(to)
1707
1798
  ) {
1708
1799
  return;
1709
1800
  }
1710
1801
  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));
1802
+ startLinkTransition(async () => {
1803
+ try {
1804
+ await router.push(to, { replace, transition });
1805
+ } catch (error) {
1806
+ // A failed navigation falls back to the browser doing it.
1807
+ console.error(error);
1808
+ window.location.assign(addressOf(to));
1809
+ }
1715
1810
  });
1716
1811
  };
1717
1812
 
@@ -1727,11 +1822,13 @@ export component Link(
1727
1822
  // works before hydration and for a right click goes where this one does.
1728
1823
  href={addressOf(to)}
1729
1824
  className={className}
1825
+ aria-busy={linkPending || undefined}
1826
+ data-pending={linkPending ? "" : undefined}
1730
1827
  onClick={drives ? handleClick : onClick}
1731
1828
  onMouseEnter={drives && prefetch === "intent" ? doPrefetch : undefined}
1732
1829
  onFocus={drives && prefetch === "intent" ? doPrefetch : undefined}
1733
1830
  >
1734
- {children}
1831
+ <LinkStatusContext value={linkPending}>{children}</LinkStatusContext>
1735
1832
  </a>
1736
1833
  );
1737
1834
  }
@@ -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
  }
@@ -0,0 +1,217 @@
1
+ // @flow
2
+ import * as React from "react";
3
+ import { Pressable } from "react-native";
4
+ import {
5
+ CommonActions,
6
+ NavigationContainer,
7
+ StackActions,
8
+ createNavigationContainerRef,
9
+ useRoute,
10
+ } from "@react-navigation/native";
11
+ import { resolveNativeNavigation, createNativeRouter, createNativeLinking } from "./native.js";
12
+ import type { NativeLinkSource } from "./native.js";
13
+ import type { RouteTable } from "./internal/routing.js";
14
+ import type {
15
+ NativeLayout,
16
+ NativeNode,
17
+ NativeTree,
18
+ NavigationPayload,
19
+ NavigationState,
20
+ } from "./internal/native-tree.js";
21
+ import { nativeTree, stateForPath, hrefFromState, paramsForPath } from "./internal/native-tree.js";
22
+
23
+ export interface NavigatorPair {
24
+ readonly Navigator: React.ComponentType<{ ... }>;
25
+ readonly Screen: React.ComponentType<{ ... }>;
26
+ }
27
+ export type FileRouter = {
28
+ readonly push: (href: string) => void,
29
+ readonly replace: (href: string) => void,
30
+ readonly back: () => void,
31
+ readonly prefetch: (href: string) => Promise<void>,
32
+ };
33
+ type Scope = {
34
+ tree: NativeTree,
35
+ node: NativeNode,
36
+ screens: Map<NativeNode, React.ComponentType<{ ... }>>,
37
+ stack: NavigatorPair,
38
+ tabs: ?NavigatorPair,
39
+ router: FileRouter,
40
+ };
41
+ const ScopeContext: React.Context<Scope | null> = React.createContext(null);
42
+
43
+ function useScope(): Scope {
44
+ const scope = React.useContext(ScopeContext);
45
+ if (scope == null)
46
+ throw new Error(
47
+ "Native navigation: mount createNativeNavigation().Root before using its navigator or hooks",
48
+ );
49
+ return scope;
50
+ }
51
+
52
+ /** The application supplies its own React Navigation navigator factories. */
53
+ export function createNativeNavigation(options: {
54
+ table: RouteTable<>,
55
+ layouts: $ReadOnlyArray<NativeLayout>,
56
+ stack: NavigatorPair,
57
+ tabs?: NavigatorPair,
58
+ initialHref?: string,
59
+ links?: NativeLinkSource,
60
+ }): { Root: React.ComponentType<{ ... }>, router: FileRouter, linking: { ... } } {
61
+ const { table, layouts, stack, tabs } = options;
62
+ const tree = nativeTree(table, layouts);
63
+ const ref = createNavigationContainerRef();
64
+ const screens = new Map<NativeNode, React.ComponentType<{ ... }>>();
65
+ const resolve = (href: string): { names: $ReadOnlyArray<string>, payload: NavigationPayload } => {
66
+ const event = resolveNativeNavigation(table, href);
67
+ const names = tree.paths.get(event.route);
68
+ if (names == null) throw new Error(`Native navigation: no screen for ${event.route}`);
69
+ return { names, payload: { ufHref: event.href, ufParams: event.params } };
70
+ };
71
+ function actionFor(
72
+ names: $ReadOnlyArray<string>,
73
+ payload: NavigationPayload,
74
+ kind: "push" | "replace",
75
+ ): { readonly [string]: mixed } {
76
+ if (!ref.isReady()) throw new Error("Native navigation: NavigationContainer is not ready");
77
+ let state: NavigationState = ref.getRootState();
78
+ let depth = 0;
79
+ while (depth < names.length - 1) {
80
+ const active = state.routes[state.index ?? 0];
81
+ if (active?.name !== names[depth] || active?.state == null) break;
82
+ state = active.state;
83
+ depth += 1;
84
+ }
85
+ const name = names[depth];
86
+ const params = paramsForPath(names.slice(depth + 1), payload);
87
+ const action =
88
+ state.type === "stack"
89
+ ? StackActions[kind](name, params)
90
+ : CommonActions.navigate({ name, params });
91
+ return { ...action, target: state.key };
92
+ }
93
+ function navigate(href: string, kind: "push" | "replace"): void {
94
+ const { names, payload } = resolve(href);
95
+ ref.dispatch(actionFor(names, payload, kind));
96
+ }
97
+ const router: FileRouter = {
98
+ push: (href) => navigate(href, "push"),
99
+ replace: (href) => navigate(href, "replace"),
100
+ back: () => {
101
+ if (ref.canGoBack()) ref.goBack();
102
+ },
103
+ prefetch: createNativeRouter(table, {}).prefetch,
104
+ };
105
+ const source = options.links == null ? null : createNativeLinking(table, options.links);
106
+ const linking = {
107
+ prefixes: [""],
108
+ getInitialURL: source?.getInitialURL ?? (async () => null),
109
+ subscribe: source?.subscribe ?? (() => () => {}),
110
+ getStateFromPath: (href: string) => {
111
+ const { names, payload } = resolve(
112
+ href.startsWith("/") || /^[A-Za-z][A-Za-z0-9+.-]*:/.test(href) ? href : `/${href}`,
113
+ );
114
+ return stateForPath(names, payload);
115
+ },
116
+ getPathFromState: (state: NavigationState) => hrefFromState(state) ?? "/",
117
+ getActionFromState: (state: NavigationState) => {
118
+ const href = hrefFromState(state);
119
+ if (href == null) return undefined;
120
+ const { names, payload } = resolve(href);
121
+ return actionFor(names, payload, "push");
122
+ },
123
+ };
124
+ function build(node: NativeNode): React.ComponentType<{ ... }> {
125
+ const load = node.module;
126
+ const Content =
127
+ load == null
128
+ ? Stack
129
+ : React.lazy(async () => {
130
+ const module: $FlowFixMe = await load();
131
+ const Component = module.default ?? (node.layout ? module.Layout : module.Page);
132
+ if (
133
+ typeof Component !== "function" &&
134
+ (Component == null || typeof Component !== "object")
135
+ ) {
136
+ throw new Error(
137
+ `Native navigation: ${node.name} must export ${node.layout ? "Layout" : "Page"} or default`,
138
+ );
139
+ }
140
+ return { default: Component };
141
+ });
142
+ function Screen(props: { ... }): React.Node {
143
+ return (
144
+ <ScopeContext value={{ tree, node, screens, stack, tabs, router }}>
145
+ <React.Suspense fallback={null}>
146
+ <Content {...props} />
147
+ </React.Suspense>
148
+ </ScopeContext>
149
+ );
150
+ }
151
+ screens.set(node, Screen);
152
+ for (const child of node.children) build(child);
153
+ return Screen;
154
+ }
155
+ const RootScreen = build(tree.root);
156
+ const initialState =
157
+ options.initialHref == null ? undefined : linking.getStateFromPath(options.initialHref);
158
+ function Root(props: { ... }): React.Node {
159
+ return (
160
+ <NavigationContainer {...props} ref={ref} linking={linking} initialState={initialState}>
161
+ <RootScreen />
162
+ </NavigationContainer>
163
+ );
164
+ }
165
+ return { Root, router, linking };
166
+ }
167
+
168
+ /** Any React Navigation navigator can consume this segment's direct children. */
169
+ export component Navigator(navigator: NavigatorPair, ...props: { ... }) {
170
+ const scope = useScope();
171
+ const Container = navigator.Navigator;
172
+ const Screen = navigator.Screen;
173
+ return (
174
+ <Container {...props}>
175
+ {scope.node.children.map((node) => (
176
+ <Screen key={node.name} name={node.name} component={scope.screens.get(node)} />
177
+ ))}
178
+ </Container>
179
+ );
180
+ }
181
+
182
+ export component Stack(...props: { ... }) {
183
+ return <Navigator {...props} navigator={useScope().stack} />;
184
+ }
185
+
186
+ export component Tabs(...props: { ... }) {
187
+ const tabs = useScope().tabs;
188
+ if (tabs == null)
189
+ throw new Error("Native navigation: supply the app's tab navigator to createNativeNavigation");
190
+ return <Navigator {...props} navigator={tabs} />;
191
+ }
192
+
193
+ export function useNativeRouter(): FileRouter {
194
+ return useScope().router;
195
+ }
196
+ export function useParams(): { readonly [string]: string | $ReadOnlyArray<string> } {
197
+ const route = useRoute();
198
+ return route.params?.ufParams ?? {};
199
+ }
200
+
201
+ export component Link(
202
+ href: string,
203
+ children: React.Node,
204
+ replace: boolean = false,
205
+ ...props: { ... }
206
+ ) {
207
+ const router = useNativeRouter();
208
+ return (
209
+ <Pressable
210
+ {...props}
211
+ accessibilityRole="link"
212
+ onPress={() => (replace ? router.replace(href) : router.push(href))}
213
+ >
214
+ {children}
215
+ </Pressable>
216
+ );
217
+ }