Skip to content

Instantly share code, notes, and snippets.

@phacks
Last active January 14, 2022 10:49
Show Gist options
  • Save phacks/9be4a4ceb27e51f60f7670f28e7f5280 to your computer and use it in GitHub Desktop.
Save phacks/9be4a4ceb27e51f60f7670f28e7f5280 to your computer and use it in GitHub Desktop.
/* InstantClick 3.1.0 | (C) 2014-2017 Alexandre Dieulot | http://instantclick.io/license */
var instantclick,
InstantClick = (instantclick = (function(document, location, $userAgent) {
// Internal variables
var $currentLocationWithoutHash,
$urlToPreload,
$preloadTimer,
$lastTouchTimestamp,
$hasBeenInitialized,
$touchEndedWithoutClickTimer,
$lastUsedTimeoutId = 0,
// Preloading-related variables
$history = {},
$fetchedBodies = {},
$xhr,
$url = false,
$title = false,
$isContentTypeNotHTML,
$areTrackedElementsDifferent,
$body = false,
$lastDisplayTimestamp = 0,
$isPreloading = false,
$isWaitingForCompletion = false,
$gotANetworkError = false,
$trackedElementsData = [],
// Variables defined by public functions
$preloadOnMousedown,
$delayBeforePreload = 65,
$eventsCallbacks = {
preload: [],
receive: [],
wait: [],
change: [],
restore: [],
exit: [],
},
$timers = {},
$currentPageXhrs = [],
$windowEventListeners = {},
$delegatedEvents = {};
////////// POLYFILL //////////
// Needed for `addEvent`
if (!Element.prototype.matches) {
Element.prototype.matches =
Element.prototype.webkitMatchesSelector ||
Element.prototype.msMatchesSelector ||
function(selector) {
var matches = document.querySelectorAll(selector);
for (var i = 0; i < matches.length; i++) {
if (matches[i] == this) {
return true;
}
}
return false;
};
}
////////// HELPERS //////////
function removeHash(url) {
var index = url.indexOf("#");
if (index == -1) {
return url;
}
return url.substr(0, index);
}
function getParentLinkElement(element) {
while (element && element.nodeName != "A") {
element = element.parentNode;
}
// `element` will be null if no link element is found
return element;
}
function isBlacklisted(element) {
do {
if (!element.hasAttribute) {
// Parent of <html>
break;
}
if (element.hasAttribute("data-instant")) {
return false;
}
if (element.hasAttribute("data-no-instant")) {
return true;
}
} while ((element = element.parentNode));
return false;
}
function isPreloadable(linkElement) {
var domain = location.protocol + "//" + location.host;
if (
linkElement.target || // target="_blank" etc.
linkElement.hasAttribute("download") ||
linkElement.href.indexOf(domain + "/") != 0 || // Another domain, or no href attribute
(linkElement.href.indexOf("#") > -1 &&
removeHash(linkElement.href) == $currentLocationWithoutHash) || // Anchor
isBlacklisted(linkElement)
) {
return false;
}
return true;
}
function triggerPageEvent(eventType) {
var argumentsToApply = Array.prototype.slice.call(arguments, 1),
returnValue = false;
for (var i = 0; i < $eventsCallbacks[eventType].length; i++) {
if (eventType == "receive") {
var altered = $eventsCallbacks[eventType][i].apply(
window,
argumentsToApply
);
if (altered) {
// Update arguments for the next iteration of the loop.
if ("body" in altered) {
argumentsToApply[1] = altered.body;
}
if ("title" in altered) {
argumentsToApply[2] = altered.title;
}
returnValue = altered;
}
} else {
$eventsCallbacks[eventType][i].apply(window, argumentsToApply);
}
}
return returnValue;
}
function changePage(title, body, urlToPush, scrollPosition) {
abortCurrentPageXhrs();
document.documentElement.replaceChild(body, document.body);
// We cannot just use `document.body = doc.body`, it causes Safari (tested
// 5.1, 6.0 and Mobile 7.0) to execute script tags directly.
document.title = title;
removeExpiredKeys("force");
if (urlToPush) {
addOrRemoveWindowEventListeners("remove");
if (urlToPush != location.href) {
history.pushState(null, null, urlToPush);
if ($userAgent.indexOf(" CriOS/") > -1) {
// Chrome for iOS:
//
// 1. Removes title in tab on pushState, so it needs to be set after.
//
// 2. Will not set the title if it's identical after trimming, so we
// add a non-breaking space.
if (document.title == title) {
document.title = title + String.fromCharCode(160);
} else {
document.title = title;
}
}
}
var hashIndex = urlToPush.indexOf("#"),
offsetElement =
hashIndex > -1 &&
document.getElementById(urlToPush.substr(hashIndex + 1)),
offset = 0;
if (offsetElement) {
while (offsetElement.offsetParent) {
offset += offsetElement.offsetTop;
offsetElement = offsetElement.offsetParent;
}
}
if ("requestAnimationFrame" in window) {
// Safari on macOS doesn't immediately visually change the page on
// `document.documentElement.replaceChild`, so if `scrollTo` is called
// without `requestAnimationFrame` it often scrolls before the page
// is displayed.
requestAnimationFrame(function() {
scrollTo(0, offset);
});
} else {
scrollTo(0, offset);
// Safari on macOS scrolls before the page is visually changed, but
// adding `requestAnimationFrame` doesn't fix it in this case.
}
clearCurrentPageTimeouts();
$currentLocationWithoutHash = removeHash(urlToPush);
if ($currentLocationWithoutHash in $windowEventListeners) {
$windowEventListeners[$currentLocationWithoutHash] = [];
}
$timers[$currentLocationWithoutHash] = {};
applyScriptElements(function(element) {
return !element.hasAttribute("data-instant-track");
});
triggerPageEvent("change", false);
} else {
// On popstate, browsers scroll by themselves, but at least Firefox
// scrolls BEFORE popstate is fired and thus before we can replace the
// page. If the page before popstate is too short the user won't be
// scrolled at the right position as a result. We need to scroll again.
scrollTo(0, scrollPosition);
// iOS's gesture to go back by swiping from the left edge of the screen
// will start a preloading if the user touches a link, it needs to be
// cancelled otherwise the page behind the touched link will be
// displayed.
$xhr.abort();
setPreloadingAsHalted();
applyScriptElements(function(element) {
return element.hasAttribute("data-instant-restore");
});
restoreTimers();
triggerPageEvent("restore");
}
}
function setPreloadingAsHalted() {
$isPreloading = false;
$isWaitingForCompletion = false;
}
function removeNoscriptTags(html) {
// Must be done on text, not on a node's innerHTML, otherwise strange
// things happen with implicitly closed elements (see the Noscript test).
return html.replace(/<noscript[\s\S]+?<\/noscript>/gi, "");
}
function abortCurrentPageXhrs() {
for (var i = 0; i < $currentPageXhrs.length; i++) {
if (
typeof $currentPageXhrs[i] == "object" &&
"abort" in $currentPageXhrs[i]
) {
$currentPageXhrs[i].instantclickAbort = true;
$currentPageXhrs[i].abort();
}
}
$currentPageXhrs = [];
}
function clearCurrentPageTimeouts() {
for (var i in $timers[$currentLocationWithoutHash]) {
var timeout = $timers[$currentLocationWithoutHash][i];
window.clearTimeout(timeout.realId);
timeout.delayLeft = timeout.delay - +new Date() + timeout.timestamp;
}
}
function restoreTimers() {
for (var i in $timers[$currentLocationWithoutHash]) {
if (!("delayLeft" in $timers[$currentLocationWithoutHash][i])) {
continue;
}
var args = [
$timers[$currentLocationWithoutHash][i].callback,
$timers[$currentLocationWithoutHash][i].delayLeft,
];
for (
var j = 0;
j < $timers[$currentLocationWithoutHash][i].params.length;
j++
) {
args.push($timers[$currentLocationWithoutHash][i].params[j]);
}
addTimer(
args,
$timers[$currentLocationWithoutHash][i].isRepeating,
$timers[$currentLocationWithoutHash][i].delay
);
delete $timers[$currentLocationWithoutHash][i];
}
}
function handleTouchendWithoutClick() {
$xhr.abort();
setPreloadingAsHalted();
}
function addOrRemoveWindowEventListeners(addOrRemove) {
if ($currentLocationWithoutHash in $windowEventListeners) {
for (
var i = 0;
i < $windowEventListeners[$currentLocationWithoutHash].length;
i++
) {
window[addOrRemove + "EventListener"].apply(
window,
$windowEventListeners[$currentLocationWithoutHash][i]
);
}
}
}
function applyScriptElements(condition) {
var scriptElementsInDOM = document.body.getElementsByTagName("script"),
scriptElementsToCopy = [],
originalElement,
copyElement,
parentNode,
nextSibling,
i;
// `scriptElementsInDOM` will change during the copy of scripts if
// a script add or delete script elements, so we need to put script
// elements in an array to loop through them correctly.
for (i = 0; i < scriptElementsInDOM.length; i++) {
scriptElementsToCopy.push(scriptElementsInDOM[i]);
}
for (i = 0; i < scriptElementsToCopy.length; i++) {
originalElement = scriptElementsToCopy[i];
if (!originalElement) {
// Might have disappeared, see previous comment
continue;
}
if (!condition(originalElement)) {
continue;
}
copyElement = document.createElement("script");
for (var j = 0; j < originalElement.attributes.length; j++) {
copyElement.setAttribute(
originalElement.attributes[j].name,
originalElement.attributes[j].value
);
}
copyElement.textContent = originalElement.textContent;
parentNode = originalElement.parentNode;
nextSibling = originalElement.nextSibling;
parentNode.removeChild(originalElement);
parentNode.insertBefore(copyElement, nextSibling);
}
}
function addTrackedElements() {
var trackedElements = document.querySelectorAll("[data-instant-track]"),
element,
elementData;
for (var i = 0; i < trackedElements.length; i++) {
element = trackedElements[i];
elementData =
element.getAttribute("href") ||
element.getAttribute("src") ||
element.textContent;
// We can't use just `element.href` and `element.src` because we can't
// retrieve `href`s and `src`s from the Ajax response.
$trackedElementsData.push(elementData);
}
}
function addTimer(args, isRepeating, realDelay) {
var callback = args[0],
delay = args[1],
params = [].slice.call(args, 2),
timestamp = +new Date();
$lastUsedTimeoutId++;
var id = $lastUsedTimeoutId;
var callbackModified;
if (isRepeating) {
callbackModified = function(args2) {
callback(args2);
delete $timers[$currentLocationWithoutHash][id];
args[0] = callback;
args[1] = delay;
addTimer(args, true);
};
} else {
callbackModified = function(args2) {
callback(args2);
delete $timers[$currentLocationWithoutHash][id];
};
}
args[0] = callbackModified;
if (realDelay != undefined) {
timestamp += delay - realDelay;
delay = realDelay;
}
var realId = window.setTimeout.apply(window, args);
$timers[$currentLocationWithoutHash][id] = {
realId: realId,
timestamp: timestamp,
callback: callback,
delay: delay,
params: params,
isRepeating: isRepeating,
};
return -id;
}
////////// EVENT LISTENERS //////////
function mousedownListener(event) {
var linkElement = getParentLinkElement(event.target);
if (!linkElement || !isPreloadable(linkElement)) {
return;
}
preload(linkElement.href);
}
function mouseoverListener(event) {
if ($lastTouchTimestamp > +new Date() - 500) {
// On a touch device, if the content of the page change on mouseover
// click is never fired and the user will need to tap a second time.
// https://developer.apple.com/library/content/documentation/AppleApplications/Reference/SafariWebContent/HandlingEvents/HandlingEvents.html#//apple_ref/doc/uid/TP40006511-SW4
//
// Content change could happen in the `preload` event, so we stop there.
return;
}
if (+new Date() - $lastDisplayTimestamp < 100) {
// After a page is displayed, if the user's cursor happens to be above
// a link a mouseover event will be in most browsers triggered
// automatically, and in other browsers it will be triggered when the
// user moves his mouse by 1px.
//
// Here are the behaviors I noticed, all on Windows:
// - Safari 5.1: auto-triggers after 0 ms
// - IE 11: auto-triggers after 30-80 ms (depends on page's size?)
// - Firefox: auto-triggers after 10 ms
// - Opera 18: auto-triggers after 10 ms
//
// - Chrome: triggers when cursor moved
// - Opera 12.16: triggers when cursor moved
//
// To remedy to this, we do nothing if the last display occurred less
// than 100 ms ago.
return;
}
var linkElement = getParentLinkElement(event.target);
if (!linkElement) {
return;
}
if (linkElement == getParentLinkElement(event.relatedTarget)) {
// Happens when mouseout-ing and mouseover-ing child elements of the same link element
return;
}
if (!isPreloadable(linkElement)) {
return;
}
linkElement.addEventListener("mouseout", mouseoutListener);
if (!$isWaitingForCompletion) {
$urlToPreload = linkElement.href;
$preloadTimer = setTimeout(preload, $delayBeforePreload);
}
}
function touchstartListener(event) {
$lastTouchTimestamp = +new Date();
var linkElement = getParentLinkElement(event.target);
if (!linkElement || !isPreloadable(linkElement)) {
return;
}
if ($touchEndedWithoutClickTimer) {
clearTimeout($touchEndedWithoutClickTimer);
$touchEndedWithoutClickTimer = false;
}
linkElement.addEventListener("touchend", touchendAndTouchcancelListener);
linkElement.addEventListener(
"touchcancel",
touchendAndTouchcancelListener
);
preload(linkElement.href);
}
function clickListenerPrelude() {
// Makes clickListener be fired after everyone else, so that we can respect
// event.preventDefault.
document.addEventListener("click", clickListener);
}
function clickListener(event) {
document.removeEventListener("click", clickListener);
if ($touchEndedWithoutClickTimer) {
clearTimeout($touchEndedWithoutClickTimer);
$touchEndedWithoutClickTimer = false;
}
if (event.defaultPrevented) {
return;
}
var linkElement = getParentLinkElement(event.target);
if (!linkElement || !isPreloadable(linkElement)) {
return;
}
// Check if it's opening in a new tab
if (
event.button != 0 || // Chrome < 55 fires a click event when the middle mouse button is pressed
event.metaKey ||
event.ctrlKey
) {
return;
}
event.preventDefault();
display(linkElement.href);
}
function mouseoutListener(event) {
if (
getParentLinkElement(event.target) ==
getParentLinkElement(event.relatedTarget)
) {
// Happens when mouseout-ing and mouseover-ing child elements of the same link element,
// we don't want to stop preloading then.
return;
}
if ($preloadTimer) {
clearTimeout($preloadTimer);
$preloadTimer = false;
return;
}
if (!$isPreloading || $isWaitingForCompletion) {
return;
}
$xhr.abort();
setPreloadingAsHalted();
}
function touchendAndTouchcancelListener(event) {
if (!$isPreloading || $isWaitingForCompletion) {
return;
}
$touchEndedWithoutClickTimer = setTimeout(
handleTouchendWithoutClick,
500
);
}
function readystatechangeListener() {
if ($xhr.readyState == 2) {
// headers received
var contentType = $xhr.getResponseHeader("Content-Type");
if (!contentType || !/^text\/html/i.test(contentType)) {
$isContentTypeNotHTML = true;
}
}
if ($xhr.readyState < 4) {
return;
}
if ($xhr.status == 0) {
// Request error/timeout/abort
$gotANetworkError = true;
if ($isWaitingForCompletion) {
triggerPageEvent("exit", $url, "network error");
location.href = $url;
}
return;
}
if ($isContentTypeNotHTML) {
if ($isWaitingForCompletion) {
triggerPageEvent("exit", $url, "non-html content-type");
location.href = $url;
}
return;
}
var doc = document.implementation.createHTMLDocument("");
doc.documentElement.innerHTML = removeNoscriptTags($xhr.responseText);
$title = doc.title;
$body = doc.body;
$fetchedBodies[$url] = { body: $body, title: $title };
var alteredOnReceive = triggerPageEvent("receive", $url, $body, $title);
if (alteredOnReceive) {
if ("body" in alteredOnReceive) {
$body = alteredOnReceive.body;
}
if ("title" in alteredOnReceive) {
$title = alteredOnReceive.title;
}
}
var urlWithoutHash = removeHash($url);
$history[urlWithoutHash] = {
body: $body,
title: $title,
scrollPosition:
urlWithoutHash in $history
? $history[urlWithoutHash].scrollPosition
: 0,
};
var trackedElements = doc.querySelectorAll("[data-instant-track]"),
element,
elementData;
if (trackedElements.length != $trackedElementsData.length) {
$areTrackedElementsDifferent = true;
} else {
for (var i = 0; i < trackedElements.length; i++) {
element = trackedElements[i];
elementData =
element.getAttribute("href") ||
element.getAttribute("src") ||
element.textContent;
if ($trackedElementsData.indexOf(elementData) == -1) {
$areTrackedElementsDifferent = true;
}
}
}
if ($isWaitingForCompletion) {
$isWaitingForCompletion = false;
display($url);
}
}
function popstateListener() {
var loc = removeHash(location.href);
if (loc == $currentLocationWithoutHash) {
return;
}
if ($isWaitingForCompletion) {
setPreloadingAsHalted();
$xhr.abort();
}
if (!(loc in $history)) {
triggerPageEvent("exit", location.href, "not in history");
if (loc == location.href) {
// no location.hash
location.href = location.href;
// Reloads the page while using cache for scripts, styles and images,
// unlike `location.reload()`
} else {
// When there's a hash, `location.href = location.href` won't reload
// the page (but will trigger a popstate event, thus causing an infinite
// loop), so we need to call `location.reload()`
location.reload();
}
return;
}
$history[$currentLocationWithoutHash].scrollPosition = pageYOffset;
clearCurrentPageTimeouts();
addOrRemoveWindowEventListeners("remove");
$currentLocationWithoutHash = loc;
changePage(
$history[loc].title,
$history[loc].body,
false,
$history[loc].scrollPosition
);
addOrRemoveWindowEventListeners("add");
}
////////// MAIN FUNCTIONS //////////
function preload(url) {
if ($preloadTimer) {
clearTimeout($preloadTimer);
$preloadTimer = false;
}
if (!url) {
url = $urlToPreload;
}
if ($isPreloading && (url == $url || $isWaitingForCompletion)) {
return;
}
$isPreloading = true;
$isWaitingForCompletion = false;
$url = url;
$body = false;
$isContentTypeNotHTML = false;
$gotANetworkError = false;
$areTrackedElementsDifferent = false;
triggerPageEvent("preload");
removeExpiredKeys();
if (!$fetchedBodies[$url]) {
$xhr.open("GET", $url);
$xhr.timeout = 90000; // Must be set after `open()` with IE
$xhr.send();
}
}
function removeExpiredKeys(option) {
if (Object.keys($fetchedBodies).length > 13 || option == "force") {
$fetchedBodies = {};
}
}
function display(url) {
if ($fetchedBodies[url]) {
var $body = $fetchedBodies[url]["body"];
var $title = $fetchedBodies[url]["title"];
}
$lastDisplayTimestamp = +new Date();
if ($preloadTimer || !$isPreloading) {
// $preloadTimer:
// Happens when there's a delay before preloading and that delay
// hasn't expired (preloading didn't kick in).
//
// !$isPreloading:
// A link has been clicked, and preloading hasn't been initiated.
// It happens with touch devices when a user taps *near* the link,
// causing `touchstart` not to be fired. Safari/Chrome will trigger
// `mouseover`, `mousedown`, `click` (and others), but when that happens
// we do nothing in `mouseover` as it may cause `click` not to fire (see
// comment in `mouseoverListener`).
//
// It also happens when a user uses his keyboard to navigate (with Tab
// and Return), and possibly in other non-mainstream ways to navigate
// a website.
if ($preloadTimer && $url && $url != url) {
// Happens when the user clicks on a link before preloading
// kicks in while another link is already preloading.
triggerPageEvent(
"exit",
url,
"click occured while preloading planned"
);
location.href = url;
return;
}
preload(url);
triggerPageEvent("wait");
$isWaitingForCompletion = true; // Must be set *after* calling `preload`
return;
}
if ($isWaitingForCompletion) {
// The user clicked on a link while a page to display was preloading.
// Either on the same link or on another link. If it's the same link
// something might have gone wrong (or he could have double clicked, we
// don't handle that case), so we send him to the page without pjax.
// If it's another link, it hasn't been preloaded, so we redirect the
// user to it.
triggerPageEvent(
"exit",
url,
"clicked on a link while waiting for another page to display"
);
location.href = url;
return;
}
if ($isContentTypeNotHTML) {
triggerPageEvent("exit", $url, "non-html content-type");
location.href = $url;
return;
}
if ($gotANetworkError) {
triggerPageEvent("exit", $url, "network error");
location.href = $url;
return;
}
if ($areTrackedElementsDifferent) {
triggerPageEvent("exit", $url, "different assets");
location.href = $url;
return;
}
if (!$body) {
triggerPageEvent("wait");
$isWaitingForCompletion = true;
return;
}
$history[$currentLocationWithoutHash].scrollPosition = pageYOffset;
setPreloadingAsHalted();
changePage($title, $body, $url);
}
////////// PUBLIC VARIABLE AND FUNCTIONS //////////
var supported = false;
if ("pushState" in history && location.protocol != "file:") {
supported = true;
var indexOfAndroid = $userAgent.indexOf("Android ");
if (indexOfAndroid > -1) {
// The stock browser in Android 4.0.3 through 4.3.1 supports pushState,
// though it doesn't update the address bar.
//
// More problematic is that it has a bug on `popstate` when coming back
// from a page not displayed through InstantClick: `location.href` is
// undefined and `location.reload()` doesn't work.
//
// Android < 4.4 is therefore blacklisted, unless it's a browser known
// not to have that latter bug.
var androidVersion = parseFloat(
$userAgent.substr(indexOfAndroid + "Android ".length)
);
if (androidVersion < 4.4) {
supported = false;
if (androidVersion >= 4) {
var whitelistedBrowsersUserAgentsOnAndroid4 = [
/ Chrome\//, // Chrome, Opera, Puffin, QQ, Yandex
/ UCBrowser\//,
/ Firefox\//,
/ Windows Phone /, // WP 8.1+ pretends to be Android
];
for (
var i = 0;
i < whitelistedBrowsersUserAgentsOnAndroid4.length;
i++
) {
if (whitelistedBrowsersUserAgentsOnAndroid4[i].test($userAgent)) {
supported = true;
break;
}
}
}
}
}
}
function init(preloadingMode) {
if (!supported) {
triggerPageEvent("change", true);
return;
}
if ($hasBeenInitialized) {
return;
}
$hasBeenInitialized = true;
if (preloadingMode == "mousedown") {
$preloadOnMousedown = true;
} else if (typeof preloadingMode == "number") {
$delayBeforePreload = preloadingMode;
}
$currentLocationWithoutHash = removeHash(location.href);
$timers[$currentLocationWithoutHash] = {};
$history[$currentLocationWithoutHash] = {
body: document.body,
title: document.title,
scrollPosition: pageYOffset,
};
if (document.readyState == "loading") {
document.addEventListener("DOMContentLoaded", addTrackedElements);
} else {
addTrackedElements();
}
$xhr = new XMLHttpRequest();
$xhr.addEventListener("readystatechange", readystatechangeListener);
document.addEventListener("touchstart", touchstartListener, true);
if ($preloadOnMousedown) {
document.addEventListener("mousedown", mousedownListener, true);
} else {
document.addEventListener("mouseover", mouseoverListener, true);
}
document.addEventListener("click", clickListenerPrelude, true);
addEventListener("popstate", popstateListener);
}
function on(eventType, callback) {
$eventsCallbacks[eventType].push(callback);
if (eventType == "change") {
callback(!$lastDisplayTimestamp);
}
}
function setTimeout() {
return addTimer(arguments, false);
}
function setInterval() {
return addTimer(arguments, true);
}
function clearTimeout(id) {
id = -id;
for (var loc in $timers) {
if (id in $timers[loc]) {
window.clearTimeout($timers[loc][id].realId);
delete $timers[loc][id];
}
}
}
function xhr(xhr) {
$currentPageXhrs.push(xhr);
}
function addPageEvent() {
if (!($currentLocationWithoutHash in $windowEventListeners)) {
$windowEventListeners[$currentLocationWithoutHash] = [];
}
$windowEventListeners[$currentLocationWithoutHash].push(arguments);
addEventListener.apply(window, arguments);
}
function removePageEvent() {
if (!($currentLocationWithoutHash in $windowEventListeners)) {
return;
}
firstLoop: for (
var i = 0;
i < $windowEventListeners[$currentLocationWithoutHash].length;
i++
) {
if (
arguments.length !=
$windowEventListeners[$currentLocationWithoutHash][i].length
) {
continue;
}
for (
var j = 0;
j < $windowEventListeners[$currentLocationWithoutHash][i].length;
j++
) {
if (
arguments[j] !=
$windowEventListeners[$currentLocationWithoutHash][i][j]
) {
continue firstLoop;
}
}
$windowEventListeners[$currentLocationWithoutHash].splice(i, 1);
}
}
function addEvent(selector, type, listener) {
if (!(type in $delegatedEvents)) {
$delegatedEvents[type] = {};
document.addEventListener(
type,
function(event) {
var element = event.target;
event.originalStopPropagation = event.stopPropagation;
event.stopPropagation = function() {
this.isPropagationStopped = true;
this.originalStopPropagation();
};
while (element && element.nodeType == 1) {
for (var selector in $delegatedEvents[type]) {
if (element.matches(selector)) {
for (
var i = 0;
i < $delegatedEvents[type][selector].length;
i++
) {
$delegatedEvents[type][selector][i].call(element, event);
}
if (event.isPropagationStopped) {
return;
}
break;
}
}
element = element.parentNode;
}
},
false
); // Third parameter isn't optional in Firefox < 6
if (type == "click" && /iP(?:hone|ad|od)/.test($userAgent)) {
// Force Mobile Safari to trigger the click event on document by adding a pointer cursor to body
var styleElement = document.createElement("style");
styleElement.setAttribute("instantclick-mobile-safari-cursor", ""); // So that this style element doesn't surprise developers in the browser DOM inspector.
styleElement.textContent = "body { cursor: pointer !important; }";
document.head.appendChild(styleElement);
}
}
if (!(selector in $delegatedEvents[type])) {
$delegatedEvents[type][selector] = [];
}
// Run removeEvent beforehand so that it can't be added twice
removeEvent(selector, type, listener);
$delegatedEvents[type][selector].push(listener);
}
function removeEvent(selector, type, listener) {
var index = $delegatedEvents[type][selector].indexOf(listener);
if (index > -1) {
$delegatedEvents[type][selector].splice(index, 1);
}
}
function go(url) {
var link = document.createElement("a");
link.href = url;
document.body.appendChild(link);
link.click();
}
////////////////////
return {
supported: supported,
init: init,
on: on,
setTimeout: setTimeout,
setInterval: setInterval,
clearTimeout: clearTimeout,
xhr: xhr,
addPageEvent: addPageEvent,
removePageEvent: removePageEvent,
addEvent: addEvent,
removeEvent: removeEvent,
go: go,
};
})(document, location, navigator.userAgent));
@ianbayne
Copy link

ianbayne commented Jun 5, 2020

It looks like your URL above for dev.to isn't working. Incidentally, how is this gist used? Are links opt-in; i.e., do you need to, for example, define some kind of data attribute on links you want to be pre-fetchable?

@phacks
Copy link
Author

phacks commented Jun 5, 2020

@ianbayne Updated the link—sorry about that. This version of prefetching is opt-out, you’d need to set data-no-instant on links you do not want to prefetch.

@ianbayne
Copy link

ianbayne commented Jun 5, 2020

Awesome, @phacks, thanks for the very quick response! Understood regarding the need to opt-out. I see that you also found hopsoft's gist for prefetching with Turbolinks. I've used that gist a wee bit in some personal projects, and am excited to give yours a spin as well. Thanks again!

@phacks
Copy link
Author

phacks commented Jun 5, 2020

You’re welcome! As I say in the linked article, hopsoft’s gist works really well but triggers two requests because of how Turbolinks work. I wanted to find a way to only trigger one, as it felt a bit wasteful otherwise.

@silentjay
Copy link

silentjay commented Jul 6, 2020

@phacks i'm trying to figure out the advantage of your method over hopsofts. From what I've tested hopsofts gist will fetch the page onhover if it's not already in the cache, then onclick retrieve it from the disk cache. If it's already in the disk cache then no request is made onhover and on onclick it's just retrieved with a single request.

Could you explain how yours improves on this?

@phacks
Copy link
Author

phacks commented Jul 7, 2020

@silentjay I believe hopsoft’s solution will always make 2 requests (one when prefetching, one when clicking). See this comment:

The prefetcher only makes 1 request. It ensures that clicking a prefetched url is handled as a Turbolinks restoration visit. A 2nd request is still made to pick up any page changes since the prefetch cached the Turbolinks snapshot... as per normal Turbolinks behavior.

The onclick request does not retrieve from the disk cache but is a normal request, to make sure that the user sees an up to date version of the page. In the “prefetch on hover” scenario however, I think that there is very little chance that the prefetched version and the version the user gets on click would differ, as they would be triggered ~300ms apart. Hence my searching for a 1 request solution.

One could imagine a scenario where the user hovers a link, then spends a minute on the page, then clicks said link. In that case, the code in this gist would display a stale version and hopsoft’s would display an up-to-date one. We decided that this was an uncommon enough edge-case and would rather optimize for speedier navigation, bandwidth and server load.

@vitobotta
Copy link

Hi! Can you please clarify how to use this? I havea Rails 6 app, so it uses Turbolinks, Stimulus with Webpacker. How do I use your code? Can it work together with Turbolinks? Thanks!

@vitobotta
Copy link

I kinda got it working with turbolinks (I just require the file from inside the turbolinks:load event and call init()) but it still does two requests, one on hover and the other on click. How to prevent that? Thanks

@vitobotta
Copy link

NVM it works! thanks :)

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