summaryrefslogtreecommitdiff
path: root/packages/integrations/prefetch/src/client.ts
blob: 0b37946eeca879cbb906129744ee8a5504e1b8dc (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
import throttles from 'throttles';
import '../@types/network-information.d.ts';
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.pathname &&
			!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, { passive: true, 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((el) => fetch(el.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);
		}
	});
}