@rsc-kit/core 0.1.0
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/README.md +95 -0
- package/SECURITY.md +60 -0
- package/dist/action.d.ts +90 -0
- package/dist/action.d.ts.map +1 -0
- package/dist/action.js +137 -0
- package/dist/action.js.map +1 -0
- package/dist/build-rsc-vite.d.ts +39 -0
- package/dist/build-rsc-vite.d.ts.map +1 -0
- package/dist/build-rsc-vite.js +172 -0
- package/dist/build-rsc-vite.js.map +1 -0
- package/dist/cache.d.ts +25 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +108 -0
- package/dist/cache.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +85 -0
- package/dist/cli.js.map +1 -0
- package/dist/compile.d.ts +55 -0
- package/dist/compile.d.ts.map +1 -0
- package/dist/compile.js +141 -0
- package/dist/compile.js.map +1 -0
- package/dist/devServer.d.ts +18 -0
- package/dist/devServer.d.ts.map +1 -0
- package/dist/devServer.js +70 -0
- package/dist/devServer.js.map +1 -0
- package/dist/devUrls.d.ts +11 -0
- package/dist/devUrls.d.ts.map +1 -0
- package/dist/devUrls.js +68 -0
- package/dist/devUrls.js.map +1 -0
- package/dist/embed.d.ts +21 -0
- package/dist/embed.d.ts.map +1 -0
- package/dist/embed.js +119 -0
- package/dist/embed.js.map +1 -0
- package/dist/errors.d.ts +24 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +38 -0
- package/dist/errors.js.map +1 -0
- package/dist/export.d.ts +41 -0
- package/dist/export.d.ts.map +1 -0
- package/dist/export.js +100 -0
- package/dist/export.js.map +1 -0
- package/dist/files.d.ts +39 -0
- package/dist/files.d.ts.map +1 -0
- package/dist/files.js +146 -0
- package/dist/files.js.map +1 -0
- package/dist/headers.d.ts +81 -0
- package/dist/headers.d.ts.map +1 -0
- package/dist/headers.js +96 -0
- package/dist/headers.js.map +1 -0
- package/dist/host.d.ts +86 -0
- package/dist/host.d.ts.map +1 -0
- package/dist/host.js +590 -0
- package/dist/host.js.map +1 -0
- package/dist/js/ActivityRouter.d.ts +47 -0
- package/dist/js/ActivityRouter.d.ts.map +1 -0
- package/dist/js/ActivityRouter.js +87 -0
- package/dist/js/ActivityRouter.js.map +1 -0
- package/dist/js/DocumentTitle.d.ts +22 -0
- package/dist/js/DocumentTitle.d.ts.map +1 -0
- package/dist/js/DocumentTitle.js +28 -0
- package/dist/js/DocumentTitle.js.map +1 -0
- package/dist/js/Form.d.ts +54 -0
- package/dist/js/Form.d.ts.map +1 -0
- package/dist/js/Form.js +225 -0
- package/dist/js/Form.js.map +1 -0
- package/dist/js/Link.d.ts +29 -0
- package/dist/js/Link.d.ts.map +1 -0
- package/dist/js/Link.js +112 -0
- package/dist/js/Link.js.map +1 -0
- package/dist/js/PathnameProvider.d.ts +8 -0
- package/dist/js/PathnameProvider.d.ts.map +1 -0
- package/dist/js/PathnameProvider.js +24 -0
- package/dist/js/PathnameProvider.js.map +1 -0
- package/dist/js/RedirectBoundary.d.ts +18 -0
- package/dist/js/RedirectBoundary.d.ts.map +1 -0
- package/dist/js/RedirectBoundary.js +34 -0
- package/dist/js/RedirectBoundary.js.map +1 -0
- package/dist/js/SegmentBoundary.d.ts +8 -0
- package/dist/js/SegmentBoundary.d.ts.map +1 -0
- package/dist/js/SegmentBoundary.js +38 -0
- package/dist/js/SegmentBoundary.js.map +1 -0
- package/dist/js/SlotBoundary.d.ts +15 -0
- package/dist/js/SlotBoundary.d.ts.map +1 -0
- package/dist/js/SlotBoundary.js +17 -0
- package/dist/js/SlotBoundary.js.map +1 -0
- package/dist/js/createViteRscApp.d.ts +8 -0
- package/dist/js/createViteRscApp.d.ts.map +1 -0
- package/dist/js/createViteRscApp.js +185 -0
- package/dist/js/createViteRscApp.js.map +1 -0
- package/dist/js/errors.d.ts +51 -0
- package/dist/js/errors.d.ts.map +1 -0
- package/dist/js/errors.js +93 -0
- package/dist/js/errors.js.map +1 -0
- package/dist/js/navigate.d.ts +118 -0
- package/dist/js/navigate.d.ts.map +1 -0
- package/dist/js/navigate.js +780 -0
- package/dist/js/navigate.js.map +1 -0
- package/dist/js/onlineStore.d.ts +23 -0
- package/dist/js/onlineStore.d.ts.map +1 -0
- package/dist/js/onlineStore.js +42 -0
- package/dist/js/onlineStore.js.map +1 -0
- package/dist/js/pagePayload.d.ts +15 -0
- package/dist/js/pagePayload.d.ts.map +1 -0
- package/dist/js/pagePayload.js +39 -0
- package/dist/js/pagePayload.js.map +1 -0
- package/dist/js/router.d.ts +29 -0
- package/dist/js/router.d.ts.map +1 -0
- package/dist/js/router.js +40 -0
- package/dist/js/router.js.map +1 -0
- package/dist/js/section.d.ts +16 -0
- package/dist/js/section.d.ts.map +1 -0
- package/dist/js/section.js +55 -0
- package/dist/js/section.js.map +1 -0
- package/dist/js/segmentStore.d.ts +69 -0
- package/dist/js/segmentStore.d.ts.map +1 -0
- package/dist/js/segmentStore.js +127 -0
- package/dist/js/segmentStore.js.map +1 -0
- package/dist/js/slotStore.d.ts +35 -0
- package/dist/js/slotStore.d.ts.map +1 -0
- package/dist/js/slotStore.js +46 -0
- package/dist/js/slotStore.js.map +1 -0
- package/dist/js/standardSchema.d.ts +47 -0
- package/dist/js/standardSchema.d.ts.map +1 -0
- package/dist/js/standardSchema.js +41 -0
- package/dist/js/standardSchema.js.map +1 -0
- package/dist/js/useForm.d.ts +43 -0
- package/dist/js/useForm.d.ts.map +1 -0
- package/dist/js/useForm.js +164 -0
- package/dist/js/useForm.js.map +1 -0
- package/dist/js/useLinkStatus.d.ts +2 -0
- package/dist/js/useLinkStatus.d.ts.map +1 -0
- package/dist/js/useLinkStatus.js +3 -0
- package/dist/js/useLinkStatus.js.map +1 -0
- package/dist/js/useOnline.d.ts +8 -0
- package/dist/js/useOnline.d.ts.map +1 -0
- package/dist/js/useOnline.js +20 -0
- package/dist/js/useOnline.js.map +1 -0
- package/dist/js/usePathname.d.ts +9 -0
- package/dist/js/usePathname.d.ts.map +1 -0
- package/dist/js/usePathname.js +32 -0
- package/dist/js/usePathname.js.map +1 -0
- package/dist/js/useSearchParams.d.ts +2 -0
- package/dist/js/useSearchParams.d.ts.map +1 -0
- package/dist/js/useSearchParams.js +60 -0
- package/dist/js/useSearchParams.js.map +1 -0
- package/dist/manifest.d.ts +66 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +13 -0
- package/dist/manifest.js.map +1 -0
- package/dist/prerender.d.ts +138 -0
- package/dist/prerender.d.ts.map +1 -0
- package/dist/prerender.js +387 -0
- package/dist/prerender.js.map +1 -0
- package/dist/redirect.d.ts +34 -0
- package/dist/redirect.d.ts.map +1 -0
- package/dist/redirect.js +108 -0
- package/dist/redirect.js.map +1 -0
- package/dist/redirectDigest.d.ts +20 -0
- package/dist/redirectDigest.d.ts.map +1 -0
- package/dist/redirectDigest.js +66 -0
- package/dist/redirectDigest.js.map +1 -0
- package/dist/request.d.ts +93 -0
- package/dist/request.d.ts.map +1 -0
- package/dist/request.js +308 -0
- package/dist/request.js.map +1 -0
- package/dist/revalidate.d.ts +43 -0
- package/dist/revalidate.d.ts.map +1 -0
- package/dist/revalidate.js +99 -0
- package/dist/revalidate.js.map +1 -0
- package/dist/routes.d.ts +37 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +28 -0
- package/dist/routes.js.map +1 -0
- package/dist/routing.d.ts +52 -0
- package/dist/routing.d.ts.map +1 -0
- package/dist/routing.js +146 -0
- package/dist/routing.js.map +1 -0
- package/dist/runtime.d.ts +36 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +86 -0
- package/dist/runtime.js.map +1 -0
- package/dist/safeUrl.d.ts +10 -0
- package/dist/safeUrl.d.ts.map +1 -0
- package/dist/safeUrl.js +40 -0
- package/dist/safeUrl.js.map +1 -0
- package/dist/streaming.d.ts +62 -0
- package/dist/streaming.d.ts.map +1 -0
- package/dist/streaming.js +108 -0
- package/dist/streaming.js.map +1 -0
- package/dist/types.d.ts +82 -0
- package/dist/vite.d.ts +109 -0
- package/dist/vite.d.ts.map +1 -0
- package/dist/vite.js +1909 -0
- package/dist/vite.js.map +1 -0
- package/dist/worker.d.ts +2 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +863 -0
- package/dist/worker.js.map +1 -0
- package/package.json +223 -0
|
@@ -0,0 +1,780 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core SPA navigation engine for RSC.
|
|
3
|
+
*
|
|
4
|
+
* Uses module-level state (singleton in the browser bundle).
|
|
5
|
+
* The Flight deserializer is injected by createViteRscApp to avoid
|
|
6
|
+
* duplicate bundling of react-server-dom-webpack.
|
|
7
|
+
*/
|
|
8
|
+
import { isSafeRedirect } from '../safeUrl.js';
|
|
9
|
+
import { reportReachable } from "./onlineStore";
|
|
10
|
+
import { clearSlots, setSlot } from "./slotStore";
|
|
11
|
+
// Shared with the host: it stores a page under this key and the client looks
|
|
12
|
+
// under it, so the format cannot live in two places.
|
|
13
|
+
import { matchRoute, retentionKey as retentionKeyFor, sharedDepth } from "../routing";
|
|
14
|
+
let version = "";
|
|
15
|
+
let onNavigate = null;
|
|
16
|
+
let onRestore = null;
|
|
17
|
+
let flightDeserializer = null;
|
|
18
|
+
let callServerFn = null;
|
|
19
|
+
let activeController = null;
|
|
20
|
+
const cache = new Map();
|
|
21
|
+
/** In-flight prefetches, so one the pointer moved away from can be dropped. */
|
|
22
|
+
const prefetchControllers = new Map();
|
|
23
|
+
let interceptManifest = [];
|
|
24
|
+
// The layout chain currently mounted, outermost first. Sent so the server can
|
|
25
|
+
// skip re-rendering the layouts still on screen.
|
|
26
|
+
let heldLayouts = [];
|
|
27
|
+
/**
|
|
28
|
+
* The boundary depth an interception was rendered at, while one is showing.
|
|
29
|
+
*
|
|
30
|
+
* An interceptor replaces a slot on the layout that declares it, so leaving the
|
|
31
|
+
* intercepted view has to re-render that layout for the slot to go back to its
|
|
32
|
+
* default. Left to itself the next navigation shares the whole chain, replaces
|
|
33
|
+
* only the page below it, and the modal stays open over the new one.
|
|
34
|
+
*/
|
|
35
|
+
let interceptedAtDepth = null;
|
|
36
|
+
/**
|
|
37
|
+
* The url showing underneath an open interception.
|
|
38
|
+
*
|
|
39
|
+
* An interception puts something in a slot on a page that stays where it is,
|
|
40
|
+
* so closing it is not a navigation at all — the page beneath was never
|
|
41
|
+
* replaced. Remembering which url that is means going back to it costs
|
|
42
|
+
* nothing, rather than fetching and rebuilding a page that is already on
|
|
43
|
+
* screen with everything the user typed into it.
|
|
44
|
+
*/
|
|
45
|
+
let interceptedOver = null;
|
|
46
|
+
const DEFAULT_PREFETCH_TTL = 30_000;
|
|
47
|
+
/**
|
|
48
|
+
* Where a payload lives when there is no server to negotiate with.
|
|
49
|
+
*
|
|
50
|
+
* Normally the payload and the page share a url and are told apart by the
|
|
51
|
+
* X-RSC header. A static host cannot vary by header — it serves one file per
|
|
52
|
+
* url — so an exported build gives payloads their own addresses and the client
|
|
53
|
+
* asks for those instead.
|
|
54
|
+
*/
|
|
55
|
+
let staticPayloadSuffix = null;
|
|
56
|
+
/**
|
|
57
|
+
* The route table, on a static host only.
|
|
58
|
+
*
|
|
59
|
+
* A server works out how much of the page to send by comparing the chain the
|
|
60
|
+
* client sent with the route's own, and says so in a header. A file server
|
|
61
|
+
* does neither — so on an exported site the client has to know the target's
|
|
62
|
+
* layout chain before it asks, or every navigation takes the whole document
|
|
63
|
+
* and replaces the root, which unmounts everything retained behind it.
|
|
64
|
+
*
|
|
65
|
+
* Inlined into the browser entry by the build, so it costs no request.
|
|
66
|
+
*/
|
|
67
|
+
let staticRoutes = null;
|
|
68
|
+
export function setStaticRoutes(routes) {
|
|
69
|
+
staticRoutes = routes;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The layout chain of the page loaded from a file, since no header said.
|
|
73
|
+
*
|
|
74
|
+
* Without this the client believes it holds nothing, every navigation asks
|
|
75
|
+
* for a whole document, and the depth variants sitting beside it are never
|
|
76
|
+
* requested — the export looks correct and retention silently never happens.
|
|
77
|
+
*/
|
|
78
|
+
export function seedStaticChain(url) {
|
|
79
|
+
const segments = staticSegments(url);
|
|
80
|
+
if (!segments)
|
|
81
|
+
return false;
|
|
82
|
+
heldLayouts = segments.chain;
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The depth an exported payload should be asked for, and the chain it leaves
|
|
87
|
+
* mounted — the two things a header would otherwise have carried.
|
|
88
|
+
*/
|
|
89
|
+
function staticSegments(url, held = heldLayouts) {
|
|
90
|
+
if (staticRoutes === null)
|
|
91
|
+
return null;
|
|
92
|
+
const path = new URL(url, window.location.origin).pathname;
|
|
93
|
+
const match = matchRoute({ routes: staticRoutes }, path);
|
|
94
|
+
if (!match)
|
|
95
|
+
return null;
|
|
96
|
+
return {
|
|
97
|
+
// Against the chain the request is made with, never against whatever is
|
|
98
|
+
// mounted by the time the answer arrives. A prefetch is fetched against
|
|
99
|
+
// one chain and applied later, and reading live state here labels its
|
|
100
|
+
// payload with a depth it was not rendered for.
|
|
101
|
+
depth: sharedDepth(held.join(","), match.route.layouts),
|
|
102
|
+
// Copied: this is the build's table, and the caller assigns it to the
|
|
103
|
+
// chain it holds — handing out the array itself makes the two the same
|
|
104
|
+
// object.
|
|
105
|
+
chain: [...match.route.layouts],
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
export function setStaticPayloads(suffix) {
|
|
109
|
+
staticPayloadSuffix = suffix;
|
|
110
|
+
}
|
|
111
|
+
/** The url to request a payload from, which is the page's own unless exported. */
|
|
112
|
+
export function payloadUrl(url, held = heldLayouts) {
|
|
113
|
+
if (staticPayloadSuffix === null)
|
|
114
|
+
return url;
|
|
115
|
+
const parsed = new URL(url, window.location.origin);
|
|
116
|
+
const path = parsed.pathname.replace(/\/+$/, "");
|
|
117
|
+
const segments = staticSegments(url, held);
|
|
118
|
+
// The variant for exactly the depth this client shares. Asking for the whole
|
|
119
|
+
// document when a segment would do is not merely wasteful: it replaces the
|
|
120
|
+
// root, and replacing the root throws away every page retained behind it.
|
|
121
|
+
const name = segments && segments.depth > 0
|
|
122
|
+
? staticPayloadSuffix.replace(/^index\./, `index.seg${segments.depth}.`)
|
|
123
|
+
: staticPayloadSuffix;
|
|
124
|
+
return `${path}/${name}${parsed.search}`;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* What a static payload was fetched as, recorded against its own response.
|
|
128
|
+
*
|
|
129
|
+
* The depth asked for and the depth applied have to be the same number. Worked
|
|
130
|
+
* out separately at the two call sites they can disagree — and a payload
|
|
131
|
+
* rendered whole but applied as a segment nests a boundary inside itself,
|
|
132
|
+
* which does not error, it recurses until the renderer stops responding.
|
|
133
|
+
*/
|
|
134
|
+
const staticFetches = new WeakMap();
|
|
135
|
+
export function setVersion(v) {
|
|
136
|
+
version = v;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* The layout chain the client is holding.
|
|
140
|
+
*
|
|
141
|
+
* Seeded from the initial page's response and updated on every navigation, so
|
|
142
|
+
* the next request can say what is already mounted.
|
|
143
|
+
*/
|
|
144
|
+
export function setHeldLayouts(chain) {
|
|
145
|
+
heldLayouts = chain;
|
|
146
|
+
}
|
|
147
|
+
export function getHeldLayouts() {
|
|
148
|
+
return heldLayouts;
|
|
149
|
+
}
|
|
150
|
+
export function setNavigateHandler(fn) {
|
|
151
|
+
onNavigate = fn;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* How the router reveals a page that is still mounted behind the current one.
|
|
155
|
+
*
|
|
156
|
+
* Returning true means the page was restored with its client state intact and
|
|
157
|
+
* no request was made.
|
|
158
|
+
*/
|
|
159
|
+
export function setRestoreHandler(fn) {
|
|
160
|
+
onRestore = fn;
|
|
161
|
+
}
|
|
162
|
+
export function setDeserializer(fn) {
|
|
163
|
+
flightDeserializer = fn;
|
|
164
|
+
}
|
|
165
|
+
export function setCallServer(fn) {
|
|
166
|
+
callServerFn = fn;
|
|
167
|
+
}
|
|
168
|
+
export function setInterceptManifest(entries) {
|
|
169
|
+
interceptManifest = entries;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Check if a URL matches any intercept pattern.
|
|
173
|
+
* Returns the matching slot name, or null if no match.
|
|
174
|
+
*/
|
|
175
|
+
function matchIntercept(url) {
|
|
176
|
+
if (interceptManifest.length === 0)
|
|
177
|
+
return null;
|
|
178
|
+
let pathname;
|
|
179
|
+
try {
|
|
180
|
+
pathname = new URL(url, window.location.origin).pathname;
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
pathname = url.split("?")[0];
|
|
184
|
+
}
|
|
185
|
+
for (const entry of interceptManifest) {
|
|
186
|
+
// urlPattern already has a leading slash (e.g. "/docs/item/[id]")
|
|
187
|
+
const regex = new RegExp("^" +
|
|
188
|
+
entry.urlPattern
|
|
189
|
+
.replace(/\[\.\.\.(\w+)\]/g, "(.+)")
|
|
190
|
+
.replace(/\[(\w+)\]/g, "([^/]+)") +
|
|
191
|
+
"$");
|
|
192
|
+
if (regex.test(pathname)) {
|
|
193
|
+
return entry.slot;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
export function renderTree(tree) {
|
|
199
|
+
onNavigate?.(tree, retentionKey(window.location.href, null), 0);
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Identity of a page for retention purposes.
|
|
203
|
+
*
|
|
204
|
+
* Path and query only: a hash is a position within the same page, and an
|
|
205
|
+
* intercepted route is a different rendering of the same URL, so it retains
|
|
206
|
+
* separately from the full page.
|
|
207
|
+
*/
|
|
208
|
+
export function retentionKey(url, interceptSlot) {
|
|
209
|
+
let path;
|
|
210
|
+
try {
|
|
211
|
+
const parsed = new URL(url, window.location.origin);
|
|
212
|
+
path = parsed.pathname + parsed.search;
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
path = url.split("#")[0];
|
|
216
|
+
}
|
|
217
|
+
return retentionKeyFor(path, interceptSlot);
|
|
218
|
+
}
|
|
219
|
+
export function getCallServer() {
|
|
220
|
+
if (!callServerFn) {
|
|
221
|
+
throw new Error("callServer not initialized. Ensure createViteRscApp() has been called.");
|
|
222
|
+
}
|
|
223
|
+
return callServerFn;
|
|
224
|
+
}
|
|
225
|
+
function fetchRscPayload(url, signal, interceptSlot, refererUrl, chain = heldLayouts,
|
|
226
|
+
// A prefetch is speculative and a navigation is not, but on the wire they
|
|
227
|
+
// were identical — so a click could queue behind several prefetches the user
|
|
228
|
+
// had already moved past. Over HTTP/1.1 a browser opens ~6 connections per
|
|
229
|
+
// origin, which a sweep across a nav bar fills on its own.
|
|
230
|
+
priority = "high") {
|
|
231
|
+
const headers = {
|
|
232
|
+
"X-RSC": "true",
|
|
233
|
+
"X-RSC-Version": version,
|
|
234
|
+
};
|
|
235
|
+
if (chain.length) {
|
|
236
|
+
headers["X-RSC-Segments"] = chain.join(",");
|
|
237
|
+
}
|
|
238
|
+
if (interceptSlot) {
|
|
239
|
+
headers["X-RSC-Intercept"] = interceptSlot;
|
|
240
|
+
}
|
|
241
|
+
if (refererUrl) {
|
|
242
|
+
headers["X-RSC-Referer"] = refererUrl;
|
|
243
|
+
}
|
|
244
|
+
// `priority` is not in every lib.dom yet; browsers without it ignore it.
|
|
245
|
+
const asked = staticSegments(url, chain);
|
|
246
|
+
const request = fetch(payloadUrl(url, chain), { headers, signal, priority }).catch((err) => {
|
|
247
|
+
// Nothing answered at all. An abort is our own doing, not the network's —
|
|
248
|
+
// leaving a link cancels its prefetch, and that must not read as offline.
|
|
249
|
+
if (!(err instanceof DOMException && err.name === "AbortError")) {
|
|
250
|
+
reportReachable(false);
|
|
251
|
+
}
|
|
252
|
+
throw err;
|
|
253
|
+
});
|
|
254
|
+
return request.then(async (response) => {
|
|
255
|
+
// What this payload was asked for, so the apply side cannot work out a
|
|
256
|
+
// different answer from state that has moved on since.
|
|
257
|
+
if (asked)
|
|
258
|
+
staticFetches.set(response, asked);
|
|
259
|
+
// Something answered, whatever it said. A 500 is a reachable server.
|
|
260
|
+
reportReachable(true);
|
|
261
|
+
// Adopt the server's build version from the first response that carries
|
|
262
|
+
// one. Until we know it we send an empty version, which the middleware
|
|
263
|
+
// treats as "no opinion"; afterwards a redeploy mid-session answers 409.
|
|
264
|
+
const served = response.headers.get("X-RSC-Version");
|
|
265
|
+
if (served && version === "") {
|
|
266
|
+
version = served;
|
|
267
|
+
}
|
|
268
|
+
if (response.status === 409) {
|
|
269
|
+
const location = response.headers.get("X-RSC-Location");
|
|
270
|
+
// Server-chosen, so checked again here: the engine refuses these at the
|
|
271
|
+
// source, but a host in front of it can put anything on the header.
|
|
272
|
+
window.location.href = isSafeRedirect(location ?? url) ? (location ?? url) : url;
|
|
273
|
+
throw new Error("Version mismatch — full reload triggered");
|
|
274
|
+
}
|
|
275
|
+
return response;
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Deserialize a Flight response into a React tree.
|
|
280
|
+
*
|
|
281
|
+
* Client modules, CSS <link>s and <title>/<meta> all travel inside the Flight
|
|
282
|
+
* payload — @vitejs/plugin-rsc emits stylesheet links as tree elements and
|
|
283
|
+
* resolves client references through its own browser runtime, and React 19
|
|
284
|
+
* hoists document metadata into <head>. Nothing needs injecting from headers.
|
|
285
|
+
*/
|
|
286
|
+
function deserializeResponse(response) {
|
|
287
|
+
return flightDeserializer(response.body, {
|
|
288
|
+
callServer: callServerFn ?? (async () => {
|
|
289
|
+
throw new Error("Server actions not initialized");
|
|
290
|
+
}),
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
function isExternalUrl(url) {
|
|
294
|
+
try {
|
|
295
|
+
return new URL(url, window.location.origin).origin !== window.location.origin;
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Whether a cached payload can be used for a navigation claiming this chain.
|
|
303
|
+
*
|
|
304
|
+
* A partial payload only composes against the chain it was rendered for, so
|
|
305
|
+
* the chain it was fetched against has to be the one the navigation is about
|
|
306
|
+
* to claim — not whatever happens to be mounted.
|
|
307
|
+
*/
|
|
308
|
+
function isUsable(cached, chain) {
|
|
309
|
+
return (cached !== undefined &&
|
|
310
|
+
cached.expiresAt > Date.now() &&
|
|
311
|
+
cached.heldWhenFetched === chain.join(","));
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Whether a navigation to this url would be served from the prefetch cache.
|
|
315
|
+
*
|
|
316
|
+
* Lets a caller decide whether showing an already-fetched page is free. A form
|
|
317
|
+
* uses it to put its target route's shell on screen while the real query runs:
|
|
318
|
+
* worth doing when the shell is in hand, never worth an extra request.
|
|
319
|
+
*/
|
|
320
|
+
export function isPrefetched(url) {
|
|
321
|
+
if (isExternalUrl(url))
|
|
322
|
+
return false;
|
|
323
|
+
const interceptSlot = matchIntercept(url);
|
|
324
|
+
const cacheKey = retentionKeyFor(url, interceptSlot);
|
|
325
|
+
return isUsable(cache.get(cacheKey), claimedChain(interceptSlot));
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* The layout chain a navigation to a url will claim.
|
|
329
|
+
*
|
|
330
|
+
* Leaving an interception claims fewer layouts than are held, which is what
|
|
331
|
+
* forces the layout owning the slot to render again so the slot can empty.
|
|
332
|
+
* Both sides have to agree on it: a prefetch recorded against a different
|
|
333
|
+
* chain can never be used, so the close of every modal refetched a payload it
|
|
334
|
+
* had already fetched.
|
|
335
|
+
*/
|
|
336
|
+
function claimedChain(interceptSlot) {
|
|
337
|
+
return !interceptSlot && interceptedAtDepth !== null
|
|
338
|
+
? heldLayouts.slice(0, interceptedAtDepth)
|
|
339
|
+
: heldLayouts;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* How many redirects a single navigation will follow before giving up.
|
|
343
|
+
*
|
|
344
|
+
* A page that redirects to itself is a mistake someone will make, and without
|
|
345
|
+
* a ceiling it is an unbounded loop of requests rather than an error. The
|
|
346
|
+
* browser's own limit for HTTP redirects is 20; this is smaller because these
|
|
347
|
+
* are full renders, not header exchanges.
|
|
348
|
+
*/
|
|
349
|
+
const MAX_REDIRECTS = 8;
|
|
350
|
+
/**
|
|
351
|
+
* Go to a url, the way a Link does.
|
|
352
|
+
*
|
|
353
|
+
* `url` is typed to the routes the build found; cast with `as Href` when the
|
|
354
|
+
* destination is computed rather than written.
|
|
355
|
+
*/
|
|
356
|
+
export async function navigate(url, opts) {
|
|
357
|
+
const redirectsFollowed = opts?.redirectsFollowed ?? 0;
|
|
358
|
+
if (redirectsFollowed > MAX_REDIRECTS) {
|
|
359
|
+
throw new Error(`Too many redirects following a navigation (${MAX_REDIRECTS}); last was ${url}`);
|
|
360
|
+
}
|
|
361
|
+
// External URLs can't be fetched (CORS) — go directly to full page navigation
|
|
362
|
+
if (isExternalUrl(url)) {
|
|
363
|
+
window.location.href = url;
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
// Hash-only URLs — let the browser handle scrolling natively
|
|
367
|
+
if (url.startsWith("#")) {
|
|
368
|
+
window.location.hash = url;
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
// Abort any in-flight navigation
|
|
372
|
+
activeController?.abort();
|
|
373
|
+
// If the initial HTML stream is still loading (Suspense completions streaming),
|
|
374
|
+
// stop it so the single-threaded PHP server can handle the new request.
|
|
375
|
+
if (document.readyState === "loading") {
|
|
376
|
+
window.stop();
|
|
377
|
+
}
|
|
378
|
+
const controller = new AbortController();
|
|
379
|
+
activeController = controller;
|
|
380
|
+
// Check if this URL matches an intercept pattern.
|
|
381
|
+
// If so, send the intercept slot + current URL as referer so the server
|
|
382
|
+
// renders the full tree with the interceptor in the right slot.
|
|
383
|
+
const interceptSlot = matchIntercept(url);
|
|
384
|
+
const currentUrl = interceptSlot
|
|
385
|
+
? window.location.pathname + window.location.search
|
|
386
|
+
: undefined;
|
|
387
|
+
const activityKey = retentionKey(url, interceptSlot);
|
|
388
|
+
// Back and forward are the browser's own gesture for returning to a page you
|
|
389
|
+
// were just on, so they reveal the retained one — instantly, and with the
|
|
390
|
+
// form you were filling in still filled in. A link is a fresh request: the
|
|
391
|
+
// server may have different data to say, and silently showing a stale page
|
|
392
|
+
// would be the wrong default.
|
|
393
|
+
// Closing an interception. The page underneath was never replaced, so this
|
|
394
|
+
// is a matter of emptying the slot — no request, and nothing rebuilt. The
|
|
395
|
+
// form behind the modal is still the one the user was filling in.
|
|
396
|
+
if (!interceptSlot &&
|
|
397
|
+
interceptedOver !== null &&
|
|
398
|
+
retentionKey(url, null) === retentionKey(interceptedOver, null)) {
|
|
399
|
+
clearSlots();
|
|
400
|
+
interceptedOver = null;
|
|
401
|
+
interceptedAtDepth = null;
|
|
402
|
+
if (opts?.replace) {
|
|
403
|
+
history.replaceState({ rscUrl: url }, "", url);
|
|
404
|
+
}
|
|
405
|
+
else {
|
|
406
|
+
history.pushState({ rscUrl: url }, "", url);
|
|
407
|
+
}
|
|
408
|
+
window.dispatchEvent(new CustomEvent("rsc-navigate", { detail: url }));
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
if (opts?.restore && onRestore?.(activityKey)) {
|
|
412
|
+
// A restored tree carries its own slot contents, so the flag only has to
|
|
413
|
+
// reflect whether what is now showing is an intercepted view.
|
|
414
|
+
if (!interceptSlot)
|
|
415
|
+
interceptedAtDepth = null;
|
|
416
|
+
if (opts.replace) {
|
|
417
|
+
history.replaceState({ rscUrl: url }, "", url);
|
|
418
|
+
}
|
|
419
|
+
else {
|
|
420
|
+
history.pushState({ rscUrl: url }, "", url);
|
|
421
|
+
}
|
|
422
|
+
window.dispatchEvent(new CustomEvent("rsc-navigate", { detail: url }));
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
// A prefetched payload was rendered against the chain held at prefetch time.
|
|
426
|
+
let segmentDepth = 0;
|
|
427
|
+
let nextLayouts = null;
|
|
428
|
+
/** The slot this answer fills, when it is one region rather than a segment. */
|
|
429
|
+
let slotPayload = null;
|
|
430
|
+
/** The cache entry this navigation is using, whose metadata settles with it. */
|
|
431
|
+
let reused = null;
|
|
432
|
+
const previousUrl = window.location.pathname + window.location.search;
|
|
433
|
+
try {
|
|
434
|
+
const cacheKey = retentionKeyFor(url, interceptSlot);
|
|
435
|
+
const cached = cache.get(cacheKey);
|
|
436
|
+
let treePromise;
|
|
437
|
+
const chain = claimedChain(interceptSlot);
|
|
438
|
+
// A partial payload only composes against the chain it was rendered for —
|
|
439
|
+
// the one this navigation is about to claim, not whatever is mounted.
|
|
440
|
+
// Hovering a link inside a modal prefetches it against the full chain, so
|
|
441
|
+
// reusing that here would skip the layout holding the modal and leave it
|
|
442
|
+
// open over the page behind it.
|
|
443
|
+
const usable = isUsable(cached, chain);
|
|
444
|
+
if (usable) {
|
|
445
|
+
treePromise = cached.tree;
|
|
446
|
+
// Read after the tree resolves, not now. A prefetch fills these in when
|
|
447
|
+
// its response lands, and a click can land first — hover a modal link
|
|
448
|
+
// and click it quickly and the entry still says depth 0, so the
|
|
449
|
+
// interceptor is applied as a whole document and the modal renders *as*
|
|
450
|
+
// the page. The fields are set before the tree promise resolves, so
|
|
451
|
+
// waiting for it is what makes them true.
|
|
452
|
+
reused = cached;
|
|
453
|
+
cache.delete(cacheKey);
|
|
454
|
+
}
|
|
455
|
+
else {
|
|
456
|
+
cache.delete(cacheKey);
|
|
457
|
+
const response = await fetchRscPayload(url, controller.signal, interceptSlot ?? undefined, currentUrl, chain);
|
|
458
|
+
// The check is for a host that answered the page instead of the
|
|
459
|
+
// payload, which is what a server does when it does not recognise the
|
|
460
|
+
// header. An exported build asks a url that only ever holds a payload,
|
|
461
|
+
// and a file server labels it by extension — commonly
|
|
462
|
+
// application/octet-stream — so the check would reject every navigation
|
|
463
|
+
// and send the browser on a full page load instead.
|
|
464
|
+
// The render asked to go somewhere else, and said so before writing
|
|
465
|
+
// anything — so this is still a navigation, not a page load. A redirect
|
|
466
|
+
// decided later than that cannot travel here; it arrives in the payload
|
|
467
|
+
// as an error digest and RedirectBoundary performs it.
|
|
468
|
+
const redirectTo = response.headers.get("X-RSC-Redirect");
|
|
469
|
+
if (redirectTo) {
|
|
470
|
+
// replace: the url that redirected never became a page the user was
|
|
471
|
+
// on, so Back must not return to it and redirect again.
|
|
472
|
+
// Chosen by the server, not written here.
|
|
473
|
+
await navigate(redirectTo, { replace: true, redirectsFollowed: redirectsFollowed + 1 });
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
const contentType = response.headers.get("Content-Type") ?? "";
|
|
477
|
+
if (staticPayloadSuffix === null && !contentType.includes("text/x-component")) {
|
|
478
|
+
window.location.href = url;
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
// Headers on a server; worked out locally on a static host, where there
|
|
482
|
+
// is nothing to send them.
|
|
483
|
+
const served = staticFetches.get(response) ?? null;
|
|
484
|
+
segmentDepth = Number(response.headers.get("X-RSC-Segment-Depth") ?? served?.depth ?? 0) || 0;
|
|
485
|
+
// Named region rather than a segment — see the apply below.
|
|
486
|
+
slotPayload = response.headers.get("X-RSC-Revalidate");
|
|
487
|
+
const servedLayouts = response.headers.get("X-RSC-Layouts");
|
|
488
|
+
if (servedLayouts !== null) {
|
|
489
|
+
nextLayouts = servedLayouts === "" ? [] : servedLayouts.split(",");
|
|
490
|
+
}
|
|
491
|
+
else if (served) {
|
|
492
|
+
nextLayouts = served.chain;
|
|
493
|
+
}
|
|
494
|
+
treePromise = deserializeResponse(response);
|
|
495
|
+
}
|
|
496
|
+
const tree = await treePromise;
|
|
497
|
+
if (reused) {
|
|
498
|
+
segmentDepth = reused.segmentDepth;
|
|
499
|
+
nextLayouts = reused.layouts;
|
|
500
|
+
slotPayload = reused.slot;
|
|
501
|
+
}
|
|
502
|
+
if (controller.signal.aborted)
|
|
503
|
+
return;
|
|
504
|
+
if (opts?.replace) {
|
|
505
|
+
history.replaceState({ rscUrl: url }, "", url);
|
|
506
|
+
}
|
|
507
|
+
else {
|
|
508
|
+
history.pushState({ rscUrl: url }, "", url);
|
|
509
|
+
}
|
|
510
|
+
if (nextLayouts !== null)
|
|
511
|
+
heldLayouts = nextLayouts;
|
|
512
|
+
// The answer is one region, not a piece of the page: the host rendered
|
|
513
|
+
// only the interceptor because the page underneath is already mounted and
|
|
514
|
+
// still correct. Putting it in the slot leaves that page — and everything
|
|
515
|
+
// typed into it — exactly as it was.
|
|
516
|
+
if (slotPayload !== null) {
|
|
517
|
+
setSlot(slotPayload, tree);
|
|
518
|
+
interceptedOver = interceptedOver ?? previousUrl;
|
|
519
|
+
interceptedAtDepth = null;
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
// A slot rendered for the page being left has no claim on the one being
|
|
523
|
+
// arrived at.
|
|
524
|
+
clearSlots();
|
|
525
|
+
interceptedOver = null;
|
|
526
|
+
interceptedAtDepth = interceptSlot ? segmentDepth : null;
|
|
527
|
+
onNavigate?.(tree, activityKey, segmentDepth);
|
|
528
|
+
if (!opts?.preserveScroll && !interceptSlot) {
|
|
529
|
+
// Wait for React to commit the DOM update before scrolling.
|
|
530
|
+
// Intercepted navigations preserve scroll (e.g. modal over current page).
|
|
531
|
+
requestAnimationFrame(() => {
|
|
532
|
+
window.scrollTo(0, 0);
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
window.dispatchEvent(new CustomEvent("rsc-navigate", { detail: url }));
|
|
536
|
+
}
|
|
537
|
+
catch (err) {
|
|
538
|
+
if (err instanceof DOMException && err.name === "AbortError")
|
|
539
|
+
return;
|
|
540
|
+
// Without this a navigation that fails does nothing observable: the click
|
|
541
|
+
// clears its own pending state and the page stays as it was, with no
|
|
542
|
+
// error, no fallback and nothing for an app to react to. Dispatched before
|
|
543
|
+
// rethrowing, so a programmatic caller still sees the failure.
|
|
544
|
+
window.dispatchEvent(new CustomEvent("rsc-navigate-error", { detail: { url, error: err } }));
|
|
545
|
+
throw err;
|
|
546
|
+
}
|
|
547
|
+
finally {
|
|
548
|
+
if (activeController === controller) {
|
|
549
|
+
activeController = null;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Put something the server re-rendered on screen.
|
|
555
|
+
*
|
|
556
|
+
* The trees arrive with an action's answer rather than being fetched, so this
|
|
557
|
+
* is the same apply path a navigation uses — without a request, a url change
|
|
558
|
+
* or a history entry.
|
|
559
|
+
*/
|
|
560
|
+
export function applyRevalidated(target, tree) {
|
|
561
|
+
const url = window.location.pathname + window.location.search;
|
|
562
|
+
const key = retentionKey(url, null);
|
|
563
|
+
if (target === 'all') {
|
|
564
|
+
// Depth 0 replaces the root, which is what re-rendering the layouts means.
|
|
565
|
+
onNavigate?.(tree, key, 0);
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
if (target === 'page') {
|
|
569
|
+
onNavigate?.(tree, key, heldLayouts.length);
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
setSlot(target, tree);
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Ask the server for part of this page again.
|
|
576
|
+
*
|
|
577
|
+
* refresh() the page, leaving the layouts mounted
|
|
578
|
+
* refresh('all') the whole document, layouts included
|
|
579
|
+
* refresh('orders') one parallel slot, by the name its directory gave it
|
|
580
|
+
*
|
|
581
|
+
* The same words an action uses, so a thing can be invalidated from either
|
|
582
|
+
* side. This is the path for a refresh nobody mutated anything to earn — a
|
|
583
|
+
* button, a poll, a message saying that table has moved; what an action
|
|
584
|
+
* invalidated travels back inside the action's own answer instead.
|
|
585
|
+
*
|
|
586
|
+
* A slot is the only region smaller than a page the server can name, so two
|
|
587
|
+
* tables refresh apart from each other only if they are two slots. The page
|
|
588
|
+
* form leaves the layouts alone, which is what makes it cheap and also why a
|
|
589
|
+
* count living in a layout will not move until you ask for 'all'.
|
|
590
|
+
*/
|
|
591
|
+
export async function refresh(target = 'page') {
|
|
592
|
+
const url = window.location.pathname + window.location.search;
|
|
593
|
+
if (target !== 'page' && target !== 'all') {
|
|
594
|
+
const response = await fetch(payloadUrl(url), {
|
|
595
|
+
headers: { "X-RSC": "true", "X-RSC-Version": version, "X-RSC-Revalidate": target },
|
|
596
|
+
});
|
|
597
|
+
if (!response.ok) {
|
|
598
|
+
throw new Error(`Could not revalidate ${target}: the server answered ${response.status}`);
|
|
599
|
+
}
|
|
600
|
+
setSlot(target, await deserializeResponse(response));
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
const interceptSlot = matchIntercept(url);
|
|
604
|
+
const cacheKey = retentionKeyFor(url, interceptSlot);
|
|
605
|
+
// Never from the cache: refreshing asks what the server says now, not what
|
|
606
|
+
// it said a moment ago.
|
|
607
|
+
cache.delete(cacheKey);
|
|
608
|
+
if (target === 'all') {
|
|
609
|
+
heldLayouts = [];
|
|
610
|
+
}
|
|
611
|
+
// A refresh is not a navigation: the same page stays under the reader, so
|
|
612
|
+
// where they were reading has to survive the tree being replaced.
|
|
613
|
+
//
|
|
614
|
+
// `preserveScroll` is not enough on its own. It stops navigate() scrolling
|
|
615
|
+
// to the top deliberately, and says nothing about the scroll positions
|
|
616
|
+
// *inside* the page. Refreshing everything replaces the root, so every
|
|
617
|
+
// element that scrolls is a new node and starts at zero — a sidebar,
|
|
618
|
+
// a code block, any pane with its own overflow. The window survives because
|
|
619
|
+
// the document element is not the thing being replaced, which is why this
|
|
620
|
+
// looks fine until a page has a second scroller in it.
|
|
621
|
+
const positions = scrollPositions();
|
|
622
|
+
await navigate(url, { replace: true, preserveScroll: true });
|
|
623
|
+
restoreScroll(positions);
|
|
624
|
+
}
|
|
625
|
+
/** Every element that can scroll, whether or not it currently is. */
|
|
626
|
+
function scrollables() {
|
|
627
|
+
return [...document.querySelectorAll('*')].filter((el) => el.scrollHeight > el.clientHeight || el.scrollWidth > el.clientWidth);
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Where everything on the page is scrolled to, in document order.
|
|
631
|
+
*
|
|
632
|
+
* Identified by position in the list rather than by a selector: a refresh
|
|
633
|
+
* re-renders the same page, so the nth scrollable element is the same one, and
|
|
634
|
+
* a selector would have to survive whatever classes the markup happens to use.
|
|
635
|
+
* The tag is carried only to notice when that assumption has broken.
|
|
636
|
+
*
|
|
637
|
+
* Everything that *can* scroll, not everything that *is* scrolled — the two
|
|
638
|
+
* lists have to be built the same way or they do not line up. Recording only
|
|
639
|
+
* the scrolled ones and restoring over the scrollable ones puts a sidebar's
|
|
640
|
+
* position onto whatever element happens to come first, which on any page long
|
|
641
|
+
* enough to scroll is <html>.
|
|
642
|
+
*/
|
|
643
|
+
function scrollPositions() {
|
|
644
|
+
return [
|
|
645
|
+
{ tag: 'window', top: window.scrollY, left: window.scrollX },
|
|
646
|
+
...scrollables().map((el) => ({ tag: el.tagName, top: el.scrollTop, left: el.scrollLeft })),
|
|
647
|
+
];
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* Put the page back where it was, once React has finished replacing it.
|
|
651
|
+
*
|
|
652
|
+
* Applied over several ticks rather than once, because `root.render()`
|
|
653
|
+
* schedules the update instead of performing it: a single pass writes the
|
|
654
|
+
* position onto the nodes that are about to be thrown away, and the ones that
|
|
655
|
+
* replace them start at zero. There is nothing to await — the navigation
|
|
656
|
+
* resolves when the tree is handed to React, not when React has committed it.
|
|
657
|
+
*
|
|
658
|
+
* `setTimeout`, not `requestAnimationFrame`. A hidden tab never runs an
|
|
659
|
+
* animation frame, so a refresh in a background tab — an HMR update, a poll —
|
|
660
|
+
* would leave the scroll state wrong the moment it came back into view. It
|
|
661
|
+
* also makes this untestable in an automated browser, where the tab under test
|
|
662
|
+
* is usually not the visible one.
|
|
663
|
+
*/
|
|
664
|
+
const RESTORE_ATTEMPTS = 5;
|
|
665
|
+
function restoreScroll(positions) {
|
|
666
|
+
const [win, ...inner] = positions;
|
|
667
|
+
const apply = (attempt) => {
|
|
668
|
+
if (window.scrollY !== win.top || window.scrollX !== win.left) {
|
|
669
|
+
window.scrollTo(win.left, win.top);
|
|
670
|
+
}
|
|
671
|
+
const now = scrollables();
|
|
672
|
+
for (let i = 0; i < inner.length && i < now.length; i++) {
|
|
673
|
+
// A different element here means the page came back a different shape,
|
|
674
|
+
// and guessing further would scroll something nobody touched.
|
|
675
|
+
if (now[i].tagName !== inner[i].tag)
|
|
676
|
+
break;
|
|
677
|
+
if (inner[i].top !== 0 && now[i].scrollTop !== inner[i].top)
|
|
678
|
+
now[i].scrollTop = inner[i].top;
|
|
679
|
+
if (inner[i].left !== 0 && now[i].scrollLeft !== inner[i].left)
|
|
680
|
+
now[i].scrollLeft = inner[i].left;
|
|
681
|
+
}
|
|
682
|
+
if (attempt < RESTORE_ATTEMPTS)
|
|
683
|
+
setTimeout(() => apply(attempt + 1), 16);
|
|
684
|
+
};
|
|
685
|
+
apply(1);
|
|
686
|
+
}
|
|
687
|
+
export function prefetch(url, cacheForMs) {
|
|
688
|
+
if (isExternalUrl(url))
|
|
689
|
+
return;
|
|
690
|
+
const ttl = cacheForMs ?? DEFAULT_PREFETCH_TTL;
|
|
691
|
+
const interceptSlot = matchIntercept(url);
|
|
692
|
+
if (interceptSlot) {
|
|
693
|
+
// Intercepted route — only prefetch the intercepted variant
|
|
694
|
+
const currentUrl = window.location.pathname + window.location.search;
|
|
695
|
+
const cacheKey = retentionKeyFor(url, interceptSlot);
|
|
696
|
+
prefetchUrl(cacheKey, url, ttl, interceptSlot, currentUrl);
|
|
697
|
+
}
|
|
698
|
+
else {
|
|
699
|
+
prefetchUrl(url, url, ttl);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
function prefetchUrl(cacheKey, url, ttl, interceptSlot, refererUrl) {
|
|
703
|
+
const chain = claimedChain(interceptSlot ?? null);
|
|
704
|
+
const existing = cache.get(cacheKey);
|
|
705
|
+
if (existing && existing.expiresAt > Date.now()) {
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
cache.delete(cacheKey);
|
|
709
|
+
const controller = new AbortController();
|
|
710
|
+
prefetchControllers.set(cacheKey, controller);
|
|
711
|
+
const entry = {
|
|
712
|
+
tree: Promise.resolve(null),
|
|
713
|
+
expiresAt: Date.now() + ttl,
|
|
714
|
+
segmentDepth: 0,
|
|
715
|
+
layouts: null,
|
|
716
|
+
slot: null,
|
|
717
|
+
heldWhenFetched: chain.join(","),
|
|
718
|
+
};
|
|
719
|
+
// Low priority: the browser then lets a real navigation overtake a queue of
|
|
720
|
+
// speculative requests instead of serving them in the order they were made.
|
|
721
|
+
entry.tree = fetchRscPayload(url, controller.signal, interceptSlot, refererUrl, chain, "low")
|
|
722
|
+
.then((response) => {
|
|
723
|
+
// On a static host there are no headers to read, and dropping the depth
|
|
724
|
+
// is not a small loss: the entry then claims a segment is a whole
|
|
725
|
+
// document, and rendering a layout-less page as the document root does
|
|
726
|
+
// not warn — it hangs the renderer.
|
|
727
|
+
// A prefetch that lands on a redirect is not cached. Following it would
|
|
728
|
+
// navigate on hover, and storing it would hand the click a 204 with no
|
|
729
|
+
// body to deserialize. The click re-requests and redirects properly.
|
|
730
|
+
if (response.headers.get("X-RSC-Redirect")) {
|
|
731
|
+
cache.delete(cacheKey);
|
|
732
|
+
return null;
|
|
733
|
+
}
|
|
734
|
+
entry.slot = response.headers.get("X-RSC-Revalidate");
|
|
735
|
+
const local = staticFetches.get(response) ?? null;
|
|
736
|
+
entry.segmentDepth = Number(response.headers.get("X-RSC-Segment-Depth") ?? local?.depth ?? 0) || 0;
|
|
737
|
+
const served = response.headers.get("X-RSC-Layouts");
|
|
738
|
+
if (served !== null) {
|
|
739
|
+
entry.layouts = served === "" ? [] : served.split(",");
|
|
740
|
+
}
|
|
741
|
+
else if (local) {
|
|
742
|
+
entry.layouts = local.chain;
|
|
743
|
+
}
|
|
744
|
+
return deserializeResponse(response);
|
|
745
|
+
})
|
|
746
|
+
.catch(() => {
|
|
747
|
+
cache.delete(cacheKey);
|
|
748
|
+
return null;
|
|
749
|
+
})
|
|
750
|
+
.finally(() => {
|
|
751
|
+
// Settled, so there is nothing left to abort. A completed prefetch stays
|
|
752
|
+
// in the cache — only an in-flight one is ever dropped.
|
|
753
|
+
if (prefetchControllers.get(cacheKey) === controller) {
|
|
754
|
+
prefetchControllers.delete(cacheKey);
|
|
755
|
+
}
|
|
756
|
+
});
|
|
757
|
+
cache.set(cacheKey, entry);
|
|
758
|
+
}
|
|
759
|
+
/**
|
|
760
|
+
* Drop a prefetch that is still in flight — the pointer left the link.
|
|
761
|
+
*
|
|
762
|
+
* The cache entry goes synchronously rather than in the abort's catch: the
|
|
763
|
+
* rejection lands a tick later, and a click in between would find an entry
|
|
764
|
+
* whose tree resolves to null and navigate to a blank page. A prefetch that
|
|
765
|
+
* has already completed is kept; there is no request left to cancel and the
|
|
766
|
+
* payload is still good.
|
|
767
|
+
*/
|
|
768
|
+
export function cancelPrefetch(url) {
|
|
769
|
+
if (isExternalUrl(url))
|
|
770
|
+
return;
|
|
771
|
+
const interceptSlot = matchIntercept(url);
|
|
772
|
+
const cacheKey = retentionKeyFor(url, interceptSlot);
|
|
773
|
+
const controller = prefetchControllers.get(cacheKey);
|
|
774
|
+
if (!controller)
|
|
775
|
+
return;
|
|
776
|
+
prefetchControllers.delete(cacheKey);
|
|
777
|
+
cache.delete(cacheKey);
|
|
778
|
+
controller.abort();
|
|
779
|
+
}
|
|
780
|
+
//# sourceMappingURL=navigate.js.map
|