@rsc-kit/core 0.20.6 → 0.20.8

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 (65) hide show
  1. package/dist/cache.d.ts +11 -0
  2. package/dist/cache.js +13 -0
  3. package/dist/cache.js.map +1 -1
  4. package/dist/clientEntries.js +4 -1
  5. package/dist/clientEntries.js.map +1 -1
  6. package/dist/files.d.ts +38 -0
  7. package/dist/files.js +109 -0
  8. package/dist/files.js.map +1 -1
  9. package/dist/host.d.ts +11 -2
  10. package/dist/host.js +37 -6
  11. package/dist/host.js.map +1 -1
  12. package/dist/js/ActivityRouter.d.ts +11 -0
  13. package/dist/js/ActivityRouter.js +24 -4
  14. package/dist/js/ActivityRouter.js.map +1 -1
  15. package/dist/js/Form.js +24 -2
  16. package/dist/js/Form.js.map +1 -1
  17. package/dist/js/Link.d.ts +1 -1
  18. package/dist/js/Link.js +25 -9
  19. package/dist/js/Link.js.map +1 -1
  20. package/dist/js/PageTransition.d.ts +27 -0
  21. package/dist/js/PageTransition.js +57 -0
  22. package/dist/js/PageTransition.js.map +1 -0
  23. package/dist/js/PathnameProvider.d.ts +15 -2
  24. package/dist/js/PathnameProvider.js +17 -2
  25. package/dist/js/PathnameProvider.js.map +1 -1
  26. package/dist/js/SegmentBoundary.js +27 -3
  27. package/dist/js/SegmentBoundary.js.map +1 -1
  28. package/dist/js/activityMarkers.d.ts +7 -0
  29. package/dist/js/activityMarkers.js +18 -0
  30. package/dist/js/activityMarkers.js.map +1 -0
  31. package/dist/js/createViteRscApp.js +122 -76
  32. package/dist/js/createViteRscApp.js.map +1 -1
  33. package/dist/js/earlyClicks.d.ts +3 -1
  34. package/dist/js/earlyClicks.js +28 -5
  35. package/dist/js/earlyClicks.js.map +1 -1
  36. package/dist/js/errors.d.ts +2 -0
  37. package/dist/js/errors.js +11 -0
  38. package/dist/js/errors.js.map +1 -1
  39. package/dist/js/imagePreload.d.ts +37 -0
  40. package/dist/js/imagePreload.js +116 -0
  41. package/dist/js/imagePreload.js.map +1 -0
  42. package/dist/js/navigate.d.ts +47 -1
  43. package/dist/js/navigate.js +249 -10
  44. package/dist/js/navigate.js.map +1 -1
  45. package/dist/js/perf.d.ts +25 -0
  46. package/dist/js/perf.js +57 -0
  47. package/dist/js/perf.js.map +1 -0
  48. package/dist/js/router.d.ts +10 -1
  49. package/dist/js/router.js +9 -2
  50. package/dist/js/router.js.map +1 -1
  51. package/dist/js/segmentStore.d.ts +52 -0
  52. package/dist/js/segmentStore.js +123 -2
  53. package/dist/js/segmentStore.js.map +1 -1
  54. package/dist/js/staleAssets.d.ts +10 -0
  55. package/dist/js/staleAssets.js +23 -2
  56. package/dist/js/staleAssets.js.map +1 -1
  57. package/dist/js/viewportPrefetch.d.ts +19 -10
  58. package/dist/js/viewportPrefetch.js +50 -15
  59. package/dist/js/viewportPrefetch.js.map +1 -1
  60. package/dist/shellHead.d.ts +22 -0
  61. package/dist/shellHead.js +43 -0
  62. package/dist/shellHead.js.map +1 -0
  63. package/dist/vite.js +250 -55
  64. package/dist/vite.js.map +1 -1
  65. package/package.json +5 -1
@@ -5,8 +5,10 @@
5
5
  * The Flight deserializer is injected by createViteRscApp to avoid
6
6
  * duplicate bundling of react-server-dom-webpack.
7
7
  */
8
- import { isStaleAssetError, loadDocumentOnce } from "./staleAssets";
8
+ import { announceDocumentLoad, isStaleAssetError, loadDocumentOnce, } from "./staleAssets";
9
9
  import { isUpdated, markStale } from "./updateStore";
10
+ import { navigationAbandoned, navigationCommitted, navigationReached, navigationStarted } from "./perf";
11
+ import { preloadImages } from "./imagePreload";
10
12
  import { isSafeRedirect } from "../safeUrl.js";
11
13
  import { reportReachable } from "./onlineStore";
12
14
  import { clearSlots, setSlot } from "./slotStore";
@@ -18,6 +20,22 @@ let version = "";
18
20
  const navigating = new Set();
19
21
  let onNavigate = null;
20
22
  let onRestore = null;
23
+ /**
24
+ * Re-render the whole document in place, for revalidate("all"): the page the
25
+ * visitor is on, not a page they went to. Without one registered, the
26
+ * navigation handler at depth 0 is used, which shows the tree as a page.
27
+ */
28
+ let onReplaceRoot = null;
29
+ /** Whether a navigation to the key would reveal a held page, asked without revealing it. */
30
+ let isHeldPage = null;
31
+ /** Drop the pages held behind the one on screen - after a mutation. */
32
+ let dropHeld = null;
33
+ /**
34
+ * Render a decoded page in the background, hidden, before the click - see
35
+ * warm(). Given the same tree the navigation will hand to onNavigate, so
36
+ * that the navigation is a reveal of work already done rather than a render.
37
+ */
38
+ let onPrerender = null;
21
39
  /**
22
40
  * How stale a held page may be and still be revealed by a link.
23
41
  *
@@ -41,6 +59,33 @@ let interceptManifest = [];
41
59
  // The layout chain currently mounted, outermost first. Sent so the server can
42
60
  // skip re-rendering the layouts still on screen.
43
61
  let heldLayouts = [];
62
+ /**
63
+ * The chain each page was shown under, by retention key.
64
+ *
65
+ * A held page revealed - the back button, or a link to the page just left -
66
+ * puts its layouts back on screen, and the chain has to say so. It used to
67
+ * keep the chain of the page being left: home, a category, back to home,
68
+ * then another category claimed the first category's layout as mounted. The
69
+ * server sent the page alone, and the client put it in the boundary that
70
+ * layout owns - inside the hidden category. The url changed and the page did
71
+ * not, and every tap after it did the same, until a reload. The demo froze
72
+ * on a phone within a dozen taps.
73
+ *
74
+ * Bounded like the payload cache: a key that is no longer held is never
75
+ * asked for, so the oldest can go.
76
+ */
77
+ const chainOf = new Map();
78
+ const MAX_CHAINS = 64;
79
+ function rememberChain(key) {
80
+ chainOf.delete(key);
81
+ chainOf.set(key, heldLayouts);
82
+ while (chainOf.size > MAX_CHAINS) {
83
+ const oldest = chainOf.keys().next().value;
84
+ if (oldest === undefined)
85
+ break;
86
+ chainOf.delete(oldest);
87
+ }
88
+ }
44
89
  /**
45
90
  * The boundary depth an interception was rendered at, while one is showing.
46
91
  *
@@ -60,7 +105,22 @@ let interceptedAtDepth = null;
60
105
  * screen with everything the user typed into it.
61
106
  */
62
107
  let interceptedOver = null;
108
+ /**
109
+ * The url an interception was opened from, whichever way it was rendered -
110
+ * the page still on screen under the modal. Only stillShowing asks.
111
+ */
112
+ let interceptedFrom = null;
63
113
  const DEFAULT_PREFETCH_TTL = 30_000;
114
+ /**
115
+ * How long a payload the host marked `public` is kept.
116
+ *
117
+ * The same build-time bytes for everyone, so the only way it changes is a
118
+ * deploy, and the version handshake catches that on the tap. Thirty seconds
119
+ * was Next's figure for a dynamic page; its static pages are held five
120
+ * minutes, and a landing page read for a minute before the tap on Sign in
121
+ * was, at thirty, an expired entry and a round trip on a phone.
122
+ */
123
+ const STATIC_PREFETCH_TTL = 300_000;
64
124
  /**
65
125
  * How many prefetched payloads are kept. Entries carry a TTL but were only
66
126
  * dropped when read again, so a long session hovering its way through a big
@@ -119,6 +179,7 @@ export function seedStaticChain(url) {
119
179
  if (!segments)
120
180
  return false;
121
181
  heldLayouts = segments.chain;
182
+ rememberChain(retentionKey(url, null));
122
183
  return true;
123
184
  }
124
185
  /**
@@ -207,6 +268,7 @@ async function tellWorkerServerBuild(build) {
207
268
  */
208
269
  export function setHeldLayouts(chain) {
209
270
  heldLayouts = chain;
271
+ rememberChain(retentionKey(window.location.href, null));
210
272
  }
211
273
  export function getHeldLayouts() {
212
274
  return heldLayouts;
@@ -214,6 +276,34 @@ export function getHeldLayouts() {
214
276
  export function setNavigateHandler(fn) {
215
277
  onNavigate = fn;
216
278
  }
279
+ export function setReplaceRootHandler(fn) {
280
+ onReplaceRoot = fn;
281
+ }
282
+ export function setHeldHandlers(held, drop) {
283
+ isHeldPage = held;
284
+ dropHeld = drop;
285
+ }
286
+ /**
287
+ * Everything the router holds about pages other than the one on screen,
288
+ * dropped: prefetched payloads, and pages kept behind this one.
289
+ *
290
+ * After an action that revalidated. The list a link prefetched before the
291
+ * mutation still shows the table without the new row - visit() landed on
292
+ * it with no request made, and a reload showed the row. What was fetched
293
+ * before a write is not a cache of what is true after it; the pages held
294
+ * for the back button are from before it too.
295
+ */
296
+ export function forgetOtherPages() {
297
+ for (const [key, controller] of prefetchControllers) {
298
+ controller.abort();
299
+ prefetchControllers.delete(key);
300
+ }
301
+ cache.clear();
302
+ dropHeld?.();
303
+ }
304
+ export function setPrerenderHandler(fn) {
305
+ onPrerender = fn;
306
+ }
217
307
  /**
218
308
  * How the router reveals a page that is still mounted behind the current one.
219
309
  *
@@ -480,6 +570,7 @@ export async function navigate(url, opts) {
480
570
  // advice. Not for a restore: going back to a page still held asks the
481
571
  // server for nothing.
482
572
  if (!opts?.restore && isUpdated()) {
573
+ announceDocumentLoad(url, "newer-build");
483
574
  window.location.href = url;
484
575
  return;
485
576
  }
@@ -488,12 +579,14 @@ export async function navigate(url, opts) {
488
579
  }
489
580
  // External URLs can't be fetched (CORS) — go directly to full page navigation
490
581
  if (isExternalUrl(url)) {
582
+ announceDocumentLoad(url, "external");
491
583
  window.location.href = url;
492
584
  return;
493
585
  }
494
586
  // A route.ts answers with a Response, not a page: a download, a redirect
495
587
  // that decides where someone belongs, a sign-out. The browser goes there.
496
588
  if (isApiRoute(url)) {
589
+ announceDocumentLoad(url, "api-route");
497
590
  window.location.href = url;
498
591
  return;
499
592
  }
@@ -504,6 +597,7 @@ export async function navigate(url, opts) {
504
597
  }
505
598
  // Abort any in-flight navigation
506
599
  activeController?.abort();
600
+ navigationStarted(url);
507
601
  // Not window.stop(). It used to be called here while the document was
508
602
  // still loading, to free a single-threaded server for the new request -
509
603
  // and it cancels every load the page has in flight: the client chunks a
@@ -538,6 +632,7 @@ export async function navigate(url, opts) {
538
632
  retentionKey(url, null) === retentionKey(interceptedOver, null)) {
539
633
  clearSlots();
540
634
  interceptedOver = null;
635
+ interceptedFrom = null;
541
636
  interceptedAtDepth = null;
542
637
  if (opts?.replace) {
543
638
  history.replaceState({ rscUrl: url }, "", url);
@@ -546,6 +641,7 @@ export async function navigate(url, opts) {
546
641
  history.pushState({ rscUrl: url }, "", url);
547
642
  }
548
643
  window.dispatchEvent(new CustomEvent("rsc-navigate", { detail: url }));
644
+ navigationCommitted();
549
645
  return;
550
646
  }
551
647
  // Not when the target is the page already showing: asking for this page
@@ -568,8 +664,14 @@ export async function navigate(url, opts) {
568
664
  onRestore?.(activityKey, opts?.restore ? undefined : revealWithin)) {
569
665
  // A restored tree carries its own slot contents, so the flag only has to
570
666
  // reflect whether what is now showing is an intercepted view.
571
- if (!interceptSlot)
667
+ if (!interceptSlot) {
572
668
  interceptedAtDepth = null;
669
+ interceptedFrom = null;
670
+ }
671
+ // Its layouts are the ones on screen now - see chainOf.
672
+ const chain = chainOf.get(activityKey);
673
+ if (chain)
674
+ heldLayouts = chain;
573
675
  // opts?.replace, not opts.replace: this branch used to be reachable only
574
676
  // with opts.restore set, so opts was always there. A link reaches it now
575
677
  // with nothing passed at all.
@@ -580,6 +682,7 @@ export async function navigate(url, opts) {
580
682
  history.pushState({ rscUrl: url }, "", url);
581
683
  }
582
684
  window.dispatchEvent(new CustomEvent("rsc-navigate", { detail: url }));
685
+ navigationCommitted();
583
686
  return;
584
687
  }
585
688
  // A prefetched payload was rendered against the chain held at prefetch time.
@@ -651,6 +754,7 @@ export async function navigate(url, opts) {
651
754
  const contentType = response.headers.get("Content-Type") ?? "";
652
755
  if (staticPayloadSuffix === null &&
653
756
  !contentType.includes("text/x-component")) {
757
+ announceDocumentLoad(url, `not-a-payload:${response.status}:${contentType.split(";")[0]}`);
654
758
  window.location.href = url;
655
759
  return;
656
760
  }
@@ -673,6 +777,7 @@ export async function navigate(url, opts) {
673
777
  let tree;
674
778
  try {
675
779
  tree = await treePromise;
780
+ navigationReached("decoded");
676
781
  }
677
782
  catch (error) {
678
783
  // A navigation another one overtook: its request was aborted, and
@@ -714,6 +819,7 @@ export async function navigate(url, opts) {
714
819
  }
715
820
  if (nextLayouts !== null)
716
821
  heldLayouts = nextLayouts;
822
+ rememberChain(activityKey);
717
823
  // The answer is one region, not a piece of the page: the host rendered
718
824
  // only the interceptor because the page underneath is already mounted and
719
825
  // still correct. Putting it in the slot leaves that page — and everything
@@ -721,6 +827,7 @@ export async function navigate(url, opts) {
721
827
  if (slotPayload !== null) {
722
828
  setSlot(slotPayload, tree);
723
829
  interceptedOver = interceptedOver ?? previousUrl;
830
+ interceptedFrom = interceptedFrom ?? previousUrl;
724
831
  interceptedAtDepth = null;
725
832
  return;
726
833
  }
@@ -729,6 +836,8 @@ export async function navigate(url, opts) {
729
836
  clearSlots();
730
837
  interceptedOver = null;
731
838
  interceptedAtDepth = interceptSlot ? segmentDepth : null;
839
+ interceptedFrom = interceptSlot ? (interceptedFrom ?? previousUrl) : null;
840
+ navigationReached("applied");
732
841
  onNavigate?.(tree, activityKey, segmentDepth);
733
842
  if (!opts?.preserveScroll && !interceptSlot) {
734
843
  // Wait for React to commit the DOM update before scrolling.
@@ -740,11 +849,13 @@ export async function navigate(url, opts) {
740
849
  window.dispatchEvent(new CustomEvent("rsc-navigate", { detail: url }));
741
850
  }
742
851
  catch (err) {
852
+ navigationAbandoned();
743
853
  if (err instanceof DOMException && err.name === "AbortError")
744
854
  return;
745
855
  // A chunk the deploy no longer serves: the browser would have loaded the
746
856
  // document, and the new names with it. Do what it would have done.
747
857
  if (isStaleAssetError(err)) {
858
+ announceDocumentLoad(url, `stale-asset:${String(err?.message ?? err).slice(0, 120)}`);
748
859
  window.location.href = url;
749
860
  return;
750
861
  }
@@ -768,12 +879,51 @@ export async function navigate(url, opts) {
768
879
  * is the same apply path a navigation uses — without a request, a url change
769
880
  * or a history entry.
770
881
  */
882
+ /**
883
+ * Whether the page an action was invoked from is the one on screen.
884
+ *
885
+ * The one underneath, when an interception is showing: a modal opened after
886
+ * the submit sits over the same page.
887
+ */
888
+ export function stillShowing(url) {
889
+ const key = retentionKey(url, null);
890
+ const now = retentionKey(window.location.pathname + window.location.search, null);
891
+ return key === now || (interceptedFrom !== null && key === retentionKey(interceptedFrom, null));
892
+ }
893
+ /**
894
+ * Put what an action re-rendered on screen.
895
+ *
896
+ * `from` is the url the action was invoked on - what the host rendered the
897
+ * trees for. A tap that left the page while the action was in flight has
898
+ * changed what is showing, and a document rendered for the page before
899
+ * cannot go under the url after: "Add to cart", then the brand link at
900
+ * once, showed the home page and then, when the answer landed, the product
901
+ * again under `/`. The write still happened, and the page on screen was
902
+ * fetched before it - so that page is asked for again, whole, instead.
903
+ */
904
+ export function applyRevalidations(from, revalidated) {
905
+ // What was fetched or held before the write is from before it.
906
+ forgetOtherPages();
907
+ if (!stillShowing(from)) {
908
+ void refresh("all");
909
+ return;
910
+ }
911
+ for (const [target, tree] of Object.entries(revalidated)) {
912
+ applyRevalidated(target, tree);
913
+ }
914
+ }
771
915
  export function applyRevalidated(target, tree) {
772
916
  const url = window.location.pathname + window.location.search;
773
917
  const key = retentionKey(url, null);
774
918
  if (target === "all") {
775
- // Depth 0 replaces the root, which is what re-rendering the layouts means.
776
- onNavigate?.(tree, key, 0);
919
+ // The whole document again, in place. Not a navigation to this url:
920
+ // the visible entry may be keyed by the url the document loaded with,
921
+ // and showing the tree under the current one made a second entry and
922
+ // remounted the app under it.
923
+ if (onReplaceRoot)
924
+ onReplaceRoot(tree);
925
+ else
926
+ onNavigate?.(tree, key, 0);
777
927
  return;
778
928
  }
779
929
  if (target === "page") {
@@ -801,6 +951,9 @@ export function applyRevalidated(target, tree) {
801
951
  */
802
952
  export async function refresh(target = "page") {
803
953
  const url = window.location.pathname + window.location.search;
954
+ // Asked because the data moved on; what was fetched or held before is
955
+ // from before.
956
+ forgetOtherPages();
804
957
  if (target !== "page" && target !== "all") {
805
958
  const response = await fetch(payloadUrl(url), {
806
959
  headers: {
@@ -903,7 +1056,20 @@ function restoreScroll(positions) {
903
1056
  };
904
1057
  apply(1);
905
1058
  }
906
- export function prefetch(url, cacheForMs) {
1059
+ /**
1060
+ * Fetch a page's payload ahead of the navigation that may ask for it.
1061
+ *
1062
+ * `intent` is the difference between a link that came into view and one the
1063
+ * visitor is about to follow: a hover that settled, a touch. Only then is the
1064
+ * payload decoded on arrival - decoding is what loads the client chunks the
1065
+ * page names, and a viewport prefetch that decoded put the sign-in page's
1066
+ * thirty chunks onto a landing page for every phone visitor. A touch leads
1067
+ * its click by 100-300 ms, about a round trip, and a tap that found the
1068
+ * bytes here but not the chunks spent that round trip after the click
1069
+ * instead of before it: the tap-to-paint that was "not zero" beside Next,
1070
+ * whose prefetch decodes as it lands.
1071
+ */
1072
+ export function prefetch(url, cacheForMs, intent = false) {
907
1073
  // Never a route.ts: fetching one runs it, and a hover is not a click.
908
1074
  if (isApiRoute(url))
909
1075
  return;
@@ -911,6 +1077,11 @@ export function prefetch(url, cacheForMs) {
911
1077
  // in view, was a 14 KB payload for the page already on screen.
912
1078
  if (retentionKey(url, null) === retentionKey(window.location.href, null))
913
1079
  return;
1080
+ // Nor a page still held behind this one, which a navigation would reveal
1081
+ // rather than fetch: the page just left, whose link is on every page,
1082
+ // was one wasted payload per navigation.
1083
+ if (!matchIntercept(url) && isHeldPage?.(retentionKey(url, null), revealWithin))
1084
+ return;
914
1085
  // Nor anything, once this page is known to be the previous build: the
915
1086
  // next navigation is a document load, and a payload it would not use is
916
1087
  // a 409 for nothing - one per tap, measured.
@@ -920,20 +1091,73 @@ export function prefetch(url, cacheForMs) {
920
1091
  return;
921
1092
  const ttl = cacheForMs ?? DEFAULT_PREFETCH_TTL;
922
1093
  const interceptSlot = matchIntercept(url);
1094
+ const held = { intent, explicitTtl: cacheForMs !== undefined };
923
1095
  if (interceptSlot) {
924
1096
  // Intercepted route — only prefetch the intercepted variant
925
1097
  const currentUrl = window.location.pathname + window.location.search;
926
1098
  const cacheKey = retentionKeyFor(url, interceptSlot);
927
- prefetchUrl(cacheKey, url, ttl, interceptSlot, currentUrl);
1099
+ prefetchUrl(cacheKey, url, ttl, interceptSlot, currentUrl, held);
928
1100
  }
929
1101
  else {
930
- prefetchUrl(url, url, ttl);
1102
+ prefetchUrl(retentionKeyFor(url, null), url, ttl, undefined, undefined, held);
931
1103
  }
932
1104
  }
933
- function prefetchUrl(cacheKey, url, ttl, interceptSlot, refererUrl) {
1105
+ /**
1106
+ * Do the click's work now: decode the held payload, which loads the chunks
1107
+ * it names, and render the page hidden so the click is a reveal.
1108
+ *
1109
+ * The numbers this came from, on an iPhone with everything prefetched: a
1110
+ * first visit to the landing page was 107 ms from tap to paint, 4 of them
1111
+ * decoding and 87 rendering; a page still held from a visit before was
1112
+ * 15 ms, a reveal. The render is the page's size on the phone's CPU, and
1113
+ * nothing in the payload's path shrinks it - but it can be paid before the
1114
+ * finger lifts. A touch leads its click by 100-300 ms; a settled hover, by
1115
+ * 200 or more. React renders a hidden Activity at idle priority, so the
1116
+ * work yields to scrolling, and the navigation then hands the boundary the
1117
+ * same tree it prerendered: React bails out of the whole subtree and flips
1118
+ * it visible.
1119
+ *
1120
+ * Only a segment at depth 1 or more, rendered against the chain held now: a
1121
+ * whole document replaces the root, and a slot is a region of a page not on
1122
+ * screen yet. A prefetch that landed on a redirect - a guarded link, on a
1123
+ * landing page all of them - warms the destination it prefetched instead:
1124
+ * the tap on Sign in decoded the login page and loaded its chunks after the
1125
+ * click, 154 ms of a 225 ms tap.
1126
+ */
1127
+ function warm(entry, cacheKey) {
1128
+ // The tree is read by the navigation that takes the entry, and a decode
1129
+ // that fails - a chunk the deploy no longer serves - fails there, where it
1130
+ // is handled. Here the rejection has nobody to reach.
1131
+ entry.body
1132
+ .then((text) => {
1133
+ if (entry.redirectTo) {
1134
+ const destination = cache.get(retentionKeyFor(entry.redirectTo, matchIntercept(entry.redirectTo)));
1135
+ if (destination)
1136
+ warm(destination, retentionKeyFor(entry.redirectTo, matchIntercept(entry.redirectTo)));
1137
+ return;
1138
+ }
1139
+ if (text === null)
1140
+ return;
1141
+ // The page about to show: its pictures ahead of every other page's.
1142
+ preloadImages(text, "high");
1143
+ return entry.tree.then((tree) => {
1144
+ if (entry.slot !== null ||
1145
+ entry.segmentDepth === 0 ||
1146
+ !isUsable(entry, claimedChain(null)) ||
1147
+ cache.get(cacheKey) !== entry)
1148
+ return;
1149
+ onPrerender?.(tree, cacheKey, entry.segmentDepth);
1150
+ });
1151
+ })
1152
+ .catch(() => { });
1153
+ }
1154
+ function prefetchUrl(cacheKey, url, ttl, interceptSlot, refererUrl, held = { intent: false, explicitTtl: false }) {
934
1155
  const chain = claimedChain(interceptSlot ?? null);
935
1156
  const existing = cache.get(cacheKey);
936
1157
  if (existing && existing.expiresAt > Date.now()) {
1158
+ // Fetched as it came into view, undecoded; the touch says decode it.
1159
+ if (held.intent)
1160
+ warm(existing, cacheKey);
937
1161
  return;
938
1162
  }
939
1163
  // A navigation is already fetching it; the answer is on its way.
@@ -994,8 +1218,21 @@ function prefetchUrl(cacheKey, url, ttl, interceptSlot, refererUrl) {
994
1218
  else if (local) {
995
1219
  entry.layouts = local.chain;
996
1220
  }
997
- // The bytes, not the page: decoding is the navigation's, see CacheEntry.
998
- return response.text();
1221
+ // Marked public: the build's bytes, the same for everyone, and held
1222
+ // for as long as Next holds a static page - unless the link said how
1223
+ // long. A deploy is caught by the version handshake, not by the clock.
1224
+ if (!held.explicitTtl &&
1225
+ /\bpublic\b/.test(response.headers.get("Cache-Control") ?? "")) {
1226
+ entry.expiresAt = Math.max(entry.expiresAt, Date.now() + STATIC_PREFETCH_TTL);
1227
+ }
1228
+ // The bytes, not the page: decoding is the navigation's, see CacheEntry
1229
+ // - unless the visitor is already on the way, see prefetch(). The
1230
+ // pictures the page shows are asked for now, though: on a phone they
1231
+ // take longer than the touch-to-click a hidden render has.
1232
+ return response.text().then((text) => {
1233
+ preloadImages(text);
1234
+ return text;
1235
+ });
999
1236
  })
1000
1237
  .catch(() => {
1001
1238
  entry.failed = true;
@@ -1010,6 +1247,8 @@ function prefetchUrl(cacheKey, url, ttl, interceptSlot, refererUrl) {
1010
1247
  }
1011
1248
  });
1012
1249
  cache.set(cacheKey, entry);
1250
+ if (held.intent)
1251
+ warm(entry, cacheKey);
1013
1252
  }
1014
1253
  /**
1015
1254
  * Drop a prefetch that is still in flight — the pointer left the link.