@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.
Files changed (200) hide show
  1. package/README.md +95 -0
  2. package/SECURITY.md +60 -0
  3. package/dist/action.d.ts +90 -0
  4. package/dist/action.d.ts.map +1 -0
  5. package/dist/action.js +137 -0
  6. package/dist/action.js.map +1 -0
  7. package/dist/build-rsc-vite.d.ts +39 -0
  8. package/dist/build-rsc-vite.d.ts.map +1 -0
  9. package/dist/build-rsc-vite.js +172 -0
  10. package/dist/build-rsc-vite.js.map +1 -0
  11. package/dist/cache.d.ts +25 -0
  12. package/dist/cache.d.ts.map +1 -0
  13. package/dist/cache.js +108 -0
  14. package/dist/cache.js.map +1 -0
  15. package/dist/cli.d.ts +2 -0
  16. package/dist/cli.d.ts.map +1 -0
  17. package/dist/cli.js +85 -0
  18. package/dist/cli.js.map +1 -0
  19. package/dist/compile.d.ts +55 -0
  20. package/dist/compile.d.ts.map +1 -0
  21. package/dist/compile.js +141 -0
  22. package/dist/compile.js.map +1 -0
  23. package/dist/devServer.d.ts +18 -0
  24. package/dist/devServer.d.ts.map +1 -0
  25. package/dist/devServer.js +70 -0
  26. package/dist/devServer.js.map +1 -0
  27. package/dist/devUrls.d.ts +11 -0
  28. package/dist/devUrls.d.ts.map +1 -0
  29. package/dist/devUrls.js +68 -0
  30. package/dist/devUrls.js.map +1 -0
  31. package/dist/embed.d.ts +21 -0
  32. package/dist/embed.d.ts.map +1 -0
  33. package/dist/embed.js +119 -0
  34. package/dist/embed.js.map +1 -0
  35. package/dist/errors.d.ts +24 -0
  36. package/dist/errors.d.ts.map +1 -0
  37. package/dist/errors.js +38 -0
  38. package/dist/errors.js.map +1 -0
  39. package/dist/export.d.ts +41 -0
  40. package/dist/export.d.ts.map +1 -0
  41. package/dist/export.js +100 -0
  42. package/dist/export.js.map +1 -0
  43. package/dist/files.d.ts +39 -0
  44. package/dist/files.d.ts.map +1 -0
  45. package/dist/files.js +146 -0
  46. package/dist/files.js.map +1 -0
  47. package/dist/headers.d.ts +81 -0
  48. package/dist/headers.d.ts.map +1 -0
  49. package/dist/headers.js +96 -0
  50. package/dist/headers.js.map +1 -0
  51. package/dist/host.d.ts +86 -0
  52. package/dist/host.d.ts.map +1 -0
  53. package/dist/host.js +590 -0
  54. package/dist/host.js.map +1 -0
  55. package/dist/js/ActivityRouter.d.ts +47 -0
  56. package/dist/js/ActivityRouter.d.ts.map +1 -0
  57. package/dist/js/ActivityRouter.js +87 -0
  58. package/dist/js/ActivityRouter.js.map +1 -0
  59. package/dist/js/DocumentTitle.d.ts +22 -0
  60. package/dist/js/DocumentTitle.d.ts.map +1 -0
  61. package/dist/js/DocumentTitle.js +28 -0
  62. package/dist/js/DocumentTitle.js.map +1 -0
  63. package/dist/js/Form.d.ts +54 -0
  64. package/dist/js/Form.d.ts.map +1 -0
  65. package/dist/js/Form.js +225 -0
  66. package/dist/js/Form.js.map +1 -0
  67. package/dist/js/Link.d.ts +29 -0
  68. package/dist/js/Link.d.ts.map +1 -0
  69. package/dist/js/Link.js +112 -0
  70. package/dist/js/Link.js.map +1 -0
  71. package/dist/js/PathnameProvider.d.ts +8 -0
  72. package/dist/js/PathnameProvider.d.ts.map +1 -0
  73. package/dist/js/PathnameProvider.js +24 -0
  74. package/dist/js/PathnameProvider.js.map +1 -0
  75. package/dist/js/RedirectBoundary.d.ts +18 -0
  76. package/dist/js/RedirectBoundary.d.ts.map +1 -0
  77. package/dist/js/RedirectBoundary.js +34 -0
  78. package/dist/js/RedirectBoundary.js.map +1 -0
  79. package/dist/js/SegmentBoundary.d.ts +8 -0
  80. package/dist/js/SegmentBoundary.d.ts.map +1 -0
  81. package/dist/js/SegmentBoundary.js +38 -0
  82. package/dist/js/SegmentBoundary.js.map +1 -0
  83. package/dist/js/SlotBoundary.d.ts +15 -0
  84. package/dist/js/SlotBoundary.d.ts.map +1 -0
  85. package/dist/js/SlotBoundary.js +17 -0
  86. package/dist/js/SlotBoundary.js.map +1 -0
  87. package/dist/js/createViteRscApp.d.ts +8 -0
  88. package/dist/js/createViteRscApp.d.ts.map +1 -0
  89. package/dist/js/createViteRscApp.js +185 -0
  90. package/dist/js/createViteRscApp.js.map +1 -0
  91. package/dist/js/errors.d.ts +51 -0
  92. package/dist/js/errors.d.ts.map +1 -0
  93. package/dist/js/errors.js +93 -0
  94. package/dist/js/errors.js.map +1 -0
  95. package/dist/js/navigate.d.ts +118 -0
  96. package/dist/js/navigate.d.ts.map +1 -0
  97. package/dist/js/navigate.js +780 -0
  98. package/dist/js/navigate.js.map +1 -0
  99. package/dist/js/onlineStore.d.ts +23 -0
  100. package/dist/js/onlineStore.d.ts.map +1 -0
  101. package/dist/js/onlineStore.js +42 -0
  102. package/dist/js/onlineStore.js.map +1 -0
  103. package/dist/js/pagePayload.d.ts +15 -0
  104. package/dist/js/pagePayload.d.ts.map +1 -0
  105. package/dist/js/pagePayload.js +39 -0
  106. package/dist/js/pagePayload.js.map +1 -0
  107. package/dist/js/router.d.ts +29 -0
  108. package/dist/js/router.d.ts.map +1 -0
  109. package/dist/js/router.js +40 -0
  110. package/dist/js/router.js.map +1 -0
  111. package/dist/js/section.d.ts +16 -0
  112. package/dist/js/section.d.ts.map +1 -0
  113. package/dist/js/section.js +55 -0
  114. package/dist/js/section.js.map +1 -0
  115. package/dist/js/segmentStore.d.ts +69 -0
  116. package/dist/js/segmentStore.d.ts.map +1 -0
  117. package/dist/js/segmentStore.js +127 -0
  118. package/dist/js/segmentStore.js.map +1 -0
  119. package/dist/js/slotStore.d.ts +35 -0
  120. package/dist/js/slotStore.d.ts.map +1 -0
  121. package/dist/js/slotStore.js +46 -0
  122. package/dist/js/slotStore.js.map +1 -0
  123. package/dist/js/standardSchema.d.ts +47 -0
  124. package/dist/js/standardSchema.d.ts.map +1 -0
  125. package/dist/js/standardSchema.js +41 -0
  126. package/dist/js/standardSchema.js.map +1 -0
  127. package/dist/js/useForm.d.ts +43 -0
  128. package/dist/js/useForm.d.ts.map +1 -0
  129. package/dist/js/useForm.js +164 -0
  130. package/dist/js/useForm.js.map +1 -0
  131. package/dist/js/useLinkStatus.d.ts +2 -0
  132. package/dist/js/useLinkStatus.d.ts.map +1 -0
  133. package/dist/js/useLinkStatus.js +3 -0
  134. package/dist/js/useLinkStatus.js.map +1 -0
  135. package/dist/js/useOnline.d.ts +8 -0
  136. package/dist/js/useOnline.d.ts.map +1 -0
  137. package/dist/js/useOnline.js +20 -0
  138. package/dist/js/useOnline.js.map +1 -0
  139. package/dist/js/usePathname.d.ts +9 -0
  140. package/dist/js/usePathname.d.ts.map +1 -0
  141. package/dist/js/usePathname.js +32 -0
  142. package/dist/js/usePathname.js.map +1 -0
  143. package/dist/js/useSearchParams.d.ts +2 -0
  144. package/dist/js/useSearchParams.d.ts.map +1 -0
  145. package/dist/js/useSearchParams.js +60 -0
  146. package/dist/js/useSearchParams.js.map +1 -0
  147. package/dist/manifest.d.ts +66 -0
  148. package/dist/manifest.d.ts.map +1 -0
  149. package/dist/manifest.js +13 -0
  150. package/dist/manifest.js.map +1 -0
  151. package/dist/prerender.d.ts +138 -0
  152. package/dist/prerender.d.ts.map +1 -0
  153. package/dist/prerender.js +387 -0
  154. package/dist/prerender.js.map +1 -0
  155. package/dist/redirect.d.ts +34 -0
  156. package/dist/redirect.d.ts.map +1 -0
  157. package/dist/redirect.js +108 -0
  158. package/dist/redirect.js.map +1 -0
  159. package/dist/redirectDigest.d.ts +20 -0
  160. package/dist/redirectDigest.d.ts.map +1 -0
  161. package/dist/redirectDigest.js +66 -0
  162. package/dist/redirectDigest.js.map +1 -0
  163. package/dist/request.d.ts +93 -0
  164. package/dist/request.d.ts.map +1 -0
  165. package/dist/request.js +308 -0
  166. package/dist/request.js.map +1 -0
  167. package/dist/revalidate.d.ts +43 -0
  168. package/dist/revalidate.d.ts.map +1 -0
  169. package/dist/revalidate.js +99 -0
  170. package/dist/revalidate.js.map +1 -0
  171. package/dist/routes.d.ts +37 -0
  172. package/dist/routes.d.ts.map +1 -0
  173. package/dist/routes.js +28 -0
  174. package/dist/routes.js.map +1 -0
  175. package/dist/routing.d.ts +52 -0
  176. package/dist/routing.d.ts.map +1 -0
  177. package/dist/routing.js +146 -0
  178. package/dist/routing.js.map +1 -0
  179. package/dist/runtime.d.ts +36 -0
  180. package/dist/runtime.d.ts.map +1 -0
  181. package/dist/runtime.js +86 -0
  182. package/dist/runtime.js.map +1 -0
  183. package/dist/safeUrl.d.ts +10 -0
  184. package/dist/safeUrl.d.ts.map +1 -0
  185. package/dist/safeUrl.js +40 -0
  186. package/dist/safeUrl.js.map +1 -0
  187. package/dist/streaming.d.ts +62 -0
  188. package/dist/streaming.d.ts.map +1 -0
  189. package/dist/streaming.js +108 -0
  190. package/dist/streaming.js.map +1 -0
  191. package/dist/types.d.ts +82 -0
  192. package/dist/vite.d.ts +109 -0
  193. package/dist/vite.d.ts.map +1 -0
  194. package/dist/vite.js +1909 -0
  195. package/dist/vite.js.map +1 -0
  196. package/dist/worker.d.ts +2 -0
  197. package/dist/worker.d.ts.map +1 -0
  198. package/dist/worker.js +863 -0
  199. package/dist/worker.js.map +1 -0
  200. 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