diff --git a/.size-limit.json b/.size-limit.json index 7a684a02..ba778919 100644 --- a/.size-limit.json +++ b/.size-limit.json @@ -1,22 +1,22 @@ [ { "path": "dist/quicklink.js", - "limit": "2 kB", + "limit": "2.36 kB", "gzip": true }, { "path": "dist/quicklink.mjs", - "limit": "2 kB", + "limit": "2.36 kB", "gzip": true }, { "path": "dist/quicklink.modern.mjs", - "limit": "1.6 kB", + "limit": "1.91 kB", "gzip": true }, { "path": "dist/quicklink.umd.js", - "limit": "2 kB", + "limit": "2.43 kB", "gzip": true } -] +] \ No newline at end of file diff --git a/package.json b/package.json index 249df064..de885970 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "quicklink", - "version": "2.3.0", + "version": "2.4.0", "description": "Faster subsequent page-loads by prefetching in-viewport links during idle time", "repository": { "type": "git", @@ -71,4 +71,4 @@ "size-limit": "^11.1.4", "uvu": "^0.5.6" } -} +} \ No newline at end of file diff --git a/src/chunks.mjs b/src/chunks.mjs index aa36c2be..de78fe30 100644 --- a/src/chunks.mjs +++ b/src/chunks.mjs @@ -15,7 +15,7 @@ **/ import throttle from 'throttles'; -import {priority, supported} from './prefetch.mjs'; +import {viaFetch, supported} from './prefetch.mjs'; import requestIdleCallback from './request-idle-callback.mjs'; // Cache of URLs we've prefetched @@ -145,7 +145,7 @@ export function prefetch(url, isPriority) { // ~> so that we don't repeat broken links toPrefetch.add(str); - return (isPriority ? priority : supported)( + return (isPriority ? viaFetch : supported)( new URL(str, location.href).toString(), ); }), diff --git a/src/index.mjs b/src/index.mjs index 0872cc7c..f6b9f967 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -15,7 +15,7 @@ **/ import throttle from 'throttles'; -import {priority, supported} from './prefetch.mjs'; +import {prefetchOnHover, supported, viaFetch} from './prefetch.mjs'; import requestIdleCallback from './request-idle-callback.mjs'; import {addSpeculationRules, hasSpecRulesSupport} from './prerender.mjs'; @@ -72,6 +72,9 @@ function checkConnection(conn) { * @param {Object} options - Configuration options for quicklink * @param {Object|Array} [options.el] - DOM element(s) to prefetch in-viewport links of * @param {Boolean} [options.priority] - Attempt higher priority fetch (low or high) + * @param {Boolean} [options.checkAccessControlAllowOrigin] - Check Access-Control-Allow-Origin response header + * @param {Boolean} [options.checkAccessControlAllowCredentials] - Check the Access-Control-Allow-Credentials response header + * @param {Boolean} [options.onlyOnMouseover] - Enable the prefetch only on mouseover event * @param {Array} [options.origins] - Allowed origins to prefetch (empty allows all) * @param {Array|RegExp|Function} [options.ignores] - Custom filter(s) that run after origin checks * @param {Number} [options.timeout] - Timeout after which prefetching will occur @@ -84,6 +87,7 @@ function checkConnection(conn) { * @param {Function} [options.hrefFn] - Function to use to build the URL to prefetch. * If it's not a valid function, then it will use the entry href. * @param {Boolean} [options.prerender] - Option to switch from prefetching and use prerendering only + * @param {String} [options.eagerness] - Prerender eagerness mode - default immediate * @param {Boolean} [options.prerenderAndPrefetch] - Option to use both prerendering and prefetching * @return {Function} */ @@ -133,7 +137,7 @@ export function listen(options = {}) { // either it's the prerender + prefetch mode or it's prerender *only* mode // Prerendering limit is following options.limit. UA may impose arbitraty numeric limit if ((shouldPrerenderAndPrefetch || shouldOnlyPrerender) && toPrerender.size < limit) { - prerender(hrefFn ? hrefFn(entry) : entry.href).catch(error => { + prerender(hrefFn ? hrefFn(entry) : entry.href, options.eagerness).catch(error => { if (options.onError) { options.onError(error); } else { @@ -147,7 +151,8 @@ export function listen(options = {}) { // Do not prefetch if will match/exceed limit and user has not switched to shouldOnlyPrerender mode if (toPrefetch.size < limit && !shouldOnlyPrerender) { toAdd(() => { - prefetch(hrefFn ? hrefFn(entry) : entry.href, options.priority) + prefetch(hrefFn ? hrefFn(entry) : entry.href, options.priority, + options.checkAccessControlAllowOrigin, options.checkAccessControlAllowCredentials, options.onlyOnMouseover) .then(isDone) .catch(error => { isDone(); @@ -156,7 +161,7 @@ export function listen(options = {}) { }); } }, delay); - // On exit + // On exit } else { entry = entry.target; const index = hrefsInViewport.indexOf(entry.href); @@ -172,9 +177,9 @@ export function listen(options = {}) { timeoutFn(() => { // Find all links & Connect them to IO if allowed const elementsToListen = options.el && - options.el.length && - options.el.length > 0 && - options.el[0].nodeName === 'A' ? + options.el.length && + options.el.length > 0 && + options.el[0].nodeName === 'A' ? options.el : (options.el || document).querySelectorAll('a'); @@ -201,10 +206,14 @@ export function listen(options = {}) { /** * Prefetch a given URL with an optional preferred fetch priority * @param {String} url - the URL to fetch -* @param {Boolean} [isPriority] - if is "high" priority +* @param {Boolean} isPriority - if is "high" priority +* @param {Boolean} checkAccessControlAllowOrigin - true to set crossorigin="anonymous" for DOM prefetch +* and mode:'cors' for API fetch +* @param {Boolean} checkAccessControlAllowCredentials - true to set credentials:'include' for API fetch +* @param {Boolean} onlyOnMouseover - true to enable prefetch only on mouseover event * @return {Object} a Promise */ -export function prefetch(url, isPriority) { +export function prefetch(url, isPriority, checkAccessControlAllowOrigin, checkAccessControlAllowCredentials, onlyOnMouseover) { const chkConn = checkConnection(navigator.connection); if (chkConn instanceof Error) { return Promise.reject(new Error(`Cannot prefetch, ${chkConn.message}`)); @@ -223,9 +232,8 @@ export function prefetch(url, isPriority) { // ~> so that we don't repeat broken links toPrefetch.add(str); - return (isPriority ? priority : supported)( - new URL(str, location.href).toString(), - ); + return prefetchOnHover((isPriority ? viaFetch : supported), new URL(str, location.href).toString(), onlyOnMouseover, + checkAccessControlAllowOrigin, checkAccessControlAllowCredentials, isPriority); }), ); } @@ -233,9 +241,10 @@ export function prefetch(url, isPriority) { /** * Prerender a given URL * @param {String} urls - the URL to fetch +* @param {String} eagerness - prerender eagerness mode - default immediate * @return {Object} a Promise */ -export function prerender(urls) { +export function prerender(urls, eagerness = 'immediate') { const chkConn = checkConnection(navigator.connection); if (chkConn instanceof Error) { return Promise.reject(new Error(`Cannot prerender, ${chkConn.message}`)); @@ -245,7 +254,7 @@ export function prerender(urls) { // 1) whether UA supports spec rules.. If not, fallback to prefetch // Note: Prerendering supports same-site cross origin with opt-in header if (!hasSpecRulesSupport()) { - prefetch(urls); + prefetch(urls, true, false, false, eagerness === 'moderate' || eagerness === 'conservative'); return Promise.reject(new Error('This browser does not support the speculation rules API. Falling back to prefetch.')); } @@ -258,6 +267,6 @@ export function prerender(urls) { console.warn('[Warning] You are using both prefetching and prerendering on the same document'); } - const addSpecRules = addSpeculationRules(toPrerender); + const addSpecRules = addSpeculationRules(toPrerender, eagerness); return addSpecRules === true ? Promise.resolve() : Promise.reject(addSpecRules); } diff --git a/src/prefetch.mjs b/src/prefetch.mjs index 8a4aeb19..d9259820 100644 --- a/src/prefetch.mjs +++ b/src/prefetch.mjs @@ -20,7 +20,7 @@ /** * Checks if a feature on `link` is natively supported. * Examples of features include `prefetch` and `preload`. - * @param {Object} link Link object. + * @param {Object} link - Link object. * @return {Boolean} whether the feature is supported */ function hasPrefetch(link) { @@ -31,13 +31,17 @@ function hasPrefetch(link) { /** * Fetches a given URL using `` * @param {string} url - the URL to fetch + * @param {Boolean} hasCrossorigin - true to set crossorigin="anonymous" * @return {Object} a Promise */ -function viaDOM(url) { +function viaDOM(url, hasCrossorigin) { return new Promise((resolve, reject, link) => { link = document.createElement('link'); link.rel = 'prefetch'; link.href = url; + if (hasCrossorigin) { + link.setAttribute('crossorigin', 'anonymous'); + } link.onload = resolve; link.onerror = reject; @@ -49,13 +53,16 @@ function viaDOM(url) { /** * Fetches a given URL using XMLHttpRequest * @param {string} url - the URL to fetch + * @param {Boolean} hasCredentials - true to set withCredentials:true * @return {Object} a Promise */ -function viaXHR(url) { +function viaXHR(url, hasCredentials) { return new Promise((resolve, reject, request) => { request = new XMLHttpRequest(); - request.open('GET', url, request.withCredentials = true); + request.open('GET', url, request.withCredentials = hasCredentials); + + request.setRequestHeader('Accept', '*/*'); request.onload = () => { if (request.status === 200) { @@ -74,9 +81,12 @@ function viaXHR(url) { * Fetches a given URL using the Fetch API. Falls back * to XMLHttpRequest if the API is not supported. * @param {string} url - the URL to fetch + * @param {Boolean} hasModeCors - true to set mode:'cors' + * @param {Boolean} hasCredentials - true to set credentials:'include' + * @param {Boolean} isPriority - true to set priority:'high' * @return {Object} a Promise */ -export function priority(url) { +export function viaFetch(url, hasModeCors, hasCredentials, isPriority) { // TODO: Investigate using preload for high-priority // fetches. May have to sniff file-extension to provide // valid 'as' values. In the future, we may be able to @@ -84,7 +94,48 @@ export function priority(url) { // // As of 2018, fetch() is high-priority in Chrome // and medium-priority in Safari. - return window.fetch ? fetch(url, {credentials: 'include'}) : viaXHR(url); + const options = {headers: {accept: '*/*'}}; + if (!hasModeCors) options.mode = 'no-cors'; + if (hasCredentials) options.credentials = 'include'; + isPriority ? options.priority = 'high' : options.priority = 'low'; + return window.fetch ? fetch(url, options) : viaXHR(url, hasCredentials); +} + +/** + * Calls the prefetch function immediately + * or only on the mouseover event. + * @param {Function} callback - original prefetch function + * @param {String} url - url to prefetch + * @param {Boolean} onlyOnMouseover - true to add the mouseover listener + * @return {Object} a Promise + */ +export function prefetchOnHover(callback, url, onlyOnMouseover, ...args) { + if (!onlyOnMouseover) return callback(url, ...args); + + const elements = document.querySelectorAll(`a[href="${decodeURIComponent(url)}"]`); + const timerMap = new Map(); + + for (const el of elements) { + const mouseenterListener = e => { + const timer = setTimeout(() => { + el.removeEventListener('mouseenter', mouseenterListener); + el.removeEventListener('mouseleave', mouseleaveListener); + return callback(url, ...args); + }, 200); + timerMap.set(el, timer); + }; + + const mouseleaveListener = e => { + const timer = timerMap.get(el); + if (timer) { + clearTimeout(timer); + timerMap.delete(el); + } + }; + + el.addEventListener('mouseenter', mouseenterListener); + el.addEventListener('mouseleave', mouseleaveListener); + } } -export const supported = hasPrefetch() ? viaDOM : viaXHR; +export const supported = hasPrefetch() ? viaDOM : viaFetch; diff --git a/src/prerender.mjs b/src/prerender.mjs index e41ba8d1..f91e1e7e 100644 --- a/src/prerender.mjs +++ b/src/prerender.mjs @@ -20,12 +20,15 @@ /** * Add a given set of urls to the speculation rules * @param {Set} urlsToPrerender - the URLs to add to speculation rules + * @param {String} eagerness - prerender eagerness mode * @return {Boolean|Object} boolean or Error Object */ -export function addSpeculationRules(urlsToPrerender) { +export function addSpeculationRules(urlsToPrerender, eagerness) { const specScript = document.createElement('script'); specScript.type = 'speculationrules'; - specScript.text = `{"prerender":[{"source": "list","urls": ["${Array.from(urlsToPrerender).join('","')}"]}]}`; + specScript.text = `{"prerender":[{"source": "list", + "urls": ["${Array.from(urlsToPrerender).join('","')}"], + "eagerness": "${eagerness}"}]}`; try { document.head.appendChild(specScript); } catch (error) {