Skip to content

Commit 9164460

Browse files
committed
feat: add link prefetching support for client-side navigation
Adds f-prefetch attribute support with four strategies: - hover (default): prefetch on pointer enter / focus - viewport: prefetch when link enters viewport via IntersectionObserver - load: prefetch immediately on page load - none: opt out of prefetching Supports container-level defaults (e.g. f-prefetch="hover" on a nav element applies to all child links). Respects Save-Data / slow connections, deduplicates in-flight requests, and caches responses with a 30s TTL. Cached partials are consumed on navigation to avoid redundant network requests. Closes #3788
1 parent c06a84e commit 9164460

3 files changed

Lines changed: 250 additions & 1 deletion

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import "./polyfills.ts";
22
import "./preact_hooks_client.ts";
33
import "./partials.ts";
4+
import "./prefetch.ts";
45
export { asset, IS_BROWSER, Partial, type PartialProps } from "../shared.ts";
56
export { boot, revive } from "./reviver.ts";

packages/fresh/src/runtime/client/partials.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { createRootFragment, isCommentNode, isElementNode } from "./reviver.ts";
2323
import type { PartialStateJson } from "../server/preact_hooks.ts";
2424
import { parse } from "../../jsonify/parse.ts";
2525
import { INTERNAL_PREFIX, PARTIAL_SEARCH_PARAM } from "../../constants.ts";
26+
import { getCachedResponse } from "./prefetch.ts";
2627

2728
export const PARTIAL_ATTR = "f-partial";
2829

@@ -359,7 +360,15 @@ async function fetchPartials(
359360
init.redirect = "follow";
360361
partialUrl = new URL(partialUrl);
361362
partialUrl.searchParams.set(PARTIAL_SEARCH_PARAM, "true");
362-
const res = await fetch(partialUrl, init);
363+
364+
// Check prefetch cache for GET requests (no custom init body)
365+
let res: Response;
366+
const cached = !init.body ? getCachedResponse(partialUrl) : null;
367+
if (cached) {
368+
res = cached;
369+
} else {
370+
res = await fetch(partialUrl, init);
371+
}
363372

364373
if (res.redirected) {
365374
const nextUrl = new URL(res.url);
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
import { PARTIAL_SEARCH_PARAM } from "../../constants.ts";
2+
import { CLIENT_NAV_ATTR } from "../shared_internal.ts";
3+
import { PARTIAL_ATTR } from "./partials.ts";
4+
5+
export const PREFETCH_ATTR = "f-prefetch";
6+
7+
type PrefetchStrategy = "hover" | "viewport" | "load" | "none";
8+
9+
interface CacheEntry {
10+
response: Response;
11+
timestamp: number;
12+
}
13+
14+
const CACHE_TTL = 30_000; // 30 seconds
15+
const prefetchCache = new Map<string, CacheEntry>();
16+
const inflightRequests = new Map<string, Promise<Response>>();
17+
const prefetchedUrls = new Set<string>();
18+
19+
// Track elements observed by IntersectionObserver to avoid re-observing
20+
const observedElements = new WeakSet<Element>();
21+
22+
/**
23+
* Get the effective prefetch strategy for a link element.
24+
* Checks the element itself, then walks up to find a container default.
25+
*/
26+
function getStrategy(el: HTMLAnchorElement): PrefetchStrategy {
27+
// Check the element itself first
28+
if (el.hasAttribute(PREFETCH_ATTR)) {
29+
const val = el.getAttribute(PREFETCH_ATTR);
30+
if (val === "none" || val === "viewport" || val === "load") return val;
31+
// f-prefetch or f-prefetch="hover" or f-prefetch=""
32+
return "hover";
33+
}
34+
35+
// Walk up to find a container-level default
36+
const container = el.closest(`[${PREFETCH_ATTR}]`);
37+
if (container && container !== el) {
38+
const val = container.getAttribute(PREFETCH_ATTR);
39+
if (val === "none") return "none";
40+
if (val === "viewport") return "viewport";
41+
if (val === "load") return "load";
42+
return "hover";
43+
}
44+
45+
return "none";
46+
}
47+
48+
/**
49+
* Check if data saving is preferred by the user.
50+
*/
51+
function shouldSaveData(): boolean {
52+
// deno-lint-ignore no-explicit-any
53+
const conn = (navigator as any).connection;
54+
if (conn) {
55+
if (conn.saveData) return true;
56+
// Also respect slow connections
57+
if (conn.effectiveType === "slow-2g" || conn.effectiveType === "2g") {
58+
return true;
59+
}
60+
}
61+
return false;
62+
}
63+
64+
/**
65+
* Get the partial URL for a link, respecting f-partial attribute.
66+
*/
67+
function getPartialUrl(el: HTMLAnchorElement): string {
68+
const partial = el.getAttribute(PARTIAL_ATTR);
69+
const url = new URL(partial ? partial : el.href, location.href);
70+
url.searchParams.set(PARTIAL_SEARCH_PARAM, "true");
71+
return url.href;
72+
}
73+
74+
/**
75+
* Check if a link is eligible for prefetching.
76+
*/
77+
function isEligible(el: HTMLAnchorElement): boolean {
78+
return (
79+
!!el.href &&
80+
(!el.target || el.target === "_self") &&
81+
el.origin === location.origin &&
82+
!el.getAttribute("href")?.startsWith("#")
83+
);
84+
}
85+
86+
/**
87+
* Prefetch a URL and store in cache.
88+
*/
89+
function prefetch(el: HTMLAnchorElement): void {
90+
if (shouldSaveData()) return;
91+
92+
const url = getPartialUrl(el);
93+
if (prefetchedUrls.has(url)) return;
94+
if (prefetchCache.has(url)) {
95+
const entry = prefetchCache.get(url)!;
96+
if (Date.now() - entry.timestamp < CACHE_TTL) return;
97+
}
98+
99+
prefetchedUrls.add(url);
100+
101+
// Deduplicate in-flight requests
102+
if (inflightRequests.has(url)) return;
103+
104+
const promise = fetch(url, {
105+
priority: "low",
106+
// deno-lint-ignore no-explicit-any
107+
} as any).then((res) => {
108+
if (res.ok) {
109+
prefetchCache.set(url, {
110+
response: res,
111+
timestamp: Date.now(),
112+
});
113+
}
114+
inflightRequests.delete(url);
115+
return res;
116+
}).catch(() => {
117+
inflightRequests.delete(url);
118+
prefetchedUrls.delete(url);
119+
return new Response(null, { status: 0 });
120+
});
121+
122+
inflightRequests.set(url, promise);
123+
}
124+
125+
/**
126+
* Get a cached response for the given partial URL, if available and fresh.
127+
*/
128+
export function getCachedResponse(partialUrl: URL): Response | null {
129+
const url = partialUrl.href;
130+
const entry = prefetchCache.get(url);
131+
if (!entry) return null;
132+
133+
if (Date.now() - entry.timestamp > CACHE_TTL) {
134+
prefetchCache.delete(url);
135+
prefetchedUrls.delete(url);
136+
return null;
137+
}
138+
139+
// Remove from cache after use (single use)
140+
prefetchCache.delete(url);
141+
prefetchedUrls.delete(url);
142+
return entry.response;
143+
}
144+
145+
// IntersectionObserver for viewport strategy
146+
let viewportObserver: IntersectionObserver | null = null;
147+
148+
function getViewportObserver(): IntersectionObserver {
149+
if (!viewportObserver) {
150+
viewportObserver = new IntersectionObserver(
151+
(entries) => {
152+
for (const entry of entries) {
153+
if (entry.isIntersecting) {
154+
const el = entry.target as HTMLAnchorElement;
155+
prefetch(el);
156+
viewportObserver!.unobserve(el);
157+
}
158+
}
159+
},
160+
{ rootMargin: "200px" },
161+
);
162+
}
163+
return viewportObserver;
164+
}
165+
166+
/**
167+
* Set up prefetching for a single link element.
168+
*/
169+
function setupLink(el: HTMLAnchorElement): void {
170+
if (!isEligible(el)) return;
171+
172+
// Check if client nav is enabled for this element
173+
const setting = el.closest(`[${CLIENT_NAV_ATTR}]`);
174+
if (setting === null || setting.getAttribute(CLIENT_NAV_ATTR) === "false") {
175+
return;
176+
}
177+
178+
const strategy = getStrategy(el);
179+
180+
switch (strategy) {
181+
case "hover":
182+
el.addEventListener("pointerenter", () => prefetch(el), { once: true });
183+
// Also prefetch on focus for keyboard navigation
184+
el.addEventListener("focus", () => prefetch(el), { once: true });
185+
break;
186+
case "viewport":
187+
if (!observedElements.has(el)) {
188+
observedElements.add(el);
189+
getViewportObserver().observe(el);
190+
}
191+
break;
192+
case "load":
193+
prefetch(el);
194+
break;
195+
case "none":
196+
break;
197+
}
198+
}
199+
200+
/**
201+
* Scan the document for links and set up prefetching.
202+
*/
203+
function scanLinks(): void {
204+
const links = document.querySelectorAll<HTMLAnchorElement>("a[href]");
205+
for (const link of links) {
206+
setupLink(link);
207+
}
208+
}
209+
210+
// Initial scan after DOM is ready
211+
if (document.readyState === "loading") {
212+
document.addEventListener("DOMContentLoaded", scanLinks);
213+
} else {
214+
// Use microtask to avoid blocking
215+
queueMicrotask(scanLinks);
216+
}
217+
218+
// Observe DOM changes to pick up dynamically added links
219+
const mutationObserver = new MutationObserver((mutations) => {
220+
for (const mutation of mutations) {
221+
const nodes = mutation.addedNodes;
222+
for (let i = 0; i < nodes.length; i++) {
223+
const node = nodes[i];
224+
if (node instanceof HTMLAnchorElement) {
225+
setupLink(node);
226+
} else if (node instanceof HTMLElement) {
227+
const links = node.querySelectorAll<HTMLAnchorElement>("a[href]");
228+
for (const link of links) {
229+
setupLink(link);
230+
}
231+
}
232+
}
233+
}
234+
});
235+
236+
mutationObserver.observe(document.body, {
237+
childList: true,
238+
subtree: true,
239+
});

0 commit comments

Comments
 (0)