forked from instantpage/instant.page
-
Notifications
You must be signed in to change notification settings - Fork 0
/
instantpage.js
365 lines (294 loc) · 14.1 KB
/
instantpage.js
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
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
/*!instant.page5.2-(c)2019-23 Alexandre Dieulot;https://instant.page/license;modified by Jacob Gross*/
;(function (document, location, Date) {
'use strict'
if (!('Set' in window)) return
// min browsers: Edge 15, Firefox 54, Chrome 51, Safari 10, Opera 38, Safari Mobile 10
const handleVaryAcceptHeader = 'instantVaryAccept' in document.body.dataset || 'Shopify' in window
// The `Vary: Accept` header when received in Chromium 79–109 makes prefetches
// unusable, as Chromium used to send a different `Accept` header.
// It’s applied on all Shopify sites by default, as Shopify is very popular
// and is the main source of this problem.
// `window.Shopify` only exists on “classic” Shopify sites. Those using
// Hydrogen (Remix SPA) aren’t concerned.
let _chromiumMajorVersionInUserAgent
const chromiumUserAgentIndex = navigator.userAgent.indexOf('Chrome/')
if (chromiumUserAgentIndex > -1) {
_chromiumMajorVersionInUserAgent = parseInt(navigator.userAgent.substring(chromiumUserAgentIndex + 'Chrome/'.length))
}
// The user agent client hints API is a theoretically more reliable way to
// get Chromium’s version… but it’s not available in Samsung Internet 20.
// It also requires a secure context, which would make debugging harder,
// and is only available in recent Chromium versions.
// In practice, Chromium browsers never shy from announcing "Chrome" in
// their regular user agent string, as that maximizes their compatibility.
if (handleVaryAcceptHeader && _chromiumMajorVersionInUserAgent && _chromiumMajorVersionInUserAgent < 110) {
return
}
const prefetcher = document.createElement('link')
const head = document.head
head.appendChild(prefetcher)
// this set is needed to take care of the "exceeded 10 prerenders" message, which happens if you try to prerender
// the same URL multiple times. This issue is fixed in Chromium 110+. https://crbug.com/1397727
const preloadedUrls = new Set()
let mouseoverTimer = 0
let lastTouchTimestamp = 0
let relList = prefetcher.relList
const supports = relList !== undefined && relList.supports !== undefined // need this check, as Edge < 17, Safari < 10.1, Safari Mobile < 10.3 don't support this
const isPrerenderSupported = supports && relList.supports('prerender')
const preload = !supports || relList.supports('prefetch') ? _prefetch : relList.supports('preload') ? _preload : false // Safari (11.1, mobile 11.3) only supports preload; for other browser we prefer prefetch over preload
if (!preload && !isPrerenderSupported) return // nothing we can do for the browser.
const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection || {}
const effectiveType = typeof connection.effectiveType === 'string' ? connection.effectiveType : ''
const has3G = effectiveType.indexOf('3g') !== -1
const saveData = connection.saveData || effectiveType.indexOf('2g') !== -1
let dataset = document.body.dataset
const mousedownShortcut = 'instantMousedownShortcut' in dataset
const allowQueryString = 'instantAllowQueryString' in dataset
const allowExternalLinks = 'instantAllowExternalLinks' in dataset
const allowSpeculationRules =
!('instantNoSpeculation' in dataset) && HTMLScriptElement.supports && HTMLScriptElement.supports('speculationrules')
const useWhitelist = 'instantWhitelist' in document.body.dataset
const useViewport =
!saveData &&
('instantViewport' in dataset ||
// Smartphones are the most likely to have a slow connection, and
// their small screen size limits the number of links (and thus
// server load).
//
// Foldable phones (being expensive as of 2023), tablets and PCs
// generally have a decent connection, and a big screen displaying
// more links that would put more load on the server.
//
// iPhone 14 Pro Max (want): 430×932 = 400 760
// Samsung Galaxy S22 Ultra with display size set to 80% (want):
// 450×965 = 434 250
// Small tablet (don’t want): 600×960 = 576 000
// Those number are virtual screen size, the viewport (used for
// the check above) will be smaller with the browser’s interface.
('instantViewportMobile' in dataset && document.documentElement.clientWidth * document.documentElement.clientHeight < 450000))
const DELAY_TO_NOT_BE_CONSIDERED_A_TOUCH_INITIATED_ACTION = 1111
const HOVER_DELAY = 'instantIntensity' in dataset ? +dataset.instantIntensity : 65
// only trigger `prefetch` requests on mobile, as ~90ms is too slow to get a preload done on mobile;
// we need to do this, as prefetch + speculationrules do not share the same request (so we might end up with 2-3 reqs)
if (preload !== _preload) document.addEventListener('touchstart', touchstartListener, { capture: true, passive: true })
let listenerOptions = { capture: true }
document.addEventListener('mouseover', mouseoverListener, listenerOptions)
if (mousedownShortcut) document.addEventListener('mousedown', mousedownShortcutListener, listenerOptions)
if (isPrerenderSupported) document.addEventListener('mousedown', mousedownListener, listenerOptions) // after 'mousedown' it leaves us ~80ms prerender time to mouseup.
if (useViewport && window.IntersectionObserver && 'isIntersecting' in IntersectionObserverEntry.prototype) {
// https://verlok.github.io/quicklink-optimal-options/
const PREFETCH_LIMIT = !has3G ? (allowExternalLinks ? +dataset.instantLimit : 1 / 0) : 1 // Infinity
const SCROLL_DELAY = 'instantScrollDelay' in dataset ? +dataset.instantScrollDelay : 1000
const THRESHOLD = 'instantThreshold' in dataset ? +dataset.instantThreshold : 0.9
const SELECTOR = 'instantSelector' in dataset ? dataset.instantSelector : 'a'
const triggeringFunction = callback => {
requestIdleCallback(callback, {
timeout: 1500,
})
}
const hrefsInViewport = new Set()
let len = 0
triggeringFunction(() => {
const intersectionObserver = new IntersectionObserver(
entries => {
for (let i = 0; i < entries.length; ++i) {
if (len > PREFETCH_LIMIT) return
const entry = entries[i]
const linkElement = entry.target
if (entry.isIntersecting) {
// Adding href to array of hrefsInViewport
hrefsInViewport.add(linkElement.href)
++len
setTimeout(() => {
// Do not prefetch if not found in viewport
if (!hrefsInViewport.has(linkElement.href)) return
intersectionObserver.unobserve(linkElement)
preload(linkElement.href, false, true)
}, SCROLL_DELAY)
} else {
--len
hrefsInViewport.delete(linkElement.href)
}
}
},
{ threshold: THRESHOLD }
)
const nodes = document.querySelectorAll(SELECTOR)
for (let i = 0; i < nodes.length; ++i) {
const node = nodes[i]
if (isPreloadable(node)) {
intersectionObserver.observe(node)
}
}
})
}
dataset = relList = listenerOptions = null // GC
let isMobile = false
function checkForClosestAnchor(event, relatedTarget) {
const target = !relatedTarget ? event.target : event.relatedTarget
if (!target || typeof target.closest !== 'function') return
return target.closest('a')
}
/**
* @param {{ target: { closest: (arg0: string) => any; }; }} event
*/
function touchstartListener(event) {
isMobile = true
/* Chrome on Android calls mouseover before touchcancel so `lastTouchTimestamp`
* must be assigned on touchstart to be measured on mouseover. */
lastTouchTimestamp = Date.now()
const linkElement = checkForClosestAnchor(event)
if (!isPreloadable(linkElement)) return
window.addEventListener('scroll', mouseoutListener, { once: true }) // if a scroll occurs before HOVER_DELAY, user is scrolling around
mouseoverTimer = setTimeout(mouseoverTimeout.bind(undefined, linkElement, true), HOVER_DELAY)
}
/**
* @param {{ target: { closest: (arg0: string) => any; }; }} event
*/
function mouseoverListener(event) {
if (Date.now() - lastTouchTimestamp < DELAY_TO_NOT_BE_CONSIDERED_A_TOUCH_INITIATED_ACTION) return
const linkElement = checkForClosestAnchor(event)
if (!isPreloadable(linkElement)) return
linkElement.addEventListener('mouseout', mouseoutListener)
mouseoverTimer = setTimeout(mouseoverTimeout.bind(undefined, linkElement, false), HOVER_DELAY)
}
function mouseoverTimeout(linkElement, important) {
if (isPrerenderSupported && isMobile) prerender(linkElement.href, important)
else preload(linkElement.href, important, !(isMobile && (saveData || has3G))) // on mobile we want to cancel requests when data saver is enabled or user has slow connection
mouseoverTimer = undefined
}
/**
* @param {{ relatedTarget: { closest: (arg0: string) => any; }; target: { closest: (arg0: string) => any; }; }} event
*/
function mouseoutListener(event) {
if (checkForClosestAnchor(event) === checkForClosestAnchor(event, true)) return
if (mouseoverTimer) {
clearTimeout(mouseoverTimer)
mouseoverTimer = undefined
return
}
stopPreloading()
}
/**
* @param {{ which: number; metaKey: any; ctrlKey: any; target: { closest: (arg0: string) => any; }; }} event
*/
function mousedownShortcutListener(event) {
if (Date.now() - lastTouchTimestamp < DELAY_TO_NOT_BE_CONSIDERED_A_TOUCH_INITIATED_ACTION) return
if (event.which > 1 || event.metaKey || event.ctrlKey) return
const linkElement = checkForClosestAnchor(event)
if (!linkElement || 'noInstant' in linkElement.dataset || linkElement.getAttribute('download') !== null) return // we don't use isPreloadable because this might lead to external links
linkElement.addEventListener(
'click',
ev => {
if (ev.detail === 1337) return
ev.preventDefault()
},
{ capture: true, once: true }
)
const customEvent = new MouseEvent('click', { bubbles: true, cancelable: true, detail: 1337, view: window })
linkElement.dispatchEvent(customEvent)
}
function mousedownListener(event) {
if (Date.now() - lastTouchTimestamp < DELAY_TO_NOT_BE_CONSIDERED_A_TOUCH_INITIATED_ACTION) return
if (event.which > 1 || event.metaKey || event.ctrlKey) return
const linkElement = checkForClosestAnchor(event)
if (!isPreloadable(linkElement, true)) return
prerender(linkElement.href, true)
}
/**
* @param {HTMLElement} linkElement
* @param ignoreUrlCheck
*/
function isPreloadable(linkElement, ignoreUrlCheck) {
let href
if (!linkElement || !(href = linkElement.href)) return false
if ((!ignoreUrlCheck && preloadedUrls.has(href)) || href.charCodeAt(0) === 35 /* # */) return false
const preloadLocation = new URL(href)
if (linkElement.origin != location.origin) {
let allowed = allowExternalLinks || 'instant' in linkElement.dataset
if (!allowed || !_chromiumMajorVersionInUserAgent) {
return false
}
}
if (preloadLocation.protocol !== 'http:' && preloadLocation.protocol !== 'https:') return false
if (preloadLocation.protocol === 'http:' && location.protocol === 'https:') return false
if ((useWhitelist || (!allowQueryString && preloadLocation.search)) && !('instant' in linkElement.dataset)) return false
if (preloadLocation.hash && preloadLocation.pathname + preloadLocation.search === location.pathname + location.search) return false
if ('noInstant' in linkElement.dataset) return false
if (linkElement.getAttribute('download') !== null) return false
return true
}
/**
* @param {string} url
* @param important
* @param newTag
*/
function _prefetch(url, important, newTag) {
console.log('prefetch', url)
preloadedUrls.add(url)
// trigger PrerenderV2: https://chromestatus.com/feature/5197044678393856
// Before Chromium 107, adding another speculation tag instead of modifying the existing one fails
/*if (allowSpeculationRules) { // && chromiumMajorVersionClientHint >= 107; but this check would exclude other Chromium browsers (Opera) that support this.
const speculationTag = document.createElement('script')
speculationTag.textContent = JSON.stringify({ prefetch: [{ source: 'list', urls: [url] }] })
speculationTag.type = 'speculationrules'
head.appendChild(speculationTag)
return;
}*/
const fetcher = newTag ? document.createElement('link') : prefetcher
if (important) fetcher.setAttribute('fetchPriority', 'high')
fetcher.href = url
fetcher.rel = 'prefetch'
fetcher.as = 'document'
// as=document is Chromium-only and allows cross-origin prefetches to be
// usable for navigation. They call it “restrictive prefetch” and intend
// to remove it: https://crbug.com/1352371
if (newTag) head.appendChild(fetcher)
}
/**
* @param {string} url
* @param important
*/
function prerender(url, important) {
console.log('prerender', url)
preloadedUrls.add(url)
// trigger PrerenderV2: https://chromestatus.com/feature/5197044678393856
// Before Chromium 107, adding another speculation tag instead of modifying the existing one fails
if (allowSpeculationRules) {
// && chromiumMajorVersionClientHint >= 107; but this check would exclude other Chromium browsers (Opera) that support this.
const speculationTag = document.createElement('script')
speculationTag.textContent = JSON.stringify({ prerender: [{ source: 'list', urls: [url] }] })
speculationTag.type = 'speculationrules'
head.appendChild(speculationTag)
return
}
if (important) prefetcher.setAttribute('fetchPriority', 'high')
prefetcher.href = url
prefetcher.rel = 'prerender'
}
/**
* @param {string} url
* @param important
* @param newTag
*/
function _preload(url, important, newTag) {
if (isMobile)
// ~90ms is too slow to get a preload done on mobile (request can not be re-used)
return
console.log('preload', url)
preloadedUrls.add(url)
const fetcher = newTag ? document.createElement('link') : prefetcher
// although Safari doesn't support `fetchPriority`, we can still set it (and hope for the best in the future)
if (important) fetcher.setAttribute('fetchPriority', 'high')
fetcher.as = 'fetch' // Safari doesn't support `document`
fetcher.href = url
fetcher.rel = 'preload' // Safari wants preload set last
if (newTag) head.appendChild(fetcher)
}
function stopPreloading() {
prefetcher.removeAttribute('rel') // so we don't trigger an empty prerender
prefetcher.removeAttribute('href') // might not cancel, if this isn't removed
prefetcher.removeAttribute('fetchPriority')
prefetcher.removeAttribute('as')
}
})(document, location, Date)