From 91644603355190590f897a6110f282a663deaae3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Mon, 27 Apr 2026 16:40:54 +0200 Subject: [PATCH] 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 denoland/fresh#3788 --- packages/fresh/src/runtime/client/mod.ts | 1 + packages/fresh/src/runtime/client/partials.ts | 11 +- packages/fresh/src/runtime/client/prefetch.ts | 239 ++++++++++++++++++ 3 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 packages/fresh/src/runtime/client/prefetch.ts diff --git a/packages/fresh/src/runtime/client/mod.ts b/packages/fresh/src/runtime/client/mod.ts index ec2d6fa8332..1279f51e066 100644 --- a/packages/fresh/src/runtime/client/mod.ts +++ b/packages/fresh/src/runtime/client/mod.ts @@ -1,5 +1,6 @@ import "./polyfills.ts"; import "./preact_hooks_client.ts"; import "./partials.ts"; +import "./prefetch.ts"; export { asset, IS_BROWSER, Partial, type PartialProps } from "../shared.ts"; export { boot, revive } from "./reviver.ts"; diff --git a/packages/fresh/src/runtime/client/partials.ts b/packages/fresh/src/runtime/client/partials.ts index 008e23a9592..5b7f3e61422 100644 --- a/packages/fresh/src/runtime/client/partials.ts +++ b/packages/fresh/src/runtime/client/partials.ts @@ -23,6 +23,7 @@ import { createRootFragment, isCommentNode, isElementNode } from "./reviver.ts"; import type { PartialStateJson } from "../server/preact_hooks.ts"; import { parse } from "../../jsonify/parse.ts"; import { INTERNAL_PREFIX, PARTIAL_SEARCH_PARAM } from "../../constants.ts"; +import { getCachedResponse } from "./prefetch.ts"; export const PARTIAL_ATTR = "f-partial"; @@ -359,7 +360,15 @@ async function fetchPartials( init.redirect = "follow"; partialUrl = new URL(partialUrl); partialUrl.searchParams.set(PARTIAL_SEARCH_PARAM, "true"); - const res = await fetch(partialUrl, init); + + // Check prefetch cache for GET requests (no custom init body) + let res: Response; + const cached = !init.body ? getCachedResponse(partialUrl) : null; + if (cached) { + res = cached; + } else { + res = await fetch(partialUrl, init); + } if (res.redirected) { const nextUrl = new URL(res.url); diff --git a/packages/fresh/src/runtime/client/prefetch.ts b/packages/fresh/src/runtime/client/prefetch.ts new file mode 100644 index 00000000000..27a9fb0dcdd --- /dev/null +++ b/packages/fresh/src/runtime/client/prefetch.ts @@ -0,0 +1,239 @@ +import { PARTIAL_SEARCH_PARAM } from "../../constants.ts"; +import { CLIENT_NAV_ATTR } from "../shared_internal.ts"; +import { PARTIAL_ATTR } from "./partials.ts"; + +export const PREFETCH_ATTR = "f-prefetch"; + +type PrefetchStrategy = "hover" | "viewport" | "load" | "none"; + +interface CacheEntry { + response: Response; + timestamp: number; +} + +const CACHE_TTL = 30_000; // 30 seconds +const prefetchCache = new Map(); +const inflightRequests = new Map>(); +const prefetchedUrls = new Set(); + +// Track elements observed by IntersectionObserver to avoid re-observing +const observedElements = new WeakSet(); + +/** + * Get the effective prefetch strategy for a link element. + * Checks the element itself, then walks up to find a container default. + */ +function getStrategy(el: HTMLAnchorElement): PrefetchStrategy { + // Check the element itself first + if (el.hasAttribute(PREFETCH_ATTR)) { + const val = el.getAttribute(PREFETCH_ATTR); + if (val === "none" || val === "viewport" || val === "load") return val; + // f-prefetch or f-prefetch="hover" or f-prefetch="" + return "hover"; + } + + // Walk up to find a container-level default + const container = el.closest(`[${PREFETCH_ATTR}]`); + if (container && container !== el) { + const val = container.getAttribute(PREFETCH_ATTR); + if (val === "none") return "none"; + if (val === "viewport") return "viewport"; + if (val === "load") return "load"; + return "hover"; + } + + return "none"; +} + +/** + * Check if data saving is preferred by the user. + */ +function shouldSaveData(): boolean { + // deno-lint-ignore no-explicit-any + const conn = (navigator as any).connection; + if (conn) { + if (conn.saveData) return true; + // Also respect slow connections + if (conn.effectiveType === "slow-2g" || conn.effectiveType === "2g") { + return true; + } + } + return false; +} + +/** + * Get the partial URL for a link, respecting f-partial attribute. + */ +function getPartialUrl(el: HTMLAnchorElement): string { + const partial = el.getAttribute(PARTIAL_ATTR); + const url = new URL(partial ? partial : el.href, location.href); + url.searchParams.set(PARTIAL_SEARCH_PARAM, "true"); + return url.href; +} + +/** + * Check if a link is eligible for prefetching. + */ +function isEligible(el: HTMLAnchorElement): boolean { + return ( + !!el.href && + (!el.target || el.target === "_self") && + el.origin === location.origin && + !el.getAttribute("href")?.startsWith("#") + ); +} + +/** + * Prefetch a URL and store in cache. + */ +function prefetch(el: HTMLAnchorElement): void { + if (shouldSaveData()) return; + + const url = getPartialUrl(el); + if (prefetchedUrls.has(url)) return; + if (prefetchCache.has(url)) { + const entry = prefetchCache.get(url)!; + if (Date.now() - entry.timestamp < CACHE_TTL) return; + } + + prefetchedUrls.add(url); + + // Deduplicate in-flight requests + if (inflightRequests.has(url)) return; + + const promise = fetch(url, { + priority: "low", + // deno-lint-ignore no-explicit-any + } as any).then((res) => { + if (res.ok) { + prefetchCache.set(url, { + response: res, + timestamp: Date.now(), + }); + } + inflightRequests.delete(url); + return res; + }).catch(() => { + inflightRequests.delete(url); + prefetchedUrls.delete(url); + return new Response(null, { status: 0 }); + }); + + inflightRequests.set(url, promise); +} + +/** + * Get a cached response for the given partial URL, if available and fresh. + */ +export function getCachedResponse(partialUrl: URL): Response | null { + const url = partialUrl.href; + const entry = prefetchCache.get(url); + if (!entry) return null; + + if (Date.now() - entry.timestamp > CACHE_TTL) { + prefetchCache.delete(url); + prefetchedUrls.delete(url); + return null; + } + + // Remove from cache after use (single use) + prefetchCache.delete(url); + prefetchedUrls.delete(url); + return entry.response; +} + +// IntersectionObserver for viewport strategy +let viewportObserver: IntersectionObserver | null = null; + +function getViewportObserver(): IntersectionObserver { + if (!viewportObserver) { + viewportObserver = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + const el = entry.target as HTMLAnchorElement; + prefetch(el); + viewportObserver!.unobserve(el); + } + } + }, + { rootMargin: "200px" }, + ); + } + return viewportObserver; +} + +/** + * Set up prefetching for a single link element. + */ +function setupLink(el: HTMLAnchorElement): void { + if (!isEligible(el)) return; + + // Check if client nav is enabled for this element + const setting = el.closest(`[${CLIENT_NAV_ATTR}]`); + if (setting === null || setting.getAttribute(CLIENT_NAV_ATTR) === "false") { + return; + } + + const strategy = getStrategy(el); + + switch (strategy) { + case "hover": + el.addEventListener("pointerenter", () => prefetch(el), { once: true }); + // Also prefetch on focus for keyboard navigation + el.addEventListener("focus", () => prefetch(el), { once: true }); + break; + case "viewport": + if (!observedElements.has(el)) { + observedElements.add(el); + getViewportObserver().observe(el); + } + break; + case "load": + prefetch(el); + break; + case "none": + break; + } +} + +/** + * Scan the document for links and set up prefetching. + */ +function scanLinks(): void { + const links = document.querySelectorAll("a[href]"); + for (const link of links) { + setupLink(link); + } +} + +// Initial scan after DOM is ready +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", scanLinks); +} else { + // Use microtask to avoid blocking + queueMicrotask(scanLinks); +} + +// Observe DOM changes to pick up dynamically added links +const mutationObserver = new MutationObserver((mutations) => { + for (const mutation of mutations) { + const nodes = mutation.addedNodes; + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + if (node instanceof HTMLAnchorElement) { + setupLink(node); + } else if (node instanceof HTMLElement) { + const links = node.querySelectorAll("a[href]"); + for (const link of links) { + setupLink(link); + } + } + } + } +}); + +mutationObserver.observe(document.body, { + childList: true, + subtree: true, +});