@rsc-kit/core 0.30.20 → 0.30.22

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.
@@ -320,10 +320,12 @@ export function setCallServer(fn) {
320
320
  callServerFn = fn;
321
321
  }
322
322
  /**
323
- * The urls a route.ts answers, so a link to one is treated as the anchor it
324
- * is: no prefetch - a hover must not run a route - and a full navigation
325
- * rather than a payload fetch, since the answer is a Response, not a page.
326
- * Baked into the generated browser entry from the route tree.
323
+ * The urls a route.ts answers, and everything under a prefix the app named in
324
+ * `backendPaths`, so a link to one is treated as the anchor it is: no prefetch
325
+ * - a hover must not run a route, or start an OAuth flow - and a full
326
+ * navigation rather than a payload fetch, since the answer is a Response or
327
+ * the backend's, not a page. Baked into the generated browser entry from the
328
+ * route tree and the config.
327
329
  */
328
330
  let apiRoutePatterns = [];
329
331
  export function setApiRoutes(patterns) {
@@ -334,7 +336,22 @@ export function setApiRoutes(patterns) {
334
336
  .join("/") +
335
337
  "/?$"));
336
338
  }
337
- /** Whether a url is one a route.ts answers. */
339
+ /**
340
+ * Leave for a document load, and say why.
341
+ *
342
+ * In place of the entry being left when the navigation was asked to replace
343
+ * it. A redirect from inside a boundary is performed after its page was
344
+ * shown and pushed, and `href` would keep that url in history: Back from where
345
+ * it went returns to a page that redirects again.
346
+ */
347
+ function loadDocument(url, reason, replace) {
348
+ announceDocumentLoad(url, reason);
349
+ if (replace)
350
+ window.location.replace(url);
351
+ else
352
+ window.location.href = url;
353
+ }
354
+ /** Whether a url is one a route.ts answers, or the backend's by name. */
338
355
  export function isApiRoute(url) {
339
356
  if (apiRoutePatterns.length === 0)
340
357
  return false;
@@ -500,7 +517,10 @@ notFound = false) {
500
517
  * hoists document metadata into <head>. Nothing needs injecting from headers.
501
518
  */
502
519
  function deserializeResponse(response) {
503
- return flightDeserializer(response.body, {
520
+ return deserializeStream(response.body);
521
+ }
522
+ function deserializeStream(stream) {
523
+ return flightDeserializer(stream, {
504
524
  callServer: callServerFn ??
505
525
  (async () => {
506
526
  throw new Error("Server actions not initialized");
@@ -578,8 +598,7 @@ export async function navigate(url, opts) {
578
598
  // advice. Not for a restore: going back to a page still held asks the
579
599
  // server for nothing.
580
600
  if (!opts?.restore && isUpdated()) {
581
- announceDocumentLoad(url, "newer-build");
582
- window.location.href = url;
601
+ loadDocument(url, "newer-build", opts?.replace);
583
602
  return;
584
603
  }
585
604
  if (redirectsFollowed > MAX_REDIRECTS) {
@@ -587,15 +606,15 @@ export async function navigate(url, opts) {
587
606
  }
588
607
  // External URLs can't be fetched (CORS) — go directly to full page navigation
589
608
  if (isExternalUrl(url)) {
590
- announceDocumentLoad(url, "external");
591
- window.location.href = url;
609
+ loadDocument(url, "external", opts?.replace);
592
610
  return;
593
611
  }
594
612
  // A route.ts answers with a Response, not a page: a download, a redirect
595
613
  // that decides where someone belongs, a sign-out. The browser goes there.
614
+ // So does a url the app named as its backend's, which may bounce to another
615
+ // site - an OAuth start - that a fetch cannot follow.
596
616
  if (isApiRoute(url)) {
597
- announceDocumentLoad(url, "api-route");
598
- window.location.href = url;
617
+ loadDocument(url, "api-route", opts?.replace);
599
618
  return;
600
619
  }
601
620
  // Hash-only URLs — let the browser handle scrolling natively
@@ -713,7 +732,6 @@ export async function navigate(url, opts) {
713
732
  // open over the page behind it.
714
733
  const usable = isUsable(cached, chain);
715
734
  if (usable) {
716
- treePromise = cached.tree;
717
735
  // Read after the tree resolves, not now. A prefetch fills these in when
718
736
  // its response lands, and a click can land first — hover a modal link
719
737
  // and click it quickly and the entry still says depth 0, so the
@@ -722,6 +740,26 @@ export async function navigate(url, opts) {
722
740
  // waiting for it is what makes them true.
723
741
  reused = cached;
724
742
  cache.delete(cacheKey);
743
+ if (reused.settled) {
744
+ treePromise = reused.tree;
745
+ }
746
+ else {
747
+ // Still arriving, so read it as it comes - see follow(). The request
748
+ // is this navigation's from here: leaving the link, or an action
749
+ // dropping the other prefetches, must not cut short a page the
750
+ // visitor is waiting on, and being overtaken by another navigation
751
+ // must stop it as it would a request of its own.
752
+ const arriving = reused;
753
+ prefetchControllers.delete(cacheKey);
754
+ controller.signal.addEventListener("abort", () => arriving.cancel(), { once: true });
755
+ // A request that dies is asked again, as a prefetch that died before
756
+ // the click is; the entry says which it was once it has settled.
757
+ treePromise = arriving.follow().catch((error) => arriving.body.then(() => {
758
+ if (arriving.failed)
759
+ return null;
760
+ throw error;
761
+ }));
762
+ }
725
763
  }
726
764
  else {
727
765
  cache.delete(cacheKey);
@@ -762,8 +800,7 @@ export async function navigate(url, opts) {
762
800
  const contentType = response.headers.get("Content-Type") ?? "";
763
801
  if (staticPayloadSuffix === null &&
764
802
  !contentType.includes("text/x-component")) {
765
- announceDocumentLoad(url, `not-a-payload:${response.status}:${contentType.split(";")[0]}`);
766
- window.location.href = url;
803
+ loadDocument(url, `not-a-payload:${response.status}:${contentType.split(";")[0]}`, opts?.replace);
767
804
  return;
768
805
  }
769
806
  // Headers on a server; worked out locally on a static host, where there
@@ -850,11 +887,12 @@ export async function navigate(url, opts) {
850
887
  // moved: the entry left behind can never be gone back to, and mounting a
851
888
  // new one showed every fallback in the page - a new chat's first message
852
889
  // redirects /chat to /chat?c=25 this way. A push keeps its own page, so
853
- // Back still restores the one it came from.
854
- const inPlaceOf = opts?.replace && !interceptSlot && new URL(url, window.location.origin).pathname === new URL(previousUrl, window.location.origin).pathname
890
+ // Back still restores the one it came from - except a search on the page
891
+ // it is on, which asks for the same place and keeps the old page behind it.
892
+ const inPlaceOf = (opts?.replace || opts?.inPlace) && !interceptSlot && new URL(url, window.location.origin).pathname === new URL(previousUrl, window.location.origin).pathname
855
893
  ? retentionKey(previousUrl, null)
856
894
  : null;
857
- onNavigate?.(tree, activityKey, segmentDepth, inPlaceOf);
895
+ onNavigate?.(tree, activityKey, segmentDepth, inPlaceOf, !opts?.replace);
858
896
  if (!opts?.preserveScroll && !interceptSlot) {
859
897
  // Wait for React to commit the DOM update before scrolling.
860
898
  // Intercepted navigations preserve scroll (e.g. modal over current page).
@@ -871,8 +909,7 @@ export async function navigate(url, opts) {
871
909
  // A chunk the deploy no longer serves: the browser would have loaded the
872
910
  // document, and the new names with it. Do what it would have done.
873
911
  if (isStaleAssetError(err)) {
874
- announceDocumentLoad(url, `stale-asset:${String(err?.message ?? err).slice(0, 120)}`);
875
- window.location.href = url;
912
+ loadDocument(url, `stale-asset:${String(err?.message ?? err).slice(0, 120)}`, opts?.replace);
876
913
  return;
877
914
  }
878
915
  // Without this a navigation that fails does nothing observable: the click
@@ -1172,6 +1209,11 @@ function warm(entry, cacheKey, followed = new Set()) {
1172
1209
  return;
1173
1210
  // The page about to show: its pictures ahead of every other page's.
1174
1211
  preloadImages(text, "high");
1212
+ // Taken by a click before it had all arrived, which read it as it came:
1213
+ // the page is on its way to the screen, and decoding it again here would
1214
+ // be the same chunks asked for twice.
1215
+ if (cache.get(cacheKey) !== entry)
1216
+ return;
1175
1217
  return entry.tree.then((tree) => {
1176
1218
  if (entry.slot !== null ||
1177
1219
  entry.segmentDepth === 0 ||
@@ -1200,11 +1242,24 @@ function prefetchUrl(cacheKey, url, ttl, interceptSlot, refererUrl, held = { int
1200
1242
  const controller = new AbortController();
1201
1243
  prefetchControllers.set(cacheKey, controller);
1202
1244
  let decoded = null;
1245
+ // Settled once the response has said whether there is a page to follow: its
1246
+ // bytes as they arrive, or nothing for a redirect or a failure.
1247
+ let arriving;
1248
+ let followed = false;
1249
+ const followable = new Promise((resolve) => {
1250
+ arriving = resolve;
1251
+ });
1203
1252
  const entry = {
1204
1253
  body: Promise.resolve(null),
1205
1254
  get tree() {
1206
1255
  return (decoded ??= this.body.then((text) => (text === null ? null : deserializeResponse(new Response(text)))));
1207
1256
  },
1257
+ settled: false,
1258
+ follow() {
1259
+ followed = true;
1260
+ return followable.then((stream) => (stream ? deserializeStream(stream) : null));
1261
+ },
1262
+ cancel: () => controller.abort(),
1208
1263
  expiresAt: Date.now() + ttl,
1209
1264
  segmentDepth: 0,
1210
1265
  layouts: null,
@@ -1237,6 +1292,7 @@ function prefetchUrl(cacheKey, url, ttl, interceptSlot, refererUrl, held = { int
1237
1292
  entry.redirectTo = redirectTo;
1238
1293
  if (!isExternalUrl(redirectTo) && isSafeRedirect(redirectTo))
1239
1294
  prefetch(redirectTo, ttl);
1295
+ arriving(null);
1240
1296
  return null;
1241
1297
  }
1242
1298
  entry.slot = response.headers.get("X-RSC-Revalidate");
@@ -1261,7 +1317,16 @@ function prefetchUrl(cacheKey, url, ttl, interceptSlot, refererUrl, held = { int
1261
1317
  // - unless the visitor is already on the way, see prefetch(). The
1262
1318
  // pictures the page shows are asked for now, though: on a phone they
1263
1319
  // take longer than the touch-to-click a hidden render has.
1320
+ //
1321
+ // Read twice: whole, for the cache, and from the first byte by a click
1322
+ // that lands before the last of them - see follow(). The second read is
1323
+ // let go as soon as the first is complete and nobody took it.
1324
+ const copy = response.clone().body;
1325
+ arriving(copy);
1264
1326
  return response.text().then((text) => {
1327
+ entry.settled = true;
1328
+ if (!followed)
1329
+ copy?.cancel();
1265
1330
  preloadImages(text);
1266
1331
  // And the code for its client components, so the first visit to a
1267
1332
  // page does not wait on a chunk after it has arrived.
@@ -1272,6 +1337,7 @@ function prefetchUrl(cacheKey, url, ttl, interceptSlot, refererUrl, held = { int
1272
1337
  .catch(() => {
1273
1338
  entry.failed = true;
1274
1339
  cache.delete(cacheKey);
1340
+ arriving(null);
1275
1341
  return null;
1276
1342
  })
1277
1343
  .finally(() => {