From 66ccc7a4bd64ad1c3ff0b937875567b144d2a145 Mon Sep 17 00:00:00 2001 From: Luke Edwards Date: Thu, 22 Aug 2019 00:25:15 -0700 Subject: [PATCH 01/16] [major] rewrite w/ new exports & options; - add `throttle` option - add `limit` option - export `prefetch` directly - export old `default` as `listen` method - remove `urls` option (temp?) - return early if no IO support - return `reset` function if okay --- package.json | 7 ++- src/index.mjs | 127 ++++++++++++++++++++++++++++------------------- src/prefetch.mjs | 55 +++++--------------- 3 files changed, 94 insertions(+), 95 deletions(-) diff --git a/package.json b/package.json index 849f0174..d1e8d52c 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "lint-fix": "eslint src/*.mjs test/*.js --fix demos/*.js", "start": "http-server .", "test": "yarn run build && mocha test/bootstrap.js --recursive test", - "build": "microbundle src/index.mjs --no-sourcemap", + "build": "microbundle src/index.mjs --no-sourcemap --external none", "prepare": "yarn run -s build", "bundlesize": "bundlesize", "changelog": "yarn conventional-changelog -i CHANGELOG.md -s -r 0", @@ -33,6 +33,9 @@ "background", "speed" ], + "dependencies": { + "throttles": "^1.0.0" + }, "devDependencies": { "babel-preset-env": "^1.7.0", "bundlesize": "^0.17.0", @@ -53,4 +56,4 @@ "maxSize": "2 kB" } ] -} \ No newline at end of file +} diff --git a/src/index.mjs b/src/index.mjs index 82939b28..f573deac 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -13,34 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. **/ - -import prefetch from './prefetch.mjs'; +import throttle from 'throttles'; +import { priority, supported } from './prefetch.mjs'; import requestIdleCallback from './request-idle-callback.mjs'; +// Cache of URLs we've prefetched +// Its `size` is compared against `opts.limit` value. const toPrefetch = new Set(); -const observer = window.IntersectionObserver && new IntersectionObserver(entries => { - entries.forEach(entry => { - if (entry.isIntersecting) { - const link = entry.target; - if (toPrefetch.has(link.href)) { - observer.unobserve(link); - prefetcher(link.href); - } - } - }); -}); - -/** - * Prefetch a supplied URL. This will also remove - * the URL from the toPrefetch Set. - * @param {String} url - URL to prefetch - */ -function prefetcher(url) { - toPrefetch.delete(url); - prefetch(new URL(url, location.href).toString(), observer.priority); -} - /** * Determine if the anchor tag should be prefetched. * A filter can be a RegExp, Function, or Array of both. @@ -63,40 +43,87 @@ function isIgnored(node, filter) { * links for `document`. Can also work off a supplied * DOM element or static array of URLs. * @param {Object} options - Configuration options for quicklink - * @param {Array} options.urls - Array of URLs to prefetch (override) - * @param {Object} options.el - DOM element to prefetch in-viewport links of - * @param {Boolean} options.priority - Attempt higher priority fetch (low or high) - * @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 - * @param {Function} options.timeoutFn - Custom timeout function + * @param {Object} [options.el] - DOM element to prefetch in-viewport links of + * @param {Boolean} [options.priority] - Attempt higher priority fetch (low or high) + * @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 + * @param {Number} [options.throttle] - The concurrency limit for prefetching + * @param {Number} [options.limit] - The total number of prefetches to allow + * @param {Function} [options.timeoutFn] - Custom timeout function */ -export default function (options) { +export function listen(options) { if (!options) options = {}; + if (!window.IntersectionObserver) return; + + const [toAdd, isDone] = throttle(options.throttle || 1/0); + const limit = options.limit || 1/0; - observer && (observer.priority = options.priority || false); + // TODO: I think this isn't needed? + const isPriority = !!options.priority; const allowed = options.origins || [location.hostname]; const ignores = options.ignores || []; - const timeout = options.timeout || 2e3; const timeoutFn = options.timeoutFn || requestIdleCallback; + const observer = new IntersectionObserver(entries => { + entries.forEach(entry => { + if (entry.isIntersecting) { + observer.unobserve(entry = entry.target); + // Do not prefetch if will match/exceed limit + (++toPrefetch.size >= limit) || toAdd(() => { + // TODO: Don't need isPriority? + prefetch(entry.href, isPriority).then(isDone); + }); + } + }); + }); + timeoutFn(() => { - // If URLs are given, prefetch them. - if (options.urls) { - options.urls.forEach(prefetcher); - } else if (observer) { - // If not, find all links and use IntersectionObserver. - Array.from((options.el || document).querySelectorAll('a'), link => { - observer.observe(link); - // If the anchor matches a permitted origin - // ~> A `[]` or `true` means everything is allowed - if (!allowed.length || allowed.includes(link.hostname)) { - // If there are any filters, the link must not match any of them - isIgnored(link, ignores) || toPrefetch.add(link.href); - } - }); - } - }, {timeout}); + // Find all links & Connect them to IO if allowed + (options.el || document).querySelectorAll('a').forEach(link => { + // If the anchor matches a permitted origin + // ~> A `[]` or `true` means everything is allowed + if (!allowed.length || allowed.includes(link.hostname)) { + // If there are any filters, the link must not match any of them + isIgnored(link, ignores) || observer.observe(link); + } + }); + }, { + timeout: options.timeout || 2e3 + }); + + return function () { + // wipe url list + toPrefetch.clear(); + // detach IO entries + observer.disconnect(); + }; +} + + +/** +* 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 {Object} [conn] - navigator.connection (internal) +* @return {Object} a Promise +*/ +export function prefetch(url, isPriority, conn) { + if (toPrefetch.has(url)) return; + + if (conn = navigator.connection) { + // Don't prefetch if using 2G or if Save-Data is enabled. + if (conn.saveData || /2g/.test(conn.effectiveType)) return; + } + + // Add it now, regardless of its success + // ~> so that we don't repeat broken links + toPrefetch.add(url); + + // Wanna do something on catch()? + return (isPriority ? priority : supported)( + new URL(url, location.href).toString() + ); } diff --git a/src/prefetch.mjs b/src/prefetch.mjs index 18b43c17..f5184a60 100644 --- a/src/prefetch.mjs +++ b/src/prefetch.mjs @@ -16,17 +16,15 @@ * See the License for the specific language governing permissions and * limitations under the License. **/ -const preFetched = {}; /** * Checks if a feature on `link` is natively supported. * Examples of features include `prefetch` and `preload`. - * @param {string} feature - name of the feature to test * @return {Boolean} whether the feature is supported */ -function support(feature) { +function hasPrefetch() { const link = document.createElement('link'); - return link.relList && link.relList.supports && link.relList.supports(feature); + return link.relList && link.relList.supports && link.relList.supports('prefetch'); } /** @@ -34,14 +32,14 @@ function support(feature) { * @param {string} url - the URL to fetch * @return {Object} a Promise */ -function linkPrefetchStrategy(url) { - return new Promise((resolve, reject) => { +function viaDOM(url) { + return new Promise((res, rej) => { const link = document.createElement(`link`); link.rel = `prefetch`; link.href = url; - link.onload = resolve; - link.onerror = reject; + link.onload = res; + link.onerror = rej; document.head.appendChild(link); }); @@ -52,14 +50,14 @@ function linkPrefetchStrategy(url) { * @param {string} url - the URL to fetch * @return {Object} a Promise */ -function xhrPrefetchStrategy(url) { - return new Promise((resolve, reject) => { +function viaXHR(url) { + return new Promise((res, rej) => { const req = new XMLHttpRequest(); req.open(`GET`, url, req.withCredentials=true); req.onload = () => { - (req.status === 200) ? resolve() : reject(); + (req.status === 200) ? res() : rej(); }; req.send(); @@ -72,7 +70,7 @@ function xhrPrefetchStrategy(url) { * @param {string} url - the URL to fetch * @return {Object} a Promise */ -function highPriFetchStrategy(url) { +export function priority(url) { // 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 @@ -80,36 +78,7 @@ function highPriFetchStrategy(url) { // // As of 2018, fetch() is high-priority in Chrome // and medium-priority in Safari. - return self.fetch == null - ? xhrPrefetchStrategy(url) - : fetch(url, {credentials: `include`}); + return window.fetch ? fetch(url, {credentials: `include`}) : viaXHR(url); } -const supportedPrefetchStrategy = support('prefetch') - ? linkPrefetchStrategy - : xhrPrefetchStrategy; - -/** - * 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 {Object} conn - navigator.connection (internal) - * @return {Object} a Promise - */ -function prefetcher(url, isPriority, conn) { - if (preFetched[url]) { - return; - } - - if (conn = navigator.connection) { - // Don't prefetch if the user is on 2G or if Save-Data is enabled. - if ((conn.effectiveType || '').includes('2g') || conn.saveData) return; - } - - // Wanna do something on catch()? - return (isPriority ? highPriFetchStrategy : supportedPrefetchStrategy)(url).then(() => { - preFetched[url] = true; - }); -}; - -export default prefetcher; +export const supported = hasPrefetch() ? viaDOM : viaXHR; From 5647884b5e8a61dacefffc9912d22f3e849b62fe Mon Sep 17 00:00:00 2001 From: Luke Edwards Date: Thu, 22 Aug 2019 00:25:37 -0700 Subject: [PATCH 02/16] chore: remove `static-url-list` test --- test/quicklink.spec.js | 12 ------------ test/test-static-url-list.html | 29 ----------------------------- 2 files changed, 41 deletions(-) delete mode 100644 test/test-static-url-list.html diff --git a/test/quicklink.spec.js b/test/quicklink.spec.js index 3243034b..a275bef2 100644 --- a/test/quicklink.spec.js +++ b/test/quicklink.spec.js @@ -57,18 +57,6 @@ describe('quicklink tests', function () { expect(responseURLs).to.include(`${server}/4.html`); }); - it('should prefetch a static list of URLs correctly', async function () { - const responseURLs = []; - page.on('response', resp => { - responseURLs.push(resp.url()); - }); - await page.goto(`${server}/test-static-url-list.html`); - await page.waitFor(1000); - expect(responseURLs).to.be.an('array'); - expect(responseURLs).to.include(`${server}/2.html`); - expect(responseURLs).to.include(`${server}/4.html`); - }); - it('should prefetch in-viewport links from a custom DOM source', async function () { const responseURLs = []; page.on('response', resp => { diff --git a/test/test-static-url-list.html b/test/test-static-url-list.html deleted file mode 100644 index f979c230..00000000 --- a/test/test-static-url-list.html +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - Prefetch: Static URL list - - - - - - - Link 1 - Link 2 - Link 3 -
- CSS -
- Link 4 - - - - - \ No newline at end of file From b7eb56c3d560993383f80d4aee3533317c19f226 Mon Sep 17 00:00:00 2001 From: Luke Edwards Date: Thu, 22 Aug 2019 00:26:01 -0700 Subject: [PATCH 03/16] chore: update usage --- demos/basic.html | 4 ++-- demos/network-idle.html | 4 ++-- test/index.html | 11 ++++------- test/test-allow-origin-all.html | 4 ++-- test/test-allow-origin.html | 4 ++-- test/test-basic-usage.html | 4 ++-- test/test-custom-dom-source.html | 7 +++---- test/test-es-modules.html | 6 +++--- test/test-ignore-basic.html | 4 ++-- test/test-ignore-multiple.html | 4 ++-- test/test-same-origin.html | 4 ++-- 11 files changed, 26 insertions(+), 30 deletions(-) diff --git a/demos/basic.html b/demos/basic.html index 0e9deab5..b04c66f5 100644 --- a/demos/basic.html +++ b/demos/basic.html @@ -34,8 +34,8 @@

Basic demo

- \ No newline at end of file + diff --git a/demos/network-idle.html b/demos/network-idle.html index aee38c7d..e88d1169 100644 --- a/demos/network-idle.html +++ b/demos/network-idle.html @@ -34,8 +34,8 @@ - \ No newline at end of file + diff --git a/test/index.html b/test/index.html index c055dc0a..5fc86bfe 100644 --- a/test/index.html +++ b/test/index.html @@ -20,20 +20,17 @@ Link 4 - \ No newline at end of file + diff --git a/test/test-allow-origin-all.html b/test/test-allow-origin-all.html index dfc87971..b1d6faee 100644 --- a/test/test-allow-origin-all.html +++ b/test/test-allow-origin-all.html @@ -21,10 +21,10 @@ Link 4 - \ No newline at end of file + diff --git a/test/test-allow-origin.html b/test/test-allow-origin.html index c1057c78..c341d82e 100644 --- a/test/test-allow-origin.html +++ b/test/test-allow-origin.html @@ -20,10 +20,10 @@ Link 4 - \ No newline at end of file + diff --git a/test/test-basic-usage.html b/test/test-basic-usage.html index 37461f55..fff87c0a 100644 --- a/test/test-basic-usage.html +++ b/test/test-basic-usage.html @@ -20,8 +20,8 @@ Link 4 - \ No newline at end of file + diff --git a/test/test-custom-dom-source.html b/test/test-custom-dom-source.html index e3a5c198..7ce08403 100644 --- a/test/test-custom-dom-source.html +++ b/test/test-custom-dom-source.html @@ -20,11 +20,10 @@ Link 4 - \ No newline at end of file + diff --git a/test/test-es-modules.html b/test/test-es-modules.html index b39b531c..70498565 100644 --- a/test/test-es-modules.html +++ b/test/test-es-modules.html @@ -19,9 +19,9 @@ Link 4 - \ No newline at end of file + diff --git a/test/test-ignore-basic.html b/test/test-ignore-basic.html index 67612a16..da080708 100644 --- a/test/test-ignore-basic.html +++ b/test/test-ignore-basic.html @@ -21,10 +21,10 @@ Link 4 - \ No newline at end of file + diff --git a/test/test-ignore-multiple.html b/test/test-ignore-multiple.html index 2978fd11..574ab221 100644 --- a/test/test-ignore-multiple.html +++ b/test/test-ignore-multiple.html @@ -21,7 +21,7 @@ Link 4 - \ No newline at end of file + diff --git a/test/test-same-origin.html b/test/test-same-origin.html index 8d81e66f..7a784e2a 100644 --- a/test/test-same-origin.html +++ b/test/test-same-origin.html @@ -20,8 +20,8 @@ Link 4 - \ No newline at end of file + From 65cf4d3f7a99fc752957f26d56b9655b52e81221 Mon Sep 17 00:00:00 2001 From: Luke Edwards Date: Thu, 22 Aug 2019 00:36:32 -0700 Subject: [PATCH 04/16] golf: shave 4B ~ --- src/prefetch.mjs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/prefetch.mjs b/src/prefetch.mjs index f5184a60..05e7f16b 100644 --- a/src/prefetch.mjs +++ b/src/prefetch.mjs @@ -22,8 +22,8 @@ * Examples of features include `prefetch` and `preload`. * @return {Boolean} whether the feature is supported */ -function hasPrefetch() { - const link = document.createElement('link'); +function hasPrefetch(link) { + link = document.createElement('link'); return link.relList && link.relList.supports && link.relList.supports('prefetch'); } @@ -33,8 +33,8 @@ function hasPrefetch() { * @return {Object} a Promise */ function viaDOM(url) { - return new Promise((res, rej) => { - const link = document.createElement(`link`); + return new Promise((res, rej, link) => { + link = document.createElement(`link`); link.rel = `prefetch`; link.href = url; @@ -51,8 +51,8 @@ function viaDOM(url) { * @return {Object} a Promise */ function viaXHR(url) { - return new Promise((res, rej) => { - const req = new XMLHttpRequest(); + return new Promise((res, rej, req) => { + req = new XMLHttpRequest(); req.open(`GET`, url, req.withCredentials=true); From f53b143af74081b035a1a4de3c35d7ed5aa11874 Mon Sep 17 00:00:00 2001 From: Luke Edwards Date: Thu, 22 Aug 2019 00:37:01 -0700 Subject: [PATCH 05/16] fix: xbrowser throw on `set.size` getter --- src/index.mjs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/index.mjs b/src/index.mjs index f573deac..e4e111e7 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -72,10 +72,12 @@ export function listen(options) { if (entry.isIntersecting) { observer.unobserve(entry = entry.target); // Do not prefetch if will match/exceed limit - (++toPrefetch.size >= limit) || toAdd(() => { - // TODO: Don't need isPriority? - prefetch(entry.href, isPriority).then(isDone); - }); + if (toPrefetch.size < limit) { + toAdd(() => { + // TODO: Don't need isPriority? + prefetch(entry.href, isPriority).then(isDone); + }); + } } }); }); From 435393c78d6ee781d4808d8a656f827916a74fb8 Mon Sep 17 00:00:00 2001 From: Luke Edwards Date: Thu, 22 Aug 2019 01:04:49 -0700 Subject: [PATCH 06/16] chore: shuffle "main" location --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d1e8d52c..faab5ffc 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,6 @@ "name": "quicklink", "version": "1.0.1", "description": "Faster subsequent page-loads by prefetching in-viewport links during idle time", - "main": "dist/quicklink.js", "repository": "https://github.com/GoogleChromeLabs/quicklink.git", "homepage": "https://github.com/GoogleChromeLabs/quicklink", "bugs": { @@ -10,6 +9,7 @@ }, "author": "addyosmani ", "license": "Apache-2.0", + "main": "dist/quicklink.js", "module": "dist/quicklink.mjs", "jsnext:main": "dist/quicklink.mjs", "umd:main": "dist/quicklink.umd.js", From 46e5faa1801f571657cc58c44704bb674ebe654e Mon Sep 17 00:00:00 2001 From: Luke Edwards Date: Fri, 23 Aug 2019 11:13:25 -0700 Subject: [PATCH 07/16] feat: add `onError` option for prefetch catch --- src/index.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/index.mjs b/src/index.mjs index e4e111e7..7f8572b5 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -51,6 +51,7 @@ function isIgnored(node, filter) { * @param {Number} [options.throttle] - The concurrency limit for prefetching * @param {Number} [options.limit] - The total number of prefetches to allow * @param {Function} [options.timeoutFn] - Custom timeout function + * @param {Function} [options.onError] - Error handler for failed `prefetch` requests */ export function listen(options) { if (!options) options = {}; @@ -75,7 +76,9 @@ export function listen(options) { if (toPrefetch.size < limit) { toAdd(() => { // TODO: Don't need isPriority? - prefetch(entry.href, isPriority).then(isDone); + prefetch(entry.href, isPriority).then(isDone).catch(err => { + isDone(); if (options.onError) options.onError(err); + }); }); } } From 5b98337429450821f88b5c7849eda60304d167ed Mon Sep 17 00:00:00 2001 From: Luke Edwards Date: Fri, 23 Aug 2019 11:17:26 -0700 Subject: [PATCH 08/16] fix: expand timeout --- src/index.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.mjs b/src/index.mjs index 7f8572b5..ce8aa0ca 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -96,7 +96,7 @@ export function listen(options) { } }); }, { - timeout: options.timeout || 2e3 + timeout: options.timeout || 2000 }); return function () { From ee659534fdde9a01f4d80fc99d6e9e372f44153f Mon Sep 17 00:00:00 2001 From: Luke Edwards Date: Fri, 23 Aug 2019 11:19:27 -0700 Subject: [PATCH 09/16] feat: allow `prefetch` to accept url[] list --- src/index.mjs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/index.mjs b/src/index.mjs index ce8aa0ca..5b162356 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -116,7 +116,13 @@ export function listen(options) { * @return {Object} a Promise */ export function prefetch(url, isPriority, conn) { - if (toPrefetch.has(url)) return; + if (Array.isArray(url)) { + return Promise.all(url.map(x => prefetch(x, isPriority))); + } + + if (toPrefetch.has(url)) { + return; + } if (conn = navigator.connection) { // Don't prefetch if using 2G or if Save-Data is enabled. From fb6544171690621e268fdc0904f4b1f825c5b1f0 Mon Sep 17 00:00:00 2001 From: Luke Edwards Date: Tue, 3 Sep 2019 22:09:21 -0700 Subject: [PATCH 10/16] fix: remove TODO notes --- src/index.mjs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/index.mjs b/src/index.mjs index 5b162356..b3799aa2 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -60,9 +60,6 @@ export function listen(options) { const [toAdd, isDone] = throttle(options.throttle || 1/0); const limit = options.limit || 1/0; - // TODO: I think this isn't needed? - const isPriority = !!options.priority; - const allowed = options.origins || [location.hostname]; const ignores = options.ignores || []; @@ -75,8 +72,7 @@ export function listen(options) { // Do not prefetch if will match/exceed limit if (toPrefetch.size < limit) { toAdd(() => { - // TODO: Don't need isPriority? - prefetch(entry.href, isPriority).then(isDone).catch(err => { + prefetch(entry.href, options.priority).then(isDone).catch(err => { isDone(); if (options.onError) options.onError(err); }); }); From e25e2175b240018f9aeafcabdac6b2fd51576f25 Mon Sep 17 00:00:00 2001 From: Luke Edwards Date: Tue, 3 Sep 2019 22:12:58 -0700 Subject: [PATCH 11/16] golf: always handle `prefetch` as array --- src/index.mjs | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/src/index.mjs b/src/index.mjs index b3799aa2..ccdb78af 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -112,25 +112,23 @@ export function listen(options) { * @return {Object} a Promise */ export function prefetch(url, isPriority, conn) { - if (Array.isArray(url)) { - return Promise.all(url.map(x => prefetch(x, isPriority))); - } - - if (toPrefetch.has(url)) { - return; - } - if (conn = navigator.connection) { // Don't prefetch if using 2G or if Save-Data is enabled. if (conn.saveData || /2g/.test(conn.effectiveType)) return; } - // Add it now, regardless of its success - // ~> so that we don't repeat broken links - toPrefetch.add(url); + // Dev must supply own catch() + return Promise.all( + [].concat(url).map(str => { + if (!toPrefetch.has(str)) { + // Add it now, regardless of its success + // ~> so that we don't repeat broken links + toPrefetch.add(str); - // Wanna do something on catch()? - return (isPriority ? priority : supported)( - new URL(url, location.href).toString() + return (isPriority ? priority : supported)( + new URL(str, location.href).toString() + ); + } + }) ); } From 27d3a34e18e081d403e2115d81f0016a4deec3ac Mon Sep 17 00:00:00 2001 From: Luke Edwards Date: Tue, 3 Sep 2019 23:30:51 -0700 Subject: [PATCH 12/16] chore: complete tests --- test/quicklink.spec.js | 73 ++++++++++++++++++++++++ test/test-limit.html | 26 +++++++++ test/test-prefetch-duplicate-shared.html | 24 ++++++++ test/test-prefetch-duplicate.html | 25 ++++++++ test/test-prefetch-multiple.html | 25 ++++++++ test/test-prefetch-single.html | 25 ++++++++ 6 files changed, 198 insertions(+) create mode 100644 test/test-limit.html create mode 100644 test/test-prefetch-duplicate-shared.html create mode 100644 test/test-prefetch-duplicate.html create mode 100644 test/test-prefetch-multiple.html create mode 100644 test/test-prefetch-single.html diff --git a/test/quicklink.spec.js b/test/quicklink.spec.js index a275bef2..89176a69 100644 --- a/test/quicklink.spec.js +++ b/test/quicklink.spec.js @@ -154,4 +154,77 @@ describe('quicklink tests', function () { // (uri, elem) => elem.textContent.includes('Spinner') expect(responseURLs).to.not.include('https://github.githubassets.com/images/spinners/octocat-spinner-32.gif'); }); + + it('should accept a single URL to prefetch()', async function () { + const responseURLs = []; + page.on('response', resp => { + responseURLs.push(resp.url()); + }); + await page.goto(`${server}/test-prefetch-single.html`); + await page.waitFor(1000); + expect(responseURLs).to.be.an('array'); + expect(responseURLs).to.include(`${server}/2.html`); + }); + + it('should accept multiple URLs to prefetch()', async function () { + const responseURLs = []; + page.on('response', resp => { + responseURLs.push(resp.url()); + }); + await page.goto(`${server}/test-prefetch-multiple.html`); + await page.waitFor(1000); + + // don't care about first 4 URLs (markup) + const ours = responseURLs.slice(4); + + expect(ours.length).to.equal(3); + expect(ours).to.include(`${server}/2.html`); + expect(ours).to.include(`${server}/3.html`); + expect(ours).to.include(`${server}/4.html`); + }); + + it('should not prefetch() the same URL repeatedly', async function () { + const responseURLs = []; + page.on('response', resp => { + responseURLs.push(resp.url()); + }); + await page.goto(`${server}/test-prefetch-duplicate.html`); + await page.waitFor(1000); + + // don't care about first 4 URLs (markup) + const ours = responseURLs.slice(4); + + expect(ours.length).to.equal(1); + expect(ours).to.include(`${server}/2.html`); + }); + + it('should not call the same URL repeatedly (shared)', async function () { + const responseURLs = []; + page.on('response', resp => { + responseURLs.push(resp.url()); + }); + await page.goto(`${server}/test-prefetch-duplicate-shared.html`); + await page.waitFor(1000); + + // count occurences of our link + const target = responseURLs.filter(x => x === `${server}/2.html`); + expect(target.length).to.equal(1); + }); + + it('should not exceed the `limit` total', async function () { + const responseURLs = []; + page.on('response', resp => { + responseURLs.push(resp.url()); + }); + await page.goto(`${server}/test-limit.html`); + await page.waitFor(1000); + + // don't care about first 4 URLs (markup) + const ours = responseURLs.slice(4); + + expect(ours.length).to.equal(1); + expect(ours).to.include(`${server}/1.html`); + }); + + // TODO: throttle test }); diff --git a/test/test-limit.html b/test/test-limit.html new file mode 100644 index 00000000..51d879a2 --- /dev/null +++ b/test/test-limit.html @@ -0,0 +1,26 @@ + + + + + + + Prefetch: Basic Usage + + + + + + + Link 1 + Link 2 + Link 3 + Link 4 + + + + + diff --git a/test/test-prefetch-duplicate-shared.html b/test/test-prefetch-duplicate-shared.html new file mode 100644 index 00000000..a01a925e --- /dev/null +++ b/test/test-prefetch-duplicate-shared.html @@ -0,0 +1,24 @@ + + + + + + Prefetch: Static URL list + + + + + + + Link 2 +
+ CSS +
+ Link 4 + + + + diff --git a/test/test-prefetch-duplicate.html b/test/test-prefetch-duplicate.html new file mode 100644 index 00000000..a5d9f865 --- /dev/null +++ b/test/test-prefetch-duplicate.html @@ -0,0 +1,25 @@ + + + + + + Prefetch: Static URL list + + + + + + + Link 1 + Link 2 + Link 3 +
+ CSS +
+ Link 4 + + + + diff --git a/test/test-prefetch-multiple.html b/test/test-prefetch-multiple.html new file mode 100644 index 00000000..80d882ec --- /dev/null +++ b/test/test-prefetch-multiple.html @@ -0,0 +1,25 @@ + + + + + + Prefetch: Static URL list + + + + + + + Link 1 + Link 2 + Link 3 +
+ CSS +
+ Link 4 + + + + diff --git a/test/test-prefetch-single.html b/test/test-prefetch-single.html new file mode 100644 index 00000000..f948160b --- /dev/null +++ b/test/test-prefetch-single.html @@ -0,0 +1,25 @@ + + + + + + Prefetch: Static URL list + + + + + + + Link 1 + Link 2 + Link 3 +
+ CSS +
+ Link 4 + + + + From f410264809642974ba8dbc98e7cfc9da66c55284 Mon Sep 17 00:00:00 2001 From: Luke Edwards Date: Sun, 8 Sep 2019 14:19:44 -0700 Subject: [PATCH 13/16] chore: add `throttle` tests --- test/quicklink.spec.js | 30 +++++++++++++++++++++++++++++- test/test-throttle.html | 26 ++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 test/test-throttle.html diff --git a/test/quicklink.spec.js b/test/quicklink.spec.js index 89176a69..1fe8ad44 100644 --- a/test/quicklink.spec.js +++ b/test/quicklink.spec.js @@ -226,5 +226,33 @@ describe('quicklink tests', function () { expect(ours).to.include(`${server}/1.html`); }); - // TODO: throttle test + it('should respect the `throttle` concurrency', async function () { + const sleep = ms => new Promise(r => setTimeout(r, ms)); + const URLs = []; // Note: Page makes 4 requests + + // Make HTML requests take a long time + // ~> so that we can ensure throttling occurs + await page.setRequestInterception(true); + + page.on('request', async req => { + if (/test\/\d+\.html$/i.test(req.url())) { + await sleep(100); + URLs.push(req.url()); + return req.respond({ status: 200 }); + } + req.continue(); + }); + + await page.goto(`${server}/test-throttle.html`); + + // Only 2 should be done by now + // Note: Parallel requests, w/ 50ms buffer + await page.waitFor(150); + expect(URLs.length).to.equal(2); + + // All should be done by now + // Note: Parallel requests, w/ 50ms buffer + await page.waitFor(250); + expect(URLs.length).to.equal(4); + }); }); diff --git a/test/test-throttle.html b/test/test-throttle.html new file mode 100644 index 00000000..3a2a77ba --- /dev/null +++ b/test/test-throttle.html @@ -0,0 +1,26 @@ + + + + + + + Prefetch: Basic Usage + + + + + + + Link 1 + Link 2 + Link 3 + Link 4 + + + + + From 2cce45b033488a85cc6bddb434de831c460ab9bc Mon Sep 17 00:00:00 2001 From: Luke Edwards Date: Sun, 8 Sep 2019 14:46:00 -0700 Subject: [PATCH 14/16] chore: update docs --- README.md | 80 ++++++++++++++++++++++++++++++++----------------------- 1 file changed, 47 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 39e9f4e3..c75742fc 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ Quickstart: ``` @@ -53,7 +53,7 @@ For example, you can initialize after the `load` event fires: ```html ``` @@ -61,27 +61,35 @@ window.addEventListener('load', () =>{ ES Module import: ```js -import quicklink from "quicklink/dist/quicklink.mjs"; -quicklink(); +import { listen, prefetch } from "quicklink"; ``` The above options are best for multi-page sites. Single-page apps have a few options available for using quicklink with a router: -* Call `quicklink()` once a navigation to a new route has completed -* Call `quicklink()` against a specific DOM element / component -* Call `quicklink({urls:[...]})` with a custom set of URLs to prefetch +* Call `quicklink.listen()` once a navigation to a new route has completed +* Call `quicklink.listen()` against a specific DOM element / component +* Call `quicklink.prefetch()` with a custom set of URLs to prefetch ## API -`quicklink` accepts an optional options object with the following parameters: +The `quicklink.listen` method accepts an optional options object with the following parameters: * `el`: DOM element to observe for in-viewport links to prefetch * `urls`: Static array of URLs to prefetch (instead of observing `document` or a DOM element links in the viewport) +* `limit`: An integer representing the _total_ requests that can be prefetched. Defaults to `Infinity` +* `throttle`: An integer representing the _concurrency limit_ for simultaneous requests. Defaults to `Infinity` * `timeout`: Integer for the `requestIdleCallback` timeout. A time in milliseconds by which the browser must execute prefetching. Defaults to 2 seconds. * `timeoutFn`: Function for specifying a timeout. Defaults to `requestIdleCallback`. Can also be swapped out for a custom function like [networkIdleCallback](https://github.com/pastelsky/network-idle-callback) (see demos) * `priority`: Boolean specifying preferred priority for fetches. Defaults to `false`. `true` will attempt to use the `fetch()` API where supported (rather than rel=prefetch) * `origins`: Static array of URL hostname strings that are allowed to be prefetched. Defaults to the same domain origin, which prevents _any_ cross-origin requests. * `ignores`: A RegExp, Function, or Array that further determines if a URL should be prefetched. These execute _after_ origin matching. +* `onError`: An optional Function that will handle errors from prefetched requests. By default, these errors are silently ignored + +The `quicklink.prefetch` method accepts one or more URL strings and an `isPriority` toggle.
+A `Promise` is returned that always resolves to an array of results (if desired) and requires that you `catch` your own reuest error(s). + +> **Note:** Calls to `prefetch` are "low-priority" by default. This behaves identically to `listen()`'s `priority` option. + TODO: * Explore detecting file-extension of resources and using [rel=preload](https://w3c.github.io/preload/) for high priority fetches @@ -107,7 +115,7 @@ Alternatively, see the [Intersection Observer polyfill](https://github.com/w3c/I Defaults to 2 seconds (via `requestIdleCallback`). Here we override it to 4 seconds: ```js -quicklink({ +quicklink.listen({ timeout: 4000 }); ``` @@ -117,28 +125,35 @@ quicklink({ Defaults to `document` otherwise. ```js -const elem = document.getElementById('carousel'); -quicklink({ - el: elem +quicklink.listen({ + el: document.getElementById('carousel') }); ``` -### Set a custom array of URLs to be prefetched +### Programmatically `prefetch()` URLs If you would prefer to provide a static list of URLs to be prefetched, instead of detecting those in-viewport, customizing URLs is supported. ```js -quicklink({ - urls: ['2.html','3.html', '4.js'] -}); +// Single URL +quicklink.prefetch('2.html'); + +// Multiple URLs +quicklink.prefetch(['2.html', '3.html', '4.js']); + +// Multiple URLs, with high priority +// Note: Can also be use with single URL! +quicklink.prefetch(['2.html', '3.html', '4.js'], true); ``` -### Set the request priority for prefetches +### Set the request priority for prefetches while scrolling Defaults to low-priority (`rel=prefetch` or XHR). For high-priority (`priority: true`), attempts to use `fetch()` or falls back to XHR. +> **Note:** This runs `prefetch(..., true)` with URLs found within the `options.el` container. + ```js -quicklink({ priority: true }); +quicklink.listen({ priority: true }); ``` ### Specify a custom list of allowed origins @@ -148,7 +163,7 @@ Provide a list of hostnames that should be prefetch-able. Only the same origin i > **Important:** You must also include your own hostname! ```js -quicklink({ +quicklink.listen({ origins: [ // add mine 'my-website.com', @@ -168,7 +183,7 @@ Enables all cross-origin requests to be made. > **Note:** You may run into [CORB](https://chromium.googlesource.com/chromium/src/+/master/services/network/cross_origin_read_blocking_explainer.md) and [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) issues! ```js -quicklink({ +quicklink.listen({ origins: true, // or origins: [] @@ -187,7 +202,7 @@ These filters run _after_ the `origins` matching has run. Ignores can be useful // - all ".zip" extensions // - all tags with "noprefetch" attribute // -quicklink({ +quicklink.listen({ ignores: [ /\/api\/?/, uri => uri.includes('.zip'), @@ -201,12 +216,12 @@ You may also wish to ignore prefetches to URLs which contain a URL fragment (e.g Using `ignores` this can be achieved as follows: ```js -quicklink({ - ignores: [ - uri => uri.includes('#') - // or RegExp: /#(.+)/ - // or element matching: (uri, elem) => !!elem.hash - ] +quicklink.listen({ + ignores: [ + uri => uri.includes('#') + // or RegExp: /#(.+)/ + // or element matching: (uri, elem) => !!elem.hash + ] }); ``` @@ -229,11 +244,10 @@ Certain features have layered support: ```html ``` @@ -258,7 +272,7 @@ Please note: this is by no means an exhaustive benchmark of the pros and cons of ### Session Stitching -Cross-origin prefetching (e.g a.com/foo.html prefetches b.com/bar.html) has a number of limitations. One such limitation is with session-stitching. b.com may expect a.com's navigation requests to include session information (e.g a temporary ID - e.g b.com/bar.html?hash=<>×tamp=<>), where this information is used to customize the experience or log information to analytics. If session-stitching requires a timestamp in the URL, what is prefetched and stored in the HTTP cache may not be the same as the one the user ultimately navigates to. This introduces a challenge as it can result in double prefetches. +Cross-origin prefetching (e.g a.com/foo.html prefetches b.com/bar.html) has a number of limitations. One such limitation is with session-stitching. b.com may expect a.com's navigation requests to include session information (e.g a temporary ID - e.g b.com/bar.html?hash=<>×tamp=<>), where this information is used to customize the experience or log information to analytics. If session-stitching requires a timestamp in the URL, what is prefetched and stored in the HTTP cache may not be the same as the one the user ultimately navigates to. This introduces a challenge as it can result in double prefetches. To workaround this problem, you can consider passing along session information via the [ping attribute](https://caniuse.com/#feat=ping) (separately) so the origin can stitch a session together asynchronously. From 6d1e1129034108a35ebb7b372742a8b5f4793e8d Mon Sep 17 00:00:00 2001 From: Luke Edwards Date: Sun, 8 Sep 2019 15:28:15 -0700 Subject: [PATCH 15/16] fix: separate listen() vs prefetch() callers --- test/test-prefetch-duplicate-shared.html | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/test-prefetch-duplicate-shared.html b/test/test-prefetch-duplicate-shared.html index a01a925e..f5650591 100644 --- a/test/test-prefetch-duplicate-shared.html +++ b/test/test-prefetch-duplicate-shared.html @@ -18,7 +18,10 @@ From 405b1c0e07f817bfec96258b4e73eb56846b1630 Mon Sep 17 00:00:00 2001 From: Luke Edwards Date: Thu, 12 Sep 2019 09:47:44 -0700 Subject: [PATCH 16/16] docs: format API docs differently (#1) --- README.md | 121 ++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 104 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index c75742fc..182ab96a 100644 --- a/README.md +++ b/README.md @@ -72,26 +72,110 @@ The above options are best for multi-page sites. Single-page apps have a few opt ## API -The `quicklink.listen` method accepts an optional options object with the following parameters: +### quicklink.listen(options) +Returns: `Function` -* `el`: DOM element to observe for in-viewport links to prefetch -* `urls`: Static array of URLs to prefetch (instead of observing `document` or a DOM element links in the viewport) -* `limit`: An integer representing the _total_ requests that can be prefetched. Defaults to `Infinity` -* `throttle`: An integer representing the _concurrency limit_ for simultaneous requests. Defaults to `Infinity` -* `timeout`: Integer for the `requestIdleCallback` timeout. A time in milliseconds by which the browser must execute prefetching. Defaults to 2 seconds. -* `timeoutFn`: Function for specifying a timeout. Defaults to `requestIdleCallback`. Can also be swapped out for a custom function like [networkIdleCallback](https://github.com/pastelsky/network-idle-callback) (see demos) -* `priority`: Boolean specifying preferred priority for fetches. Defaults to `false`. `true` will attempt to use the `fetch()` API where supported (rather than rel=prefetch) -* `origins`: Static array of URL hostname strings that are allowed to be prefetched. Defaults to the same domain origin, which prevents _any_ cross-origin requests. -* `ignores`: A RegExp, Function, or Array that further determines if a URL should be prefetched. These execute _after_ origin matching. -* `onError`: An optional Function that will handle errors from prefetched requests. By default, these errors are silently ignored +A "reset" function is returned, which will empty the active `IntersectionObserver` and the cache of URLs that have already been prefetched. This can be used between page navigations and/or when significant DOM changes have occurred. -The `quicklink.prefetch` method accepts one or more URL strings and an `isPriority` toggle.
-A `Promise` is returned that always resolves to an array of results (if desired) and requires that you `catch` your own reuest error(s). +#### options.el +Type: `HTMLElement`
+Default: `document.body` -> **Note:** Calls to `prefetch` are "low-priority" by default. This behaves identically to `listen()`'s `priority` option. +The DOM element to observe for in-viewport links to prefetch. +#### options.limit +Type: `Number`
+Default: `Infinity` + +The _total_ requests that can be prefetched while observing the `options.el` container. + +#### options.throttle +Type: `Number`
+Default: `Infinity` + +The _concurrency limit_ for simultaneous requests while observing the `options.el` container. + +#### options.timeout +Type: `Number`
+Default: `2000` + +The `requestIdleCallback` timeout, in milliseconds. + +> **Note:** The browser must be idle for the configured duration before prefetching. + +#### options.timeoutFn +Type: `Function`
+Default: `requestIdleCallback` + +A function used for specifying a `timeout` delay.
+This can be swapped out for a custom function like [networkIdleCallback](https://github.com/pastelsky/network-idle-callback) (see demos). + +By default, this uses [`requestIdleCallback`](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback) or the embedded polyfill. + +#### options.priority +Type: `Boolean`
+Default: `false` + +Whether or not the URLs within the `options.el` container should be treated as high priority. + +When `true`, quicklink will attempt to use the `fetch()` API if supported (rather than `link[rel=prefetch]`). + +#### options.origins +Type: `Array`
+Default: `[location.hostname]` + +A static array of URL hostnames that are allowed to be prefetched.
+Defaults to the same domain origin, which prevents _any_ cross-origin requests. + +**Important:** An empty array (`[]`) allows ***all origins*** to be prefetched. + +#### options.ignores +Type: `RegExp` or `Function` or `Array`
+Default: `[]` + +Determine if a URL should be prefetched. + +When a `RegExp` tests positive, a `Function` returns `true`, or an `Array` contains the string, then the URL is _not_ prefetched. + +> **Note:** An `Array` may contain `String`, `RegExp`, or `Function` values. + +> **Important:** This logic is executed _after_ origin matching! + +#### options.onError +Type: `Function`
+Default: None + +An optional error handler that will receive any errors from prefetched requests.
+By default, these errors are silently ignored. + + +### quicklink.prefetch(urls, isPriority) +Returns: `Promise` + +The `urls` provided are always passed through `Promise.all`, which means the result will always resolve to an Array. + +> **Important:** You much `catch` you own request error(s). + +#### urls +Type: `String` or `Array`
+Required: `true` + +One or many URLs to be prefetched. + +> **Note:** Each `url` value is resolved from the current location. + +#### isPriority +Type: `Boolean`
+Default: `false` + +Whether or not the URL(s) should be treated as "high priority" targets.
+By default, calls to `prefetch()` are low priority. + +> **Note:** This behaves identically to `listen()`'s `priority` option. + + +## TODO -TODO: * Explore detecting file-extension of resources and using [rel=preload](https://w3c.github.io/preload/) for high priority fetches * Explore using [Priority Hints](https://github.com/WICG/priority-hints) for importance hinting @@ -225,7 +309,7 @@ quicklink.listen({ }); ``` -## Browser support +## Browser Support The prefetching provided by `quicklink` can be viewed as a [progressive enhancement](https://www.smashingmagazine.com/2009/04/progressive-enhancement-what-it-is-and-how-to-use-it/). Cross-browser support is as follows: @@ -240,7 +324,10 @@ Certain features have layered support: ## Using the prefetcher directly -`quicklink` includes a prefetcher that can be individually imported for use in other projects. After installing `quicklink` as a dependency, you can use it as follows: +A `prefetch` method can be individually imported for use in other projects.
+This method includes the logic to respect Data Saver and 2G connections. It also issues requests thru `fetch()`, XHRs, or `link[rel=prefetch]` depending on (a) the `isPriority` value and (b) the current browser's support. + +After installing `quicklink` as a dependency, you can use it as follows: ```html