summaryrefslogtreecommitdiff
path: root/packages/integrations/prefetch/src/client.ts
blob: dc05cb84b0f1759f7cf2bdb36b2939f417401e63 (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
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
/// <reference types="../@types/network-information.d.ts" />
import throttles from 'throttles';
import requestIdleCallback from './requestIdleCallback.js';

const events = ['mouseenter', 'touchstart', 'focus'];

const preloaded = new Set<string>();
const loadedStyles = 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 ||= new DOMParser();

		const html = parser.parseFromString(contents, 'text/html');
		const styles = Array.from(html.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]'));

		await Promise.all(
			styles
				.filter((el) => !loadedStyles.has(el.href))
				.map((el) => {
					loadedStyles.add(el.href);
					return 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;
	/**
	 * Element selector used to find all links on the page that should be prefetched on user interaction.
	 *
	 * @default 'a[href][rel~="prefetch-intent"]'
	 */
	intentSelector?: string | string[];
}

export default function prefetch({
	selector = 'a[href][rel~="prefetch"]',
	throttle = 1,
	intentSelector = 'a[href][rel~="prefetch-intent"]',
}: PrefetchOptions) {
	// If the navigator is offline, it is very unlikely that a request can be made successfully
	if (!navigator.onLine) {
		return Promise.reject(new Error('Cannot prefetch, no network connection'));
	}

	// `Navigator.connection` is an experimental API and is not supported in every browser.
	if ('connection' in navigator) {
		const connection = (navigator as any).connection;
		// Don't prefetch if Save-Data is enabled.
		if (connection.saveData) {
			return Promise.reject(new Error('Cannot prefetch, Save-Data is enabled'));
		}

		// Do not prefetch if using 2G or 3G
		if (/(2|3)g/.test(connection.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) {
					const relAttributeValue = entry.target.getAttribute('rel') || '';
					let matchesIntentSelector = false;
					// Check if intentSelector is an array
					if (Array.isArray(intentSelector)) {
						// If intentSelector is an array, use .some() to check for matches
						matchesIntentSelector = intentSelector.some((intent) =>
							relAttributeValue.includes(intent)
						);
					} else {
						// If intentSelector is a string, use .includes() to check for a match
						matchesIntentSelector = relAttributeValue.includes(intentSelector);
					}
					if (!matchesIntentSelector) {
						toAdd(() => preloadHref(entry.target as HTMLAnchorElement).finally(isDone));
					}
				}
			});
		});

	requestIdleCallback(() => {
		const links = [...document.querySelectorAll<HTMLAnchorElement>(selector)].filter(shouldPreload);
		links.forEach(observe);

		const intentSelectorFinal = Array.isArray(intentSelector)
			? intentSelector.join(',')
			: intentSelector;
		// Observe links with prefetch-intent
		const intentLinks = [
			...document.querySelectorAll<HTMLAnchorElement>(intentSelectorFinal),
		].filter(shouldPreload);
		intentLinks.forEach((link) => {
			events.map((event) =>
				link.addEventListener(event, onLinkEvent, { passive: true, once: true })
			);
		});
	});
}