-
-
Save vitobotta/8ac3c6f65633b5edb2949aeff0dec69b to your computer and use it in GitHub Desktop.
// This code is to be used with https://turbo.hotwire.dev. By default Turbo keeps visited pages in its cache | |
// so that when you visit one of those pages again, Turbo will fetch the copy from cache first and present that to the user, then | |
// it will fetch the updated page from the server and replace the preview. This makes for a much more responsive navigation | |
// between pages. We can improve this further with the code in this file. It enables automatic prefetching of a page when you | |
// hover with the mouse on a link or touch it on a mobile device. There is a delay between the mouseover event and the click | |
// event, so with this trick the page is already being fetched before the click happens, speeding up also the first | |
// view of a page not yet in cache. When the page has been prefetched it is then added to Turbo's cache so it's available for | |
// the next visit during the same session. Turbo's default behavior plus this trick make for much more responsive UIs (non SPA). | |
let lastTouchTimestamp | |
let delayOnHover = 65 | |
let mouseoverTimer | |
const pendingPrefetches = new Set() | |
const eventListenersOptions = { | |
capture: true, | |
passive: true, | |
} | |
class Snapshot extends Turbo.navigator.view.snapshot.constructor { | |
} | |
document.addEventListener('touchstart', touchstartListener, eventListenersOptions) | |
document.addEventListener('mouseover', mouseoverListener, eventListenersOptions) | |
function touchstartListener(event) { | |
if (window.disablePrefetch) { | |
return | |
} | |
/* Chrome on Android calls mouseover before touchcancel so `lastTouchTimestamp` | |
* must be assigned on touchstart to be measured on mouseover. */ | |
lastTouchTimestamp = performance.now() | |
const linkElement = event.target.closest('a') | |
if (!isPreloadable(linkElement)) { | |
return | |
} | |
preload(linkElement) | |
} | |
function mouseoverListener(event) { | |
if (window.disablePrefetch) { | |
return | |
} | |
if (performance.now() - lastTouchTimestamp < 1111) { | |
return | |
} | |
const linkElement = event.target.closest('a') | |
if (!isPreloadable(linkElement)) { | |
return | |
} | |
const url = linkElement.getAttribute("href") | |
const loc = new URL(url, location.protocol + "//" + location.host).toString() | |
const absoluteUrl = loc.toString() | |
if (pendingPrefetches.has(absoluteUrl)) { | |
return | |
} | |
pendingPrefetches.add(absoluteUrl) | |
linkElement.addEventListener('mouseout', mouseoutListener, {passive: true}) | |
mouseoverTimer = setTimeout(() => { | |
preload(linkElement) | |
mouseoverTimer = undefined | |
}, delayOnHover) | |
} | |
function mouseoutListener(event) { | |
if (event.relatedTarget && event.target.closest('a') == event.relatedTarget.closest('a')) { | |
return | |
} | |
if (mouseoverTimer) { | |
clearTimeout(mouseoverTimer) | |
mouseoverTimer = undefined | |
} | |
} | |
function isPreloadable(linkElement) { | |
if (!linkElement || !linkElement.getAttribute("href") || linkElement.dataset.turbo == "false" || linkElement.dataset.prefetch == "false") { | |
return | |
} | |
if (linkElement.origin != location.origin) { | |
return | |
} | |
if (!['http:', 'https:'].includes(linkElement.protocol)) { | |
return | |
} | |
if (linkElement.protocol == 'http:' && location.protocol == 'https:') { | |
return | |
} | |
if (linkElement.search && !('prefetch' in linkElement.dataset)) { | |
return | |
} | |
if (linkElement.hash && linkElement.pathname + linkElement.search == location.pathname + location.search) { | |
return | |
} | |
return true | |
} | |
function fetchPage (url, success) { | |
const xhr = new XMLHttpRequest() | |
xhr.open('GET', url) | |
xhr.setRequestHeader('VND.PREFETCH', 'true') | |
xhr.setRequestHeader('Accept', 'text/html') | |
xhr.onreadystatechange = () => { | |
if (xhr.readyState !== XMLHttpRequest.DONE) return | |
if (xhr.status !== 200) return | |
success(xhr.responseText) | |
} | |
xhr.send() | |
} | |
function preload(link) { | |
const url = link.getAttribute("href") | |
const loc = new URL(url, location.protocol + "//" + location.host) | |
const absoluteUrl = loc.toString() | |
if (link.dataset.prefetchWithLink == "true") { | |
const prefetcher = document.createElement('link') | |
prefetcher.rel = 'prefetch' | |
prefetcher.href = url | |
document.head.appendChild(prefetcher) | |
pendingPrefetches.delete(absoluteUrl) | |
} else if (!Turbo.navigator.view.snapshotCache.has(loc)) { | |
fetchPage(url, responseText => { | |
const snapshot = Snapshot.fromHTMLString(responseText) | |
Turbo.navigator.view.snapshotCache.put(loc, snapshot) | |
pendingPrefetches.delete(absoluteUrl) | |
}) | |
} | |
} | |
Thanks so much @vitobotta. It's on SaaSHub production already 🚀.
p.s. the only modification that I've made is adding
|| linkElement.dataset.method
toisPreloadable
. Without it, it was trying to preload links that havemethod: "POST"
(which is widely used in Rails).
Nice, I saw DynaBlogger on SaaSHub :D
I'll add that check about the method too. I usually add the data-prefetch="false" attribute to links that I don't want to be prefetched in general. :)
Thank you for sharing this.
Looks like the internal api has changed and const snapshot = Snapshot.fromHTMLString(responseText)
is breaking.
I was able to fix it by changing it to const snapshot = Turbo.PageSnapshot.fromHTMLString(responseText)
.
I was able to remove class Snapshot extends Turbo.navigator.view.snapshot.constructor {}
after that.
so, how to use this?
@kwhandy I use a version of this (via stimulus) to preload pages the users are likely to click on before they click it. Makes everything a lot faster.
@leouofa Mind sharing the updated version of this?
@kylesloper Sure, here's the new gist.
Thanks so much @vitobotta. It's on SaaSHub production already 🚀.
p.s. the only modification that I've made is adding
|| linkElement.dataset.method
toisPreloadable
. Without it, it was trying to preload links that havemethod: "POST"
(which is widely used in Rails).