@uniflowed/router 0.0.0-alpha.35 → 0.0.0-alpha.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/action.js +6 -0
- package/client.js +29 -105
- package/index.js +1 -0
- package/internal/base-path.js +175 -0
- package/internal/compose.js +12 -0
- package/internal/error-view.js +4 -0
- package/internal/flight-browser.js +17 -19
- package/internal/flight-chunks.js +32 -8
- package/internal/flight-ssr.js +6 -0
- package/internal/flight.js +26 -0
- package/internal/navigation-cache.js +144 -0
- package/internal/prepare-document.js +49 -0
- package/internal/react-version.js +77 -0
- package/internal/runtime.js +208 -26
- package/internal/server-route.js +6 -0
- package/internal/shell.js +115 -0
- package/internal/stream.js +111 -21
- package/middleware.js +144 -14
- package/package.json +12 -5
- package/rsc-client.js +120 -0
- package/rsc-ssr.js +440 -0
- package/rsc.js +11 -0
- package/server-components.js +4 -0
- package/server.js +20 -484
package/internal/runtime.js
CHANGED
|
@@ -74,9 +74,16 @@ import {
|
|
|
74
74
|
import { BoundaryReporter } from "./boundaries.js";
|
|
75
75
|
import { routeBoundaries } from "./boundary-data.js";
|
|
76
76
|
import { composeRoute, pageComponent } from "./compose.js";
|
|
77
|
-
import { type FetchedFlight,
|
|
78
|
-
import { type FlightRoot, type RouteState, routeState } from "./flight.js";
|
|
77
|
+
import { type FetchedFlight, type FlightRoot, type RouteState, routeState } from "./flight.js";
|
|
79
78
|
import { Head } from "./head.js";
|
|
79
|
+
import { addressOf, applicationPathOf, canonicalAddress } from "./base-path.js";
|
|
80
|
+
import {
|
|
81
|
+
clearNavigationCache,
|
|
82
|
+
flightNavigations,
|
|
83
|
+
keepsNavigations,
|
|
84
|
+
navigationKey,
|
|
85
|
+
routeNavigations,
|
|
86
|
+
} from "./navigation-cache.js";
|
|
80
87
|
import { hasClientPage, matchRoute, nearestBoundary } from "./routing.js";
|
|
81
88
|
import type { RouteParams, SearchParams } from "./routing.js";
|
|
82
89
|
import {
|
|
@@ -137,6 +144,15 @@ export type {
|
|
|
137
144
|
|
|
138
145
|
export { resolveFailure, resolveMatch } from "./resolve.js";
|
|
139
146
|
|
|
147
|
+
// `app.router.basePath` and `trailingSlash`, installed by the entry that starts
|
|
148
|
+
// the application; see `./base-path.js`.
|
|
149
|
+
export type { RoutingSettings, TrailingSlash } from "./base-path.js";
|
|
150
|
+
export { basePath, installRouting } from "./base-path.js";
|
|
151
|
+
|
|
152
|
+
// `app.rendering.staleTime`, installed by the same entry; see
|
|
153
|
+
// `./navigation-cache.js`.
|
|
154
|
+
export { installStaleTime } from "./navigation-cache.js";
|
|
155
|
+
|
|
140
156
|
// ---------------------------------------------------------------------------
|
|
141
157
|
// View transitions
|
|
142
158
|
// ---------------------------------------------------------------------------
|
|
@@ -397,6 +413,40 @@ export function navigationMode(): Navigation {
|
|
|
397
413
|
return installedNavigation;
|
|
398
414
|
}
|
|
399
415
|
|
|
416
|
+
/**
|
|
417
|
+
* How a page that React Server Components rendered fetches the next route's
|
|
418
|
+
* payload. `hydrateFlight` in `../rsc-client.js` installs it.
|
|
419
|
+
*
|
|
420
|
+
* Handed in rather than imported, because this module is in every
|
|
421
|
+
* application's bundle: one rendered from its modules, a single-page one, and
|
|
422
|
+
* the server's. The fetch reads its answer with React's Flight client,
|
|
423
|
+
* `react-server-dom-parcel`, which only an application that renders Server
|
|
424
|
+
* Components installs, and which needs React 19.3 while the rest of the router
|
|
425
|
+
* runs on 19.2.3 (ubugeeei-prod/uf#992). A bundler resolves every import it is
|
|
426
|
+
* shown, whether or not anything calls it, so an import here would put that
|
|
427
|
+
* package in every one of those bundles, or fail the build where it is absent.
|
|
428
|
+
*/
|
|
429
|
+
let installedFlightFetch: ((url: string) => Promise<FetchedFlight>) | null = null;
|
|
430
|
+
|
|
431
|
+
/** Hand the router the payload fetch. Called once, by `hydrateFlight`, before the first render. */
|
|
432
|
+
export function installFlightFetch(fetcher: (url: string) => Promise<FetchedFlight>): void {
|
|
433
|
+
installedFlightFetch = fetcher;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** The next route's payload, through the fetch `hydrateFlight` installed. */
|
|
437
|
+
function fetchFlight(url: string): Promise<FetchedFlight> {
|
|
438
|
+
if (installedFlightFetch == null) {
|
|
439
|
+
return Promise.reject(
|
|
440
|
+
new Error(
|
|
441
|
+
"@uniflowed/router: a page rendered from a Flight payload navigated before anything " +
|
|
442
|
+
"installed the payload fetch. `hydrateFlight` from `@uniflowed/router/rsc/client` " +
|
|
443
|
+
"installs it before it hydrates, so an entry that hydrates a payload has to call that.",
|
|
444
|
+
),
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
return installedFlightFetch(url);
|
|
448
|
+
}
|
|
449
|
+
|
|
400
450
|
/** Register the generated route table. Called once by the client and server entries. */
|
|
401
451
|
export function installRoutes(table: RouteTable): void {
|
|
402
452
|
installedTable = table;
|
|
@@ -585,8 +635,12 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
|
|
|
585
635
|
if (!isBrowser()) {
|
|
586
636
|
return;
|
|
587
637
|
}
|
|
588
|
-
const target = new URL(to, window.location.href);
|
|
589
|
-
|
|
638
|
+
const target = new URL(addressOf(to), window.location.href);
|
|
639
|
+
// The application path the route table is asked about, and the address the
|
|
640
|
+
// history entry keeps: one URL, with and without `app.router.basePath`.
|
|
641
|
+
const applicationPath = applicationPathOf(target.pathname);
|
|
642
|
+
const next = (applicationPath ?? target.pathname) + target.search;
|
|
643
|
+
const address = target.pathname + target.search;
|
|
590
644
|
// The browser's job in this application. `assign` and `replace` rather
|
|
591
645
|
// than the history API, because the point is a document request: the
|
|
592
646
|
// history entry, the scroll position, the `Referer` and the unload
|
|
@@ -605,8 +659,13 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
|
|
|
605
659
|
// screen that intercepts the URL renders a page of its own, so whether the
|
|
606
660
|
// URL's ordinary page is in this bundle — the paragraph below — is not a
|
|
607
661
|
// question this navigation has to ask.
|
|
662
|
+
// An address outside the base path is not this application's to render.
|
|
663
|
+
if (applicationPath == null) {
|
|
664
|
+
window.location.assign(target.href);
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
608
667
|
const origin = beneath(shown.current);
|
|
609
|
-
const intercepting = interceptingRoutes(origin.slots,
|
|
668
|
+
const intercepting = interceptingRoutes(origin.slots, applicationPath).length > 0;
|
|
610
669
|
// The half of the split that is not about bytes. A route whose page is not
|
|
611
670
|
// in this bundle is not a route this router can render, and pretending
|
|
612
671
|
// otherwise is the silent break: the navigation would resolve to nothing
|
|
@@ -615,7 +674,7 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
|
|
|
615
674
|
// link does when there is no JavaScript at all, and what the anchor
|
|
616
675
|
// `Link` renders would have done on its own.
|
|
617
676
|
if (!intercepting) {
|
|
618
|
-
const matched = matchRoute(routeTable().routes,
|
|
677
|
+
const matched = matchRoute(routeTable().routes, applicationPath);
|
|
619
678
|
if (matched != null && !hasClientPage(matched.route)) {
|
|
620
679
|
window.location.assign(target.href);
|
|
621
680
|
return;
|
|
@@ -623,17 +682,21 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
|
|
|
623
682
|
}
|
|
624
683
|
setPending(true);
|
|
625
684
|
try {
|
|
685
|
+
// An interception depends on the page it starts from, so it is resolved
|
|
686
|
+
// every time; any other navigation reads what this page kept while it is
|
|
687
|
+
// fresh. See `./navigation-cache.js`.
|
|
688
|
+
const key = navigationKey(target.pathname, target.search);
|
|
626
689
|
const nextResolved =
|
|
627
690
|
(intercepting ? await resolveInterception(routeTable(), origin, next) : null) ??
|
|
628
|
-
(await resolveMatch(routeTable(), next));
|
|
691
|
+
(await (routeNavigations.read(key) ?? keepRoute(key, resolveMatch(routeTable(), next))));
|
|
629
692
|
// An intercepted entry remembers where it was intercepted from, so back
|
|
630
693
|
// and forward can put the page underneath under it again. Every other
|
|
631
694
|
// entry is written the way it always was.
|
|
632
695
|
const state = historyStateFor(nextResolved);
|
|
633
696
|
if (options?.replace === true) {
|
|
634
|
-
window.history.replaceState(state, "",
|
|
697
|
+
window.history.replaceState(state, "", address + target.hash);
|
|
635
698
|
} else {
|
|
636
|
-
window.history.pushState(state, "",
|
|
699
|
+
window.history.pushState(state, "", address + target.hash);
|
|
637
700
|
}
|
|
638
701
|
const commit = () => {
|
|
639
702
|
show(nextResolved);
|
|
@@ -698,7 +761,9 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
|
|
|
698
761
|
});
|
|
699
762
|
};
|
|
700
763
|
const onPopState = () => {
|
|
701
|
-
const next =
|
|
764
|
+
const next =
|
|
765
|
+
(applicationPathOf(window.location.pathname) ?? window.location.pathname) +
|
|
766
|
+
window.location.search;
|
|
702
767
|
// Back or forward into an entry an interception wrote: the page it was
|
|
703
768
|
// intercepted from, with the interception over it again. That page is
|
|
704
769
|
// resolved afresh only when it is not already the one underneath, so
|
|
@@ -723,12 +788,16 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
|
|
|
723
788
|
// Back into a route this bundle has no page for. The history entry is
|
|
724
789
|
// already the browser's — it moved before this listener ran — so the
|
|
725
790
|
// document that belongs to it is what has to be fetched.
|
|
726
|
-
const matched = matchRoute(
|
|
791
|
+
const matched = matchRoute(
|
|
792
|
+
routeTable().routes,
|
|
793
|
+
applicationPathOf(window.location.pathname) ?? window.location.pathname,
|
|
794
|
+
);
|
|
727
795
|
if (matched != null && !hasClientPage(matched.route)) {
|
|
728
796
|
window.location.reload();
|
|
729
797
|
return;
|
|
730
798
|
}
|
|
731
|
-
|
|
799
|
+
const key = navigationKey(window.location.pathname, window.location.search);
|
|
800
|
+
(routeNavigations.read(key) ?? keepRoute(key, resolveMatch(routeTable(), next))).then(arrive);
|
|
732
801
|
};
|
|
733
802
|
window.addEventListener("popstate", onPopState);
|
|
734
803
|
return () => {
|
|
@@ -748,11 +817,15 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
|
|
|
748
817
|
if (!isBrowser() || navigation === "document") {
|
|
749
818
|
return;
|
|
750
819
|
}
|
|
751
|
-
const target = new URL(to, window.location.href);
|
|
820
|
+
const target = new URL(addressOf(to), window.location.href);
|
|
821
|
+
const applicationPath = applicationPathOf(target.pathname);
|
|
822
|
+
if (applicationPath == null) {
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
752
825
|
// What the next render will need is decided the way the navigation will
|
|
753
826
|
// decide it: a URL a slot on screen intercepts renders that slot's page,
|
|
754
827
|
// so that is the module worth having, and the page the URL names is not.
|
|
755
|
-
const intercepting = interceptingRoutes(beneath(shown.current).slots,
|
|
828
|
+
const intercepting = interceptingRoutes(beneath(shown.current).slots, applicationPath);
|
|
756
829
|
if (intercepting.length > 0) {
|
|
757
830
|
await Promise.all(
|
|
758
831
|
intercepting.flatMap((route) => [
|
|
@@ -762,11 +835,22 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
|
|
|
762
835
|
);
|
|
763
836
|
return;
|
|
764
837
|
}
|
|
765
|
-
const matched = matchRoute(routeTable().routes,
|
|
838
|
+
const matched = matchRoute(routeTable().routes, applicationPath);
|
|
766
839
|
const load = matched?.route.page;
|
|
767
840
|
if (matched == null || load == null) {
|
|
768
841
|
return;
|
|
769
842
|
}
|
|
843
|
+
// With `app.rendering.staleTime` set, the whole route: its loader runs
|
|
844
|
+
// now, and the click, a later visit and the back button read what it
|
|
845
|
+
// answered while it is fresh. Otherwise only the modules it will need.
|
|
846
|
+
if (keepsNavigations()) {
|
|
847
|
+
const key = navigationKey(target.pathname, target.search);
|
|
848
|
+
await (
|
|
849
|
+
routeNavigations.read(key) ??
|
|
850
|
+
keepRoute(key, resolveMatch(routeTable(), applicationPath + target.search))
|
|
851
|
+
);
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
770
854
|
await Promise.all([
|
|
771
855
|
loadOnce(load),
|
|
772
856
|
...matched.route.layouts.map((layout) => loadOnce(layout)),
|
|
@@ -784,6 +868,9 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
|
|
|
784
868
|
window.location.reload();
|
|
785
869
|
return;
|
|
786
870
|
}
|
|
871
|
+
// Everything a navigation kept is older than what this asks for, so none
|
|
872
|
+
// of it is shown again; see `./navigation-cache.js`.
|
|
873
|
+
clearNavigationCache();
|
|
787
874
|
// A refresh of an intercepted page refreshes both of its halves: the
|
|
788
875
|
// page underneath, resolved again for its own URL, and the interception
|
|
789
876
|
// resolved again over it. Resolving only the address bar's URL would
|
|
@@ -791,7 +878,11 @@ component ModuleRouter(url: string, initial: ResolvedRoute, children: React.Node
|
|
|
791
878
|
const interception = shown.current.interception;
|
|
792
879
|
const nextResolved =
|
|
793
880
|
interception == null
|
|
794
|
-
? await resolveMatch(
|
|
881
|
+
? await resolveMatch(
|
|
882
|
+
routeTable(),
|
|
883
|
+
(applicationPathOf(window.location.pathname) ?? window.location.pathname) +
|
|
884
|
+
window.location.search,
|
|
885
|
+
)
|
|
795
886
|
: ((await resolveInterception(
|
|
796
887
|
routeTable(),
|
|
797
888
|
await resolveMatch(
|
|
@@ -859,7 +950,9 @@ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
|
|
|
859
950
|
if (!isBrowser()) {
|
|
860
951
|
return;
|
|
861
952
|
}
|
|
862
|
-
|
|
953
|
+
// A payload URL is an address, so it keeps the base path; the server takes
|
|
954
|
+
// it off.
|
|
955
|
+
const target = new URL(addressOf(to), window.location.href);
|
|
863
956
|
const next = target.pathname + target.search;
|
|
864
957
|
// The browser's job in this application; `ModuleRouter` has the argument.
|
|
865
958
|
if (navigation === "document") {
|
|
@@ -872,7 +965,14 @@ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
|
|
|
872
965
|
}
|
|
873
966
|
setPending(true);
|
|
874
967
|
try {
|
|
875
|
-
|
|
968
|
+
// The route this page already has while it is fresh, then a prefetch
|
|
969
|
+
// still in hand, then the network. See `./navigation-cache.js`.
|
|
970
|
+
const key = navigationKey(target.pathname, target.search);
|
|
971
|
+
const fetched = await (
|
|
972
|
+
flightNavigations.read(key) ??
|
|
973
|
+
takePrefetched(next) ??
|
|
974
|
+
keepFlight(key, fetchFlight(next))
|
|
975
|
+
);
|
|
876
976
|
// Not a payload: a redirect off this origin, or a host that has no payload
|
|
877
977
|
// for this URL. The browser loads it as a document, which is what the
|
|
878
978
|
// anchor would have done.
|
|
@@ -884,7 +984,11 @@ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
|
|
|
884
984
|
const nextRoot = await payload;
|
|
885
985
|
// The URL the payload came from, which is a redirect's target when the
|
|
886
986
|
// route redirected: the history entry is where the visitor ended up.
|
|
887
|
-
|
|
987
|
+
// In the trailing-slash policy's spelling: a payload URL names its
|
|
988
|
+
// document without the slash, and the history entry should be the
|
|
989
|
+
// address the server answers without a redirect.
|
|
990
|
+
const arrived = new URL(fetched.url, window.location.href);
|
|
991
|
+
const landed = canonicalAddress(arrived.pathname) + arrived.search + target.hash;
|
|
888
992
|
if (options?.replace === true) {
|
|
889
993
|
window.history.replaceState(null, "", landed);
|
|
890
994
|
} else {
|
|
@@ -927,8 +1031,10 @@ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
|
|
|
927
1031
|
const onPopState = () => {
|
|
928
1032
|
const next = window.location.pathname + window.location.search;
|
|
929
1033
|
// The history entry already moved; a payload that cannot be had for it is
|
|
930
|
-
// a document to load, and a reload is the browser's way to load it.
|
|
931
|
-
|
|
1034
|
+
// a document to load, and a reload is the browser's way to load it. While
|
|
1035
|
+
// this page keeps the route fresh, what it kept is what comes back.
|
|
1036
|
+
const key = navigationKey(window.location.pathname, window.location.search);
|
|
1037
|
+
(flightNavigations.read(key) ?? keepFlight(key, fetchFlight(next))).then(
|
|
932
1038
|
(fetched) => {
|
|
933
1039
|
if (fetched.kind === "document") {
|
|
934
1040
|
window.location.reload();
|
|
@@ -966,11 +1072,19 @@ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
|
|
|
966
1072
|
if (!isBrowser() || navigation === "document") {
|
|
967
1073
|
return;
|
|
968
1074
|
}
|
|
969
|
-
const target = new URL(to, window.location.href);
|
|
1075
|
+
const target = new URL(addressOf(to), window.location.href);
|
|
970
1076
|
if (target.origin !== window.location.origin) {
|
|
971
1077
|
return;
|
|
972
1078
|
}
|
|
973
|
-
|
|
1079
|
+
const next = target.pathname + target.search;
|
|
1080
|
+
// Kept for every navigation to it while it is fresh, when a project set
|
|
1081
|
+
// `app.rendering.staleTime`; otherwise held for the one click after it.
|
|
1082
|
+
if (keepsNavigations()) {
|
|
1083
|
+
const key = navigationKey(target.pathname, target.search);
|
|
1084
|
+
await (flightNavigations.read(key) ?? keepFlight(key, fetchFlight(next)));
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1087
|
+
await prefetchFlight(next);
|
|
974
1088
|
},
|
|
975
1089
|
refresh: async () => {
|
|
976
1090
|
if (!isBrowser()) {
|
|
@@ -980,7 +1094,13 @@ component FlightRouter(flight: Promise<FlightRoot>, children: React.Node) {
|
|
|
980
1094
|
window.location.reload();
|
|
981
1095
|
return;
|
|
982
1096
|
}
|
|
983
|
-
|
|
1097
|
+
// Everything a navigation kept is older than what this asks for, so none
|
|
1098
|
+
// of it is shown again; see `./navigation-cache.js`.
|
|
1099
|
+
clearNavigationCache();
|
|
1100
|
+
const fetched = await keepFlight(
|
|
1101
|
+
navigationKey(window.location.pathname, window.location.search),
|
|
1102
|
+
fetchFlight(window.location.pathname + window.location.search),
|
|
1103
|
+
);
|
|
984
1104
|
if (fetched.kind === "document") {
|
|
985
1105
|
window.location.reload();
|
|
986
1106
|
return;
|
|
@@ -1031,6 +1151,57 @@ const prefetchedFlights: Map<
|
|
|
1031
1151
|
{| readonly fetched: Promise<FetchedFlight>, readonly at: number |},
|
|
1032
1152
|
> = new Map();
|
|
1033
1153
|
|
|
1154
|
+
/**
|
|
1155
|
+
* `fetched`, kept under `key` for as long as `app.rendering.staleTime` says,
|
|
1156
|
+
* and forgotten again if it turns out not to be a route to show twice: a
|
|
1157
|
+
* request that failed, an answer that was a document rather than a payload, or
|
|
1158
|
+
* a payload React could not read.
|
|
1159
|
+
*/
|
|
1160
|
+
function keepFlight(key: string, fetched: Promise<FetchedFlight>): Promise<FetchedFlight> {
|
|
1161
|
+
if (!keepsNavigations()) {
|
|
1162
|
+
return fetched;
|
|
1163
|
+
}
|
|
1164
|
+
flightNavigations.store(key, fetched);
|
|
1165
|
+
const forget = () => {
|
|
1166
|
+
flightNavigations.forget(key, fetched);
|
|
1167
|
+
};
|
|
1168
|
+
void fetched.then((answer) => {
|
|
1169
|
+
if (answer.kind === "document") {
|
|
1170
|
+
forget();
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
// A route that answered with its error or not-found boundary is shown this
|
|
1174
|
+
// once: asked again, it may have recovered.
|
|
1175
|
+
void answer.root.then((root) => {
|
|
1176
|
+
if (root.route.status !== 200) {
|
|
1177
|
+
forget();
|
|
1178
|
+
}
|
|
1179
|
+
}, forget);
|
|
1180
|
+
}, forget);
|
|
1181
|
+
return fetched;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
/**
|
|
1185
|
+
* `resolved`, kept under `key` for as long as `app.rendering.staleTime` says,
|
|
1186
|
+
* and forgotten again if it did not resolve to a page: a loader that threw or
|
|
1187
|
+
* redirected, or a route that answered with its error or not-found boundary.
|
|
1188
|
+
*/
|
|
1189
|
+
function keepRoute(key: string, resolved: Promise<ResolvedRoute>): Promise<ResolvedRoute> {
|
|
1190
|
+
if (!keepsNavigations()) {
|
|
1191
|
+
return resolved;
|
|
1192
|
+
}
|
|
1193
|
+
routeNavigations.store(key, resolved);
|
|
1194
|
+
const forget = () => {
|
|
1195
|
+
routeNavigations.forget(key, resolved);
|
|
1196
|
+
};
|
|
1197
|
+
void resolved.then((route) => {
|
|
1198
|
+
if (route.status !== 200) {
|
|
1199
|
+
forget();
|
|
1200
|
+
}
|
|
1201
|
+
}, forget);
|
|
1202
|
+
return resolved;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1034
1205
|
function prefetchFlight(url: string): Promise<FetchedFlight> {
|
|
1035
1206
|
const existing = prefetchedFlights.get(url);
|
|
1036
1207
|
if (existing != null && Date.now() - existing.at < PREFETCH_LIFETIME_MS) {
|
|
@@ -1222,12 +1393,20 @@ component RenderedPage(data: mixed) {
|
|
|
1222
1393
|
}
|
|
1223
1394
|
const resolved = view.resolved;
|
|
1224
1395
|
const Page = pageComponent(resolved.page);
|
|
1396
|
+
// The route module's own export, looked up by route: `pageComponent` hands
|
|
1397
|
+
// back its `default` or `Page` as it is, so this is the same component on
|
|
1398
|
+
// every render of the same route. The React Compiler cannot see through the
|
|
1399
|
+
// lookup and reports a component created during render. The block form,
|
|
1400
|
+
// because the finding is on a JSX child and a `//` comment cannot stand
|
|
1401
|
+
// between JSX children without becoming text.
|
|
1402
|
+
// uf-lint-disable react-compiler/static-components
|
|
1225
1403
|
return (
|
|
1226
1404
|
<>
|
|
1227
1405
|
<Page params={resolved.params} searchParams={resolved.searchParams} data={data} />
|
|
1228
1406
|
{payloadElements(data)}
|
|
1229
1407
|
</>
|
|
1230
1408
|
);
|
|
1409
|
+
// uf-lint-enable react-compiler/static-components
|
|
1231
1410
|
}
|
|
1232
1411
|
|
|
1233
1412
|
/**
|
|
@@ -1532,7 +1711,7 @@ export component Link(
|
|
|
1532
1711
|
router.push(to, { replace, transition }).catch((error) => {
|
|
1533
1712
|
// A failed navigation falls back to the browser doing it.
|
|
1534
1713
|
console.error(error);
|
|
1535
|
-
window.location.assign(to);
|
|
1714
|
+
window.location.assign(addressOf(to));
|
|
1536
1715
|
});
|
|
1537
1716
|
};
|
|
1538
1717
|
|
|
@@ -1543,7 +1722,10 @@ export component Link(
|
|
|
1543
1722
|
return (
|
|
1544
1723
|
<a
|
|
1545
1724
|
{...rest}
|
|
1546
|
-
|
|
1725
|
+
// `to` is an application path; the anchor is the address, with the base
|
|
1726
|
+
// path in front and the trailing-slash policy's spelling, so a link that
|
|
1727
|
+
// works before hydration and for a right click goes where this one does.
|
|
1728
|
+
href={addressOf(to)}
|
|
1547
1729
|
className={className}
|
|
1548
1730
|
onClick={drives ? handleClick : onClick}
|
|
1549
1731
|
onMouseEnter={drives && prefetch === "intent" ? doPrefetch : undefined}
|
package/internal/server-route.js
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
24
24
|
|
|
25
25
|
import type { RouteState } from "./flight.js";
|
|
26
|
+
import { requireServerComponentsReact } from "./react-version.js";
|
|
26
27
|
|
|
27
28
|
const storage: AsyncLocalStorage<RouteState> = new AsyncLocalStorage();
|
|
28
29
|
|
|
@@ -38,8 +39,13 @@ export function withServerRoute<T>(route: RouteState, body: () => T): T {
|
|
|
38
39
|
* route is to call a router hook from a server module that is not being
|
|
39
40
|
* rendered by the router — a script, a route handler, a module evaluated at
|
|
40
41
|
* import time — and each of those is a mistake worth a sentence.
|
|
42
|
+
*
|
|
43
|
+
* The React version is checked first. On a React older than 19.3 the Flight
|
|
44
|
+
* renderer that would have put the hook inside a route refuses to start, so the
|
|
45
|
+
* sentence worth reading is that one, not "outside a route".
|
|
41
46
|
*/
|
|
42
47
|
export function serverRoute(caller: string): RouteState {
|
|
48
|
+
requireServerComponentsReact(`${caller}()`);
|
|
43
49
|
const route = storage.getStore();
|
|
44
50
|
if (route == null) {
|
|
45
51
|
throw new Error(
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Internal to `@uniflowed/router`: the documents an HTML renderer answers with
|
|
4
|
+
// that are not a route's own markup — uf's shell around that markup, and a
|
|
5
|
+
// redirect.
|
|
6
|
+
//
|
|
7
|
+
// `../server.js` renders a route from its modules, and `../rsc-ssr.js` renders
|
|
8
|
+
// one from the payload React Server Components wrote. They are two entries so
|
|
9
|
+
// that the second, which loads React's Flight client, stays out of every bundle
|
|
10
|
+
// that renders no Server Component (ubugeeei-prod/uf#992). Both write the same
|
|
11
|
+
// documents around what they render, which is why those documents live here and
|
|
12
|
+
// not in either entry.
|
|
13
|
+
|
|
14
|
+
import type { PrerenderResult, RenderAssets, RenderResult } from "../server.js";
|
|
15
|
+
import { addressOf } from "./base-path.js";
|
|
16
|
+
import { ROOT_ID } from "./document.js";
|
|
17
|
+
import type { RedirectError } from "./routing.js";
|
|
18
|
+
import { type DocumentShell, bodyOfText } from "./stream.js";
|
|
19
|
+
|
|
20
|
+
/** A redirect, as the finished document `prerender` answers with. */
|
|
21
|
+
export async function redirectResult(document: RenderResult): Promise<PrerenderResult> {
|
|
22
|
+
return {
|
|
23
|
+
status: document.status,
|
|
24
|
+
headers: document.headers,
|
|
25
|
+
html: await document.text(),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function redirectDocument(error: RedirectError): RenderResult {
|
|
30
|
+
// Under `app.router.basePath`, and in the trailing-slash policy's spelling:
|
|
31
|
+
// `redirect("/sign-in")` names an application path, and a browser follows
|
|
32
|
+
// an address.
|
|
33
|
+
const address = addressOf(error.to);
|
|
34
|
+
const target = escapeAttribute(address);
|
|
35
|
+
// A document rather than an empty body, because a redirect is still an answer
|
|
36
|
+
// a browser may be shown; it goes through the same three methods as a
|
|
37
|
+
// rendered one so that a host has one shape to write, not two.
|
|
38
|
+
const body = bodyOfText(
|
|
39
|
+
`<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="refresh" content="0; url=${target}"><title>Redirecting</title></head><body><a href="${target}">Redirecting…</a></body></html>\n`,
|
|
40
|
+
);
|
|
41
|
+
return {
|
|
42
|
+
status: error.permanent ? 308 : 307,
|
|
43
|
+
headers: { Location: address },
|
|
44
|
+
pipe: body.pipe,
|
|
45
|
+
stream: body.stream,
|
|
46
|
+
text: body.text,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The document uf writes around the app's markup.
|
|
52
|
+
*
|
|
53
|
+
* The same two shapes `assemble` chose between, decided from the same evidence
|
|
54
|
+
* — whether the markup opens with `<html>` — but stated up front instead of
|
|
55
|
+
* afterwards, because a stream has no "afterwards" in which to splice a head.
|
|
56
|
+
* An app whose root layout renders `<html>` owns the whole document and the
|
|
57
|
+
* client hydrates `document`, so uf contributes only the tags that go before
|
|
58
|
+
* `</head>`. An app that renders only content is wrapped in a minimal shell
|
|
59
|
+
* around `<div id="uf-root">`, which is what the client hydrates instead.
|
|
60
|
+
*
|
|
61
|
+
* `internal/stream.js` picks between them on the opening bytes React writes;
|
|
62
|
+
* everything either shape is made of is here, so what a uf document contains is
|
|
63
|
+
* still readable in one place.
|
|
64
|
+
*
|
|
65
|
+
* # Why the shell is three strings and not one
|
|
66
|
+
*
|
|
67
|
+
* Because uf's own `<head>` has to still be open when React's head tags arrive.
|
|
68
|
+
* React hoists a `<title>`, a `<meta>` and a `<link>` into the head it wrote
|
|
69
|
+
* itself, and here it wrote none — so with one string this shell closed its
|
|
70
|
+
* head before the app had rendered a byte, and every `og:` tag and the
|
|
71
|
+
* `<link rel="canonical">` landed in the body, where a crawler ignores them.
|
|
72
|
+
* `open` is uf's head up to that point, `body` is the rest of it and the
|
|
73
|
+
* wrapper, and what goes between them is whatever `assembled` lifts out of the
|
|
74
|
+
* app's own markup. See ubugeeei-prod/uf#547.
|
|
75
|
+
*
|
|
76
|
+
* That is also why no `<title>` is written here any more. It was, from
|
|
77
|
+
* `resolved.metadata.title` — the same string `Head` renders — so a document
|
|
78
|
+
* carried two of them, one in each place, and only one was where a browser
|
|
79
|
+
* looks. Hoisting the rendered one leaves the metadata with a single source.
|
|
80
|
+
*/
|
|
81
|
+
export function shellFor(assets: RenderAssets, nonce?: string | null): DocumentShell {
|
|
82
|
+
const head = headTags(assets, nonce);
|
|
83
|
+
return {
|
|
84
|
+
head,
|
|
85
|
+
open: `<!doctype html>\n<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">`,
|
|
86
|
+
body: `${head}</head><body><div id="${ROOT_ID}">`,
|
|
87
|
+
close: `</div></body></html>\n`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function headTags(assets: RenderAssets, nonce?: string | null): string {
|
|
92
|
+
let tags = "";
|
|
93
|
+
for (const href of assets.styles) {
|
|
94
|
+
tags += `<link rel="stylesheet" href="${escapeAttribute(href)}">`;
|
|
95
|
+
}
|
|
96
|
+
for (const href of assets.preloads) {
|
|
97
|
+
tags += `<link rel="modulepreload" href="${escapeAttribute(href)}">`;
|
|
98
|
+
}
|
|
99
|
+
// The nonce goes on the client entry even though it is `src` rather than
|
|
100
|
+
// inline, because a policy of `script-src 'nonce-…'` admits *no* script
|
|
101
|
+
// without one — a nonce policy is not an inline policy with an exception in
|
|
102
|
+
// it. A project whose policy also names `'self'` pays nothing for the
|
|
103
|
+
// attribute being here, and one whose policy is `'strict-dynamic'` needs it:
|
|
104
|
+
// that is the directive under which this script is the root of trust every
|
|
105
|
+
// chunk it imports inherits from.
|
|
106
|
+
const carried = nonce == null ? "" : ` nonce="${escapeAttribute(nonce)}"`;
|
|
107
|
+
for (const src of assets.scripts) {
|
|
108
|
+
tags += `<script type="module" src="${escapeAttribute(src)}"${carried}></script>`;
|
|
109
|
+
}
|
|
110
|
+
return tags;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function escapeAttribute(value: string): string {
|
|
114
|
+
return value.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
115
|
+
}
|