Skip to content

Instantly share code, notes, and snippets.

@vitobotta
Last active December 20, 2023 13:34
Show Gist options
  • Star 21 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save vitobotta/8ac3c6f65633b5edb2949aeff0dec69b to your computer and use it in GitHub Desktop.
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)
})
}
}
@StanBright
Copy link

Thanks for sharing this, mate. Unfortunately, it doesn't work with the current beta4 version.

I did spend some time debugging but couldn't adjust it.

  1. class Snapshot extends Turbo.navigator.view.getSnapshot().constructor => there is no getSnapshot function anymore. It's replaced with a simple getter. So class Snapshot extends Turbo.navigator.view.snapshot.constructor seemed to work.

  2. there is no Location class anymore. They are using URL instead.

@vitobotta
Copy link
Author

Thanks for sharing this, mate. Unfortunately, it doesn't work with the current beta4 version.

I did spend some time debugging but couldn't adjust it.

  1. class Snapshot extends Turbo.navigator.view.getSnapshot().constructor => there is no getSnapshot function anymore. It's replaced with a simple getter. So class Snapshot extends Turbo.navigator.view.snapshot.constructor seemed to work.
  2. there is no Location class anymore. They are using URL instead.

Hi!

Sorry for the late reply. Can you clarify how you fixed the 2 point? Thanks!

@vitobotta
Copy link
Author

Thanks for sharing this, mate. Unfortunately, it doesn't work with the current beta4 version.

I did spend some time debugging but couldn't adjust it.

  1. class Snapshot extends Turbo.navigator.view.getSnapshot().constructor => there is no getSnapshot function anymore. It's replaced with a simple getter. So class Snapshot extends Turbo.navigator.view.snapshot.constructor seemed to work.
  2. there is no Location class anymore. They are using URL instead.

NVM got it working :)

@StanBright
Copy link

Thanks so much @vitobotta. It's on SaaSHub production already 🚀.

p.s. the only modification that I've made is adding || linkElement.dataset.method to isPreloadable. Without it, it was trying to preload links that have method: "POST" (which is widely used in Rails).

@vitobotta
Copy link
Author

Thanks so much @vitobotta. It's on SaaSHub production already 🚀.

p.s. the only modification that I've made is adding || linkElement.dataset.method to isPreloadable. Without it, it was trying to preload links that have method: "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. :)

@leouofa
Copy link

leouofa commented Aug 19, 2021

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.

@kwhandy
Copy link

kwhandy commented Jan 30, 2022

so, how to use this?

@leouofa
Copy link

leouofa commented Jan 31, 2022

@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.

@kylesloper
Copy link

@leouofa Mind sharing the updated version of this?

@leouofa
Copy link

leouofa commented Aug 31, 2022

@kylesloper Sure, here's the new gist.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment