@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.
- package/dist/cache.d.ts +11 -0
- package/dist/cache.js +13 -0
- package/dist/cache.js.map +1 -1
- package/dist/clientEntries.js +4 -1
- package/dist/clientEntries.js.map +1 -1
- package/dist/files.d.ts +38 -0
- package/dist/files.js +109 -0
- package/dist/files.js.map +1 -1
- package/dist/host.d.ts +11 -2
- package/dist/host.js +37 -6
- package/dist/host.js.map +1 -1
- package/dist/js/ActivityRouter.d.ts +11 -0
- package/dist/js/ActivityRouter.js +24 -4
- package/dist/js/ActivityRouter.js.map +1 -1
- package/dist/js/Form.js +24 -2
- package/dist/js/Form.js.map +1 -1
- package/dist/js/Link.d.ts +1 -1
- package/dist/js/Link.js +25 -9
- package/dist/js/Link.js.map +1 -1
- package/dist/js/PageTransition.d.ts +27 -0
- package/dist/js/PageTransition.js +57 -0
- package/dist/js/PageTransition.js.map +1 -0
- package/dist/js/PathnameProvider.d.ts +15 -2
- package/dist/js/PathnameProvider.js +17 -2
- package/dist/js/PathnameProvider.js.map +1 -1
- package/dist/js/SegmentBoundary.js +27 -3
- package/dist/js/SegmentBoundary.js.map +1 -1
- package/dist/js/activityMarkers.d.ts +7 -0
- package/dist/js/activityMarkers.js +18 -0
- package/dist/js/activityMarkers.js.map +1 -0
- package/dist/js/createViteRscApp.js +122 -76
- package/dist/js/createViteRscApp.js.map +1 -1
- package/dist/js/earlyClicks.d.ts +3 -1
- package/dist/js/earlyClicks.js +28 -5
- package/dist/js/earlyClicks.js.map +1 -1
- package/dist/js/errors.d.ts +2 -0
- package/dist/js/errors.js +11 -0
- package/dist/js/errors.js.map +1 -1
- package/dist/js/imagePreload.d.ts +37 -0
- package/dist/js/imagePreload.js +116 -0
- package/dist/js/imagePreload.js.map +1 -0
- package/dist/js/navigate.d.ts +47 -1
- package/dist/js/navigate.js +249 -10
- package/dist/js/navigate.js.map +1 -1
- package/dist/js/perf.d.ts +25 -0
- package/dist/js/perf.js +57 -0
- package/dist/js/perf.js.map +1 -0
- package/dist/js/router.d.ts +10 -1
- package/dist/js/router.js +9 -2
- package/dist/js/router.js.map +1 -1
- package/dist/js/segmentStore.d.ts +52 -0
- package/dist/js/segmentStore.js +123 -2
- package/dist/js/segmentStore.js.map +1 -1
- package/dist/js/staleAssets.d.ts +10 -0
- package/dist/js/staleAssets.js +23 -2
- package/dist/js/staleAssets.js.map +1 -1
- package/dist/js/viewportPrefetch.d.ts +19 -10
- package/dist/js/viewportPrefetch.js +50 -15
- package/dist/js/viewportPrefetch.js.map +1 -1
- package/dist/shellHead.d.ts +22 -0
- package/dist/shellHead.js +43 -0
- package/dist/shellHead.js.map +1 -0
- package/dist/vite.js +250 -55
- package/dist/vite.js.map +1 -1
- package/package.json +5 -1
package/dist/js/navigate.js
CHANGED
|
@@ -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
|
-
//
|
|
776
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
998
|
-
|
|
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.
|