@pylonsync/functions 0.4.10 → 0.4.11
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/ssr-nav-cache.d.ts +26 -0
- package/dist/ssr-route-match.d.ts +15 -6
- package/package.json +1 -1
- package/src/ssr-client-bundler.ts +55 -16
- package/src/ssr-nav-cache.test.ts +146 -0
- package/src/ssr-nav-cache.ts +97 -0
- package/src/ssr-route-match.test.ts +6 -1
- package/src/ssr-route-match.ts +26 -12
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface NavPayloadCacheDeps {
|
|
2
|
+
/** Fetch a page, resolving to its HTML or null when it shouldn't be reused. */
|
|
3
|
+
fetchPage: (target: string) => Promise<string | null>;
|
|
4
|
+
/** Injected so tests drive expiry without sleeping. */
|
|
5
|
+
now: () => number;
|
|
6
|
+
/** Long enough to cover hover-to-click, short enough that nobody reads a
|
|
7
|
+
* page rendered from data this old. */
|
|
8
|
+
ttlMs?: number;
|
|
9
|
+
/** Cap on retained payloads; the oldest is evicted first. Pages are whole
|
|
10
|
+
* HTML documents, so this bounds memory on a link-dense page. */
|
|
11
|
+
max?: number;
|
|
12
|
+
}
|
|
13
|
+
export interface NavPayloadCache {
|
|
14
|
+
/** Warm `target`, unless a fresh entry is already present or in flight. */
|
|
15
|
+
prefetch(target: string): void;
|
|
16
|
+
/** Hand over `target`'s payload, or null. Single-use: a prefetch
|
|
17
|
+
* accelerates the NEXT click, and holding it past that would serve
|
|
18
|
+
* navigations from an increasingly stale render. */
|
|
19
|
+
take(target: string): Promise<string | null> | null;
|
|
20
|
+
/** Drop everything — called when a navigation commits, since entries were
|
|
21
|
+
* rendered against the page the user just left. */
|
|
22
|
+
clear(): void;
|
|
23
|
+
/** Retained entry count (tests + diagnostics). */
|
|
24
|
+
size(): number;
|
|
25
|
+
}
|
|
26
|
+
export declare function createNavPayloadCache(deps: NavPayloadCacheDeps): NavPayloadCache;
|
|
@@ -26,18 +26,27 @@ export declare function matchRoute(manifest: MatchableManifest | null | undefine
|
|
|
26
26
|
export interface PrefetchTargets {
|
|
27
27
|
/** The destination route's own entry chunk, or "" when no page route matches. */
|
|
28
28
|
file: string;
|
|
29
|
-
/**
|
|
29
|
+
/** Chunks to warm alongside it. */
|
|
30
30
|
imports: string[];
|
|
31
31
|
}
|
|
32
32
|
/**
|
|
33
33
|
* The chunks a click on `pathname` will need before anything can render: the
|
|
34
|
-
* destination route's entry, plus the
|
|
35
|
-
*
|
|
34
|
+
* destination route's entry, plus the chunks (React, the client runtime,
|
|
35
|
+
* common layouts) it pulls in.
|
|
36
36
|
*
|
|
37
37
|
* Warming the page payload alone leaves the entry chunk to be fetched after
|
|
38
38
|
* the click, and the route cannot render until it lands — so the prefetch
|
|
39
|
-
* covers only the half that was already fast.
|
|
40
|
-
*
|
|
41
|
-
*
|
|
39
|
+
* covers only the half that was already fast.
|
|
40
|
+
*
|
|
41
|
+
* Size is deliberately NOT a factor. Build output is content-hashed and served
|
|
42
|
+
* immutable, so warming a heavy route costs its bytes once per browser and
|
|
43
|
+
* makes every later visit to it instant; skipping it would trade a permanent
|
|
44
|
+
* win for a one-time saving, and would skip exactly the routes slowest to
|
|
45
|
+
* fetch on demand. The warm is deferred to the load event, so those bytes
|
|
46
|
+
* never compete with the current page's own render.
|
|
47
|
+
*
|
|
48
|
+
* An href matching no page route (an API path, a route this build doesn't
|
|
49
|
+
* serve) yields the union of every route's chunks: no destination is known,
|
|
50
|
+
* but those are needed by any navigation.
|
|
42
51
|
*/
|
|
43
52
|
export declare function prefetchTargets(manifest: MatchableManifest | null | undefined, pathname: string): PrefetchTargets;
|
package/package.json
CHANGED
|
@@ -332,6 +332,7 @@ import { createElement } from "react";
|
|
|
332
332
|
import { hydrateRoot } from "react-dom/client";
|
|
333
333
|
import { createPylonBoundary, nearestBoundaryComponent } from "./client-boundary";
|
|
334
334
|
import { LOADING_MODULES } from "./loading-registry";
|
|
335
|
+
import { createNavPayloadCache } from "./nav-cache";
|
|
335
336
|
import { matchRoute, prefetchTargets } from "./route-match";
|
|
336
337
|
|
|
337
338
|
const routeCache = Object.create(null);
|
|
@@ -597,16 +598,30 @@ function whenLoaded(fn) {
|
|
|
597
598
|
window.addEventListener("load", fn, { once: true });
|
|
598
599
|
}
|
|
599
600
|
|
|
600
|
-
|
|
601
|
-
|
|
601
|
+
// Payloads warmed by a hover, consumed by the click that follows. See
|
|
602
|
+
// ./nav-cache for why this is in memory rather than an HTTP-level prefetch.
|
|
603
|
+
const navPayloads = createNavPayloadCache({
|
|
604
|
+
now: () => Date.now(),
|
|
605
|
+
fetchPage: (target) =>
|
|
606
|
+
fetch(target, {
|
|
607
|
+
credentials: "same-origin",
|
|
608
|
+
headers: { Accept: "text/html" },
|
|
609
|
+
}).then((res) => {
|
|
610
|
+
// A redirect means the URL navigate() would commit isn't the one that
|
|
611
|
+
// answered; let the real navigation resolve that itself.
|
|
612
|
+
if (!res.ok || res.redirected) return null;
|
|
613
|
+
return res.text();
|
|
614
|
+
}),
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
async function prefetch(href, opts) {
|
|
602
618
|
const url = new URL(href, location.href);
|
|
603
619
|
if (url.origin !== location.origin) return;
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
document.head.appendChild(html);
|
|
620
|
+
// The page payload, but ONLY on a real intent signal (hover / touch). It
|
|
621
|
+
// costs a full SSR render, so warming a screenful of links on sight would
|
|
622
|
+
// spend a dozen renders per page load to save one.
|
|
623
|
+
if (opts && opts.document) {
|
|
624
|
+
navPayloads.prefetch(url.pathname + url.search);
|
|
610
625
|
}
|
|
611
626
|
const manifest = await loadManifest();
|
|
612
627
|
if (!manifest) return;
|
|
@@ -643,6 +658,11 @@ async function loadRouteEntry(component) {
|
|
|
643
658
|
export function hydrate(component, Page, Layouts) {
|
|
644
659
|
// Always cache the route for nav.
|
|
645
660
|
routeCache[component] = { Page, Layouts };
|
|
661
|
+
// Start the manifest fetch NOW rather than on first use. Nothing can resolve
|
|
662
|
+
// a route without it — the first <Link> reaching the viewport warms its
|
|
663
|
+
// chunks only after it lands — so leaving it lazy put a round trip in front
|
|
664
|
+
// of all prefetching. In flight from here, it resolves during hydration.
|
|
665
|
+
void loadManifest();
|
|
646
666
|
const data = readPylonData();
|
|
647
667
|
// First hydrate: the entry's component MATCHES the SSR'd page.
|
|
648
668
|
// Establish the root + install the click + popstate handlers
|
|
@@ -851,15 +871,21 @@ async function navigate(href, opts) {
|
|
|
851
871
|
// ---- Real fetch + render -----------------------------------------------
|
|
852
872
|
let html;
|
|
853
873
|
try {
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
if (
|
|
859
|
-
|
|
860
|
-
|
|
874
|
+
// A hover-prefetch usually has this in hand, or in flight — either way the
|
|
875
|
+
// click joins it instead of opening a second request for the same page.
|
|
876
|
+
const prefetched = navPayloads.take(target);
|
|
877
|
+
html = prefetched ? await prefetched : null;
|
|
878
|
+
if (html == null) {
|
|
879
|
+
const res = await fetch(target, {
|
|
880
|
+
credentials: "same-origin",
|
|
881
|
+
headers: { Accept: "text/html" },
|
|
882
|
+
});
|
|
883
|
+
if (!res.ok) {
|
|
884
|
+
fullLoad();
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
html = await res.text();
|
|
861
888
|
}
|
|
862
|
-
html = await res.text();
|
|
863
889
|
} catch {
|
|
864
890
|
fullLoad();
|
|
865
891
|
return;
|
|
@@ -912,6 +938,10 @@ async function navigate(href, opts) {
|
|
|
912
938
|
// next macrotask once the commit has settled with no error.
|
|
913
939
|
pendingNav = target;
|
|
914
940
|
currentComponent = data.component;
|
|
941
|
+
// Drop every other prefetched payload once a navigation commits: they were
|
|
942
|
+
// rendered against the previous page's state, and whatever the user does
|
|
943
|
+
// here can invalidate them. Hovering on the new page re-warms in one request.
|
|
944
|
+
navPayloads.clear();
|
|
915
945
|
activeRoot.render(tree);
|
|
916
946
|
setTimeout(() => {
|
|
917
947
|
if (pendingNav === target) pendingNav = null;
|
|
@@ -1407,6 +1437,15 @@ async function _doBuildInner(
|
|
|
1407
1437
|
"utf8",
|
|
1408
1438
|
);
|
|
1409
1439
|
|
|
1440
|
+
// Same pattern for the prefetch payload cache — the runtime imports it as
|
|
1441
|
+
// `./nav-cache`; its source of truth is ssr-nav-cache.ts, unit-tested
|
|
1442
|
+
// directly (expiry, eviction, single-use).
|
|
1443
|
+
fs.writeFileSync(
|
|
1444
|
+
path.join(stageDir, "nav-cache.ts"),
|
|
1445
|
+
fs.readFileSync(path.join(here, "ssr-nav-cache.ts"), "utf8"),
|
|
1446
|
+
"utf8",
|
|
1447
|
+
);
|
|
1448
|
+
|
|
1410
1449
|
// The app's loading.tsx modules, gathered into one module the runtime
|
|
1411
1450
|
// imports statically (`./loading-registry`) so a pending navigation can
|
|
1412
1451
|
// paint a skeleton without fetching anything. Always written — an app with
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Regression tests for the client-navigation payload cache.
|
|
2
|
+
//
|
|
3
|
+
// The bug this exists to fix: `<Link>` prefetched pages with a
|
|
4
|
+
// `<link rel="prefetch">` that could never be reused. Measured on a real app,
|
|
5
|
+
// the same URL was fetched twice — 16742 bytes both times, full transferSize
|
|
6
|
+
// on the second — because SSR pages are sent "private, no-store" and an
|
|
7
|
+
// as="document" prefetch only feeds real navigations, not fetch(). So each
|
|
8
|
+
// prefetch was a full server render, thrown away, and the click still paid.
|
|
9
|
+
|
|
10
|
+
import { describe, expect, it } from "bun:test";
|
|
11
|
+
import { createNavPayloadCache } from "./ssr-nav-cache";
|
|
12
|
+
|
|
13
|
+
/** A cache with a controllable clock and a fetch that records its calls. */
|
|
14
|
+
function harness(opts: { ttlMs?: number; max?: number } = {}) {
|
|
15
|
+
let clock = 1000;
|
|
16
|
+
const calls: string[] = [];
|
|
17
|
+
let resolveNext: ((v: string | null) => void) | null = null;
|
|
18
|
+
const cache = createNavPayloadCache({
|
|
19
|
+
now: () => clock,
|
|
20
|
+
ttlMs: opts.ttlMs,
|
|
21
|
+
max: opts.max,
|
|
22
|
+
fetchPage: (target) => {
|
|
23
|
+
calls.push(target);
|
|
24
|
+
return new Promise<string | null>((res) => {
|
|
25
|
+
resolveNext = res;
|
|
26
|
+
// Default: resolve immediately on the microtask queue.
|
|
27
|
+
queueMicrotask(() => res(`<html>${target}</html>`));
|
|
28
|
+
});
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
return {
|
|
32
|
+
cache,
|
|
33
|
+
calls,
|
|
34
|
+
advance: (ms: number) => {
|
|
35
|
+
clock += ms;
|
|
36
|
+
},
|
|
37
|
+
settleWith: (v: string | null) => resolveNext?.(v),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("nav payload cache", () => {
|
|
42
|
+
it("hands the prefetched payload to the click", async () => {
|
|
43
|
+
const h = harness();
|
|
44
|
+
h.cache.prefetch("/a");
|
|
45
|
+
const taken = h.cache.take("/a");
|
|
46
|
+
expect(taken).not.toBeNull();
|
|
47
|
+
expect(await taken!).toBe("<html>/a</html>");
|
|
48
|
+
// The whole point: the click opened no request of its own.
|
|
49
|
+
expect(h.calls).toEqual(["/a"]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("does not re-fetch a target already in flight", () => {
|
|
53
|
+
const h = harness();
|
|
54
|
+
h.cache.prefetch("/a");
|
|
55
|
+
h.cache.prefetch("/a");
|
|
56
|
+
h.cache.prefetch("/a");
|
|
57
|
+
expect(h.calls).toEqual(["/a"]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("lets a click join a prefetch that hasn't landed yet", async () => {
|
|
61
|
+
// Hover-to-click is usually shorter than the render, so this is the
|
|
62
|
+
// common path, not an edge case.
|
|
63
|
+
const h = harness();
|
|
64
|
+
h.cache.prefetch("/slow");
|
|
65
|
+
const taken = h.cache.take("/slow");
|
|
66
|
+
h.settleWith("<html>late</html>");
|
|
67
|
+
expect(await taken!).toBe("<html>late</html>");
|
|
68
|
+
expect(h.calls).toEqual(["/slow"]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("is single-use, so a second navigation re-renders", () => {
|
|
72
|
+
const h = harness();
|
|
73
|
+
h.cache.prefetch("/a");
|
|
74
|
+
expect(h.cache.take("/a")).not.toBeNull();
|
|
75
|
+
expect(h.cache.take("/a")).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("refuses a payload past its TTL", () => {
|
|
79
|
+
const h = harness({ ttlMs: 15000 });
|
|
80
|
+
h.cache.prefetch("/a");
|
|
81
|
+
h.advance(15000);
|
|
82
|
+
expect(h.cache.take("/a")).toBeNull();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("still serves a payload just inside its TTL", async () => {
|
|
86
|
+
const h = harness({ ttlMs: 15000 });
|
|
87
|
+
h.cache.prefetch("/a");
|
|
88
|
+
h.advance(14999);
|
|
89
|
+
expect(await h.cache.take("/a")!).toBe("<html>/a</html>");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("re-warms an expired target instead of holding the stale one", async () => {
|
|
93
|
+
const h = harness({ ttlMs: 100 });
|
|
94
|
+
h.cache.prefetch("/a");
|
|
95
|
+
h.advance(200);
|
|
96
|
+
h.cache.prefetch("/a");
|
|
97
|
+
expect(h.calls).toEqual(["/a", "/a"]);
|
|
98
|
+
expect(await h.cache.take("/a")!).toBe("<html>/a</html>");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("evicts the oldest entry at the cap", () => {
|
|
102
|
+
const h = harness({ max: 2 });
|
|
103
|
+
h.cache.prefetch("/a");
|
|
104
|
+
h.cache.prefetch("/b");
|
|
105
|
+
h.cache.prefetch("/c");
|
|
106
|
+
expect(h.cache.size()).toBe(2);
|
|
107
|
+
expect(h.cache.take("/a")).toBeNull();
|
|
108
|
+
expect(h.cache.take("/c")).not.toBeNull();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("drops everything when a navigation commits", () => {
|
|
112
|
+
// Entries were rendered against the page the user just left.
|
|
113
|
+
const h = harness();
|
|
114
|
+
h.cache.prefetch("/a");
|
|
115
|
+
h.cache.prefetch("/b");
|
|
116
|
+
h.cache.clear();
|
|
117
|
+
expect(h.cache.size()).toBe(0);
|
|
118
|
+
expect(h.cache.take("/a")).toBeNull();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("yields null when the fetch fails, so the click refetches", async () => {
|
|
122
|
+
const calls: string[] = [];
|
|
123
|
+
const cache = createNavPayloadCache({
|
|
124
|
+
now: () => 0,
|
|
125
|
+
fetchPage: (t) => {
|
|
126
|
+
calls.push(t);
|
|
127
|
+
return Promise.reject(new Error("offline"));
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
cache.prefetch("/a");
|
|
131
|
+
// A rejection must not escape as an unhandled rejection when nobody clicks.
|
|
132
|
+
expect(await cache.take("/a")!).toBeNull();
|
|
133
|
+
expect(calls).toEqual(["/a"]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("caches nothing for a response the fetch rejected as unusable", async () => {
|
|
137
|
+
// fetchPage returns null for a non-200 or a redirect — the URL that
|
|
138
|
+
// answered isn't the one navigate() would commit.
|
|
139
|
+
const cache = createNavPayloadCache({
|
|
140
|
+
now: () => 0,
|
|
141
|
+
fetchPage: () => Promise.resolve(null),
|
|
142
|
+
});
|
|
143
|
+
cache.prefetch("/a");
|
|
144
|
+
expect(await cache.take("/a")!).toBeNull();
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Prefetched page payloads for client-side navigation.
|
|
2
|
+
//
|
|
3
|
+
// `<Link>` warms a destination on hover; the click then consumes what the
|
|
4
|
+
// hover fetched instead of opening its own request. Before this existed the
|
|
5
|
+
// prefetch was a `<link rel="prefetch">` that could never be reused — SSR
|
|
6
|
+
// pages are sent "private, no-store", so nothing was storable, and an
|
|
7
|
+
// `as="document"` prefetch only feeds real navigations anyway, not `fetch()`.
|
|
8
|
+
// Every prefetch was therefore a full server render, executed and discarded,
|
|
9
|
+
// and the click still paid full price.
|
|
10
|
+
//
|
|
11
|
+
// So the cache lives HERE, in memory, for the length of the tab: honoring
|
|
12
|
+
// no-store means never handing the response to a disk cache, and it means a
|
|
13
|
+
// reload always re-renders.
|
|
14
|
+
//
|
|
15
|
+
// Kept in its own module (not inlined in CLIENT_RUNTIME_SOURCE) so its
|
|
16
|
+
// expiry, eviction and single-use rules are unit-testable — the same reason
|
|
17
|
+
// client-boundary and route-match were pulled out. The bundler stages a copy
|
|
18
|
+
// next to the generated runtime, which imports it as "./nav-cache".
|
|
19
|
+
|
|
20
|
+
export interface NavPayloadCacheDeps {
|
|
21
|
+
/** Fetch a page, resolving to its HTML or null when it shouldn't be reused. */
|
|
22
|
+
fetchPage: (target: string) => Promise<string | null>;
|
|
23
|
+
/** Injected so tests drive expiry without sleeping. */
|
|
24
|
+
now: () => number;
|
|
25
|
+
/** Long enough to cover hover-to-click, short enough that nobody reads a
|
|
26
|
+
* page rendered from data this old. */
|
|
27
|
+
ttlMs?: number;
|
|
28
|
+
/** Cap on retained payloads; the oldest is evicted first. Pages are whole
|
|
29
|
+
* HTML documents, so this bounds memory on a link-dense page. */
|
|
30
|
+
max?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface NavPayloadCache {
|
|
34
|
+
/** Warm `target`, unless a fresh entry is already present or in flight. */
|
|
35
|
+
prefetch(target: string): void;
|
|
36
|
+
/** Hand over `target`'s payload, or null. Single-use: a prefetch
|
|
37
|
+
* accelerates the NEXT click, and holding it past that would serve
|
|
38
|
+
* navigations from an increasingly stale render. */
|
|
39
|
+
take(target: string): Promise<string | null> | null;
|
|
40
|
+
/** Drop everything — called when a navigation commits, since entries were
|
|
41
|
+
* rendered against the page the user just left. */
|
|
42
|
+
clear(): void;
|
|
43
|
+
/** Retained entry count (tests + diagnostics). */
|
|
44
|
+
size(): number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createNavPayloadCache(
|
|
48
|
+
deps: NavPayloadCacheDeps,
|
|
49
|
+
): NavPayloadCache {
|
|
50
|
+
const { fetchPage, now } = deps;
|
|
51
|
+
const ttlMs = deps.ttlMs ?? 15000;
|
|
52
|
+
const max = deps.max ?? 8;
|
|
53
|
+
// Holds the in-flight PROMISE, not just the resolved text, so a click that
|
|
54
|
+
// lands mid-prefetch joins that request rather than starting a second one —
|
|
55
|
+
// the common case, since hover-to-click is usually shorter than the render.
|
|
56
|
+
const entries = new Map<string, { at: number; promise: Promise<string | null> }>();
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
prefetch(target: string): void {
|
|
60
|
+
const hit = entries.get(target);
|
|
61
|
+
if (hit && now() - hit.at < ttlMs) return;
|
|
62
|
+
// Re-warming an expired target: drop it first so the refreshed entry
|
|
63
|
+
// re-enters at the END of the insertion order. Map.set on an existing
|
|
64
|
+
// key keeps its original position, which would make a repeatedly
|
|
65
|
+
// re-warmed target the first one evicted. No test covers this: a
|
|
66
|
+
// re-warm only happens after expiry, so everything ahead of it in the
|
|
67
|
+
// order has expired too and evicting it costs nothing today. Keeping
|
|
68
|
+
// the LRU order honest anyway, so this stays true if the TTL does not.
|
|
69
|
+
entries.delete(target);
|
|
70
|
+
while (entries.size >= max) {
|
|
71
|
+
const oldest = entries.keys().next();
|
|
72
|
+
if (oldest.done) break;
|
|
73
|
+
entries.delete(oldest.value);
|
|
74
|
+
}
|
|
75
|
+
// A rejected fetch must not surface as an unhandled rejection when
|
|
76
|
+
// nobody ends up clicking; resolve to null and let the click refetch.
|
|
77
|
+
const promise = fetchPage(target).catch(() => null);
|
|
78
|
+
entries.set(target, { at: now(), promise });
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
take(target: string): Promise<string | null> | null {
|
|
82
|
+
const hit = entries.get(target);
|
|
83
|
+
if (!hit) return null;
|
|
84
|
+
entries.delete(target);
|
|
85
|
+
if (now() - hit.at >= ttlMs) return null;
|
|
86
|
+
return hit.promise;
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
clear(): void {
|
|
90
|
+
entries.clear();
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
size(): number {
|
|
94
|
+
return entries.size;
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -162,10 +162,15 @@ describe("prefetchTargets", () => {
|
|
|
162
162
|
});
|
|
163
163
|
|
|
164
164
|
it("is null-safe on an absent or empty manifest", () => {
|
|
165
|
-
expect(prefetchTargets(null, "/")).toEqual({
|
|
165
|
+
expect(prefetchTargets(null, "/")).toEqual({
|
|
166
|
+
file: "",
|
|
167
|
+
imports: [],
|
|
168
|
+
|
|
169
|
+
});
|
|
166
170
|
expect(prefetchTargets({ routes: {} }, "/")).toEqual({
|
|
167
171
|
file: "",
|
|
168
172
|
imports: [],
|
|
173
|
+
|
|
169
174
|
});
|
|
170
175
|
});
|
|
171
176
|
|
package/src/ssr-route-match.ts
CHANGED
|
@@ -116,34 +116,48 @@ export function matchRoute(
|
|
|
116
116
|
export interface PrefetchTargets {
|
|
117
117
|
/** The destination route's own entry chunk, or "" when no page route matches. */
|
|
118
118
|
file: string;
|
|
119
|
-
/**
|
|
119
|
+
/** Chunks to warm alongside it. */
|
|
120
120
|
imports: string[];
|
|
121
121
|
}
|
|
122
122
|
|
|
123
123
|
/**
|
|
124
124
|
* The chunks a click on `pathname` will need before anything can render: the
|
|
125
|
-
* destination route's entry, plus the
|
|
126
|
-
*
|
|
125
|
+
* destination route's entry, plus the chunks (React, the client runtime,
|
|
126
|
+
* common layouts) it pulls in.
|
|
127
127
|
*
|
|
128
128
|
* Warming the page payload alone leaves the entry chunk to be fetched after
|
|
129
129
|
* the click, and the route cannot render until it lands — so the prefetch
|
|
130
|
-
* covers only the half that was already fast.
|
|
131
|
-
*
|
|
132
|
-
*
|
|
130
|
+
* covers only the half that was already fast.
|
|
131
|
+
*
|
|
132
|
+
* Size is deliberately NOT a factor. Build output is content-hashed and served
|
|
133
|
+
* immutable, so warming a heavy route costs its bytes once per browser and
|
|
134
|
+
* makes every later visit to it instant; skipping it would trade a permanent
|
|
135
|
+
* win for a one-time saving, and would skip exactly the routes slowest to
|
|
136
|
+
* fetch on demand. The warm is deferred to the load event, so those bytes
|
|
137
|
+
* never compete with the current page's own render.
|
|
138
|
+
*
|
|
139
|
+
* An href matching no page route (an API path, a route this build doesn't
|
|
140
|
+
* serve) yields the union of every route's chunks: no destination is known,
|
|
141
|
+
* but those are needed by any navigation.
|
|
133
142
|
*/
|
|
134
143
|
export function prefetchTargets(
|
|
135
144
|
manifest: MatchableManifest | null | undefined,
|
|
136
145
|
pathname: string,
|
|
137
146
|
): PrefetchTargets {
|
|
138
|
-
const imports = new Set<string>();
|
|
139
147
|
if (!manifest || !manifest.routes) return { file: "", imports: [] };
|
|
140
|
-
for (const r of Object.values(manifest.routes)) {
|
|
141
|
-
for (const i of r?.imports || []) imports.add(i);
|
|
142
|
-
}
|
|
143
148
|
const matched = matchRoute(manifest, pathname);
|
|
144
149
|
const route = matched ? manifest.routes[matched.component] : null;
|
|
145
150
|
if (route) {
|
|
146
|
-
|
|
151
|
+
// The route's OWN transitive chunks — everything it needs, and nothing
|
|
152
|
+
// belonging to routes the user isn't heading for.
|
|
153
|
+
return {
|
|
154
|
+
file: route.file || "",
|
|
155
|
+
imports: Array.from(new Set(route.imports || [])),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
const imports = new Set<string>();
|
|
159
|
+
for (const r of Object.values(manifest.routes)) {
|
|
160
|
+
for (const i of r?.imports || []) imports.add(i);
|
|
147
161
|
}
|
|
148
|
-
return { file:
|
|
162
|
+
return { file: "", imports: Array.from(imports) };
|
|
149
163
|
}
|