@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.
- package/handler.js +42 -105
- package/http-client.js +104 -0
- package/index.js +1 -0
- package/instrumentation.js +92 -0
- package/internal/action-endpoint.js +7 -3
- 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 +11 -9
- package/internal/routing.js +4 -0
- package/internal/runtime.js +144 -41
- package/internal/server-instrumentation.js +12 -0
- package/internal/stream.js +24 -9
- package/native-navigation.js +217 -0
- package/native.js +10 -2
- package/package.json +19 -5
- package/rsc-ssr.js +10 -4
- package/rsc.js +70 -9
- package/server-components.js +1 -1
- package/server.js +18 -4
|
@@ -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();
|
package/internal/resolve.js
CHANGED
|
@@ -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
|
|
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
|
|
1050
|
+
* # Never for a document
|
|
1047
1051
|
*
|
|
1048
|
-
*
|
|
1049
|
-
*
|
|
1050
|
-
*
|
|
1051
|
-
*
|
|
1052
|
-
*
|
|
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
|
*
|
package/internal/routing.js
CHANGED
|
@@ -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
|
|};
|
package/internal/runtime.js
CHANGED
|
@@ -20,7 +20,9 @@
|
|
|
20
20
|
|
|
21
21
|
"use client";
|
|
22
22
|
|
|
23
|
-
import
|
|
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 {
|
|
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:
|
|
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(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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(
|
|
1041
|
+
window.history.replaceState(state, "", landed);
|
|
994
1042
|
} else {
|
|
995
|
-
window.history.pushState(
|
|
1043
|
+
window.history.pushState(state, "", landed);
|
|
996
1044
|
}
|
|
997
1045
|
const commit = () => {
|
|
998
|
-
|
|
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
|
-
|
|
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
|
|
1037
|
-
|
|
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
|
-
|
|
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
|
|
1084
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
1206
|
-
|
|
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(
|
|
1302
|
+
const fetched = fetchFlight(url, flightFetchOptions(from));
|
|
1303
|
+
prefetchedFlights.set(key, { fetched, at: Date.now() });
|
|
1218
1304
|
fetched.catch(() => {
|
|
1219
|
-
prefetchedFlights.delete(
|
|
1305
|
+
prefetchedFlights.delete(key);
|
|
1220
1306
|
});
|
|
1221
1307
|
return fetched;
|
|
1222
1308
|
}
|
|
1223
1309
|
|
|
1224
|
-
function takePrefetched(url: string): Promise<FetchedFlight> | null {
|
|
1225
|
-
const
|
|
1226
|
-
prefetchedFlights.
|
|
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
|
|
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
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
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
|
+
}
|
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
|
}
|