diff options
author | 2022-06-27 18:26:21 +0000 | |
---|---|---|
committer | 2022-06-27 18:26:21 +0000 | |
commit | 79fe09fa3093eb8ac2871c1894b9cddf557aecba (patch) | |
tree | 46a426e4adc3fc53bd8ba039cf277be5c8607f9e /packages/integrations/prefetch/src | |
parent | 9d78162fd9cab9eb2a64f58ca5703ba5d658828f (diff) | |
download | astro-79fe09fa3093eb8ac2871c1894b9cddf557aecba.tar.gz astro-79fe09fa3093eb8ac2871c1894b9cddf557aecba.tar.zst astro-79fe09fa3093eb8ac2871c1894b9cddf557aecba.zip |
Adds a prefetch integration for near-instant page navigations (#3725)
* Adds a basic @astrojs/prefetch integration
* adding tests for custom selectors
* missed in last commit
* Adding a few docs, removing the option for `selectors` to be an element array
* adding an option for the concurrency limit
* fixing test for updated integration options
* Update packages/labs/prefetch/src/client.ts
Co-authored-by: Nate Moore <natemoo-re@users.noreply.github.com>
* nit: removing the NodeJS.Timer type to allow typescript to infer the return
* updating docs for default selector with ~=
* Skip prefetching on 2G connections, or when data saver is enabled
* refactor: moving to packages/integrations, Astro Labs TBD down the road
* README typo fix
Co-authored-by: Nate Moore <natemoo-re@users.noreply.github.com>
Diffstat (limited to 'packages/integrations/prefetch/src')
-rw-r--r-- | packages/integrations/prefetch/src/client.ts | 109 | ||||
-rw-r--r-- | packages/integrations/prefetch/src/index.ts | 17 | ||||
-rw-r--r-- | packages/integrations/prefetch/src/requestIdleCallback.ts | 16 |
3 files changed, 142 insertions, 0 deletions
diff --git a/packages/integrations/prefetch/src/client.ts b/packages/integrations/prefetch/src/client.ts new file mode 100644 index 000000000..46a688f9d --- /dev/null +++ b/packages/integrations/prefetch/src/client.ts @@ -0,0 +1,109 @@ +/// <reference path="../@types/network-information.d.ts" /> +import throttles from 'throttles'; +import requestIdleCallback from './requestIdleCallback.js'; + +const events = ['mouseenter', 'touchstart', 'focus']; + +const preloaded = new Set<string>(); + +function shouldPreload({ href }: { href: string }) { + try { + const url = new URL(href); + return ( + window.location.origin === url.origin && + window.location.pathname !== url.hash && + !preloaded.has(href) + ); + } catch {} + + return false; +} + +let parser: DOMParser; +let observer: IntersectionObserver; + +function observe(link: HTMLAnchorElement) { + preloaded.add(link.href); + observer.observe(link); + events.map((event) => link.addEventListener(event, onLinkEvent, { once: true })); +} + +function unobserve(link: HTMLAnchorElement) { + observer.unobserve(link); + events.map((event) => link.removeEventListener(event, onLinkEvent)); +} + +function onLinkEvent({ target }: Event) { + if (!(target instanceof HTMLAnchorElement)) { + return; + } + + preloadHref(target); +} + +async function preloadHref(link: HTMLAnchorElement) { + unobserve(link); + + const { href } = link; + + try { + const contents = await fetch(href).then((res) => res.text()); + parser = parser || new DOMParser(); + + const html = parser.parseFromString(contents, 'text/html'); + const styles = Array.from(html.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]')); + + await Promise.all(styles.map(({ href }) => fetch(href))); + } catch {} +} + +export interface PrefetchOptions { + /** + * Element selector used to find all links on the page that should be prefetched. + * + * @default 'a[href][rel~="prefetch"]' + */ + selector?: string; + /** + * The number of pages that can be prefetched concurrently. + * + * @default 1 + */ + throttle?: number; +} + +export default function prefetch({ selector = 'a[href][rel~="prefetch"]', throttle = 1 }: PrefetchOptions) { + const conn = navigator.connection; + + if (typeof conn !== 'undefined') { + // Don't prefetch if using 2G or if Save-Data is enabled. + if (conn.saveData) { + return Promise.reject(new Error('Cannot prefetch, Save-Data is enabled')); + } + if (/2g/.test(conn.effectiveType)) { + return Promise.reject(new Error('Cannot prefetch, network conditions are poor')); + } + } + + const [toAdd, isDone] = throttles(throttle); + + observer = + observer || + new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting && entry.target instanceof HTMLAnchorElement) { + toAdd(() => preloadHref(entry.target as HTMLAnchorElement).finally(isDone)); + } + }); + }); + + requestIdleCallback(() => { + const links = Array.from(document.querySelectorAll<HTMLAnchorElement>(selector)).filter( + shouldPreload + ); + + for (const link of links) { + observe(link); + } + }); +} diff --git a/packages/integrations/prefetch/src/index.ts b/packages/integrations/prefetch/src/index.ts new file mode 100644 index 000000000..15f52272c --- /dev/null +++ b/packages/integrations/prefetch/src/index.ts @@ -0,0 +1,17 @@ +import type { AstroIntegration } from 'astro'; +import type { PrefetchOptions } from './client.js'; + +export default function (options: PrefetchOptions = {}): AstroIntegration { + return { + name: '@astrojs/lit', + hooks: { + 'astro:config:setup': ({ updateConfig, addRenderer, injectScript }) => { + // Inject the necessary polyfills on every page (inlined for speed). + injectScript( + 'page', + `import prefetch from "@astrojs/prefetch/client.js"; prefetch(${JSON.stringify(options)});` + ); + } + } + }; +} diff --git a/packages/integrations/prefetch/src/requestIdleCallback.ts b/packages/integrations/prefetch/src/requestIdleCallback.ts new file mode 100644 index 000000000..9435bd41d --- /dev/null +++ b/packages/integrations/prefetch/src/requestIdleCallback.ts @@ -0,0 +1,16 @@ +function shim(callback: IdleRequestCallback, options?: IdleRequestOptions) { + const timeout = options?.timeout ?? 50; + const start = Date.now(); + + return setTimeout(function () { + callback({ + didTimeout: false, + timeRemaining: function () { + return Math.max(0, timeout - (Date.now() - start)); + }, + }); + }, 1); +} + +const requestIdleCallback = window.requestIdleCallback || shim; +export default requestIdleCallback; |