@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.
- package/handler.js +42 -105
- package/http-client.js +104 -0
- package/index.js +1 -0
- package/internal/action-endpoint.js +3 -2
- package/internal/flight-browser.js +12 -2
- package/internal/flight.js +25 -0
- package/internal/native-links.js +67 -0
- package/internal/native-tree.js +89 -0
- package/internal/navigation-cache.js +37 -0
- package/internal/resolve.js +6 -8
- package/internal/routing.js +4 -0
- package/internal/runtime.js +136 -39
- package/internal/stream.js +24 -9
- package/native-navigation.js +217 -0
- package/native.js +10 -2
- package/package.json +17 -5
- package/rsc-ssr.js +10 -4
- package/rsc.js +54 -7
- package/server-components.js +1 -1
- package/server.js +5 -3
package/internal/runtime.js
CHANGED
|
@@ -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 {
|
|
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:
|
|
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(
|
|
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 =
|
|
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
|
|
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(
|
|
1035
|
+
window.history.replaceState(state, "", landed);
|
|
994
1036
|
} else {
|
|
995
|
-
window.history.pushState(
|
|
1037
|
+
window.history.pushState(state, "", landed);
|
|
996
1038
|
}
|
|
997
1039
|
const commit = () => {
|
|
998
|
-
|
|
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
|
-
|
|
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
|
|
1037
|
-
|
|
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
|
-
|
|
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
|
|
1084
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
1206
|
-
|
|
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(
|
|
1296
|
+
const fetched = fetchFlight(url, flightFetchOptions(from));
|
|
1297
|
+
prefetchedFlights.set(key, { fetched, at: Date.now() });
|
|
1218
1298
|
fetched.catch(() => {
|
|
1219
|
-
prefetchedFlights.delete(
|
|
1299
|
+
prefetchedFlights.delete(key);
|
|
1220
1300
|
});
|
|
1221
1301
|
return fetched;
|
|
1222
1302
|
}
|
|
1223
1303
|
|
|
1224
|
-
function takePrefetched(url: string): Promise<FetchedFlight> | null {
|
|
1225
|
-
const
|
|
1226
|
-
prefetchedFlights.
|
|
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
|
|
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
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
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
|
}
|
package/internal/stream.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
1048
|
-
if (
|
|
1049
|
-
if (rawText
|
|
1050
|
-
|
|
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
|
+
}
|