Skip to content

Commit

Permalink
[core] Introduce prefetch chunks build (#171)
Browse files Browse the repository at this point in the history
* Adds prefetch chunks implementation

* [fix] unit tests for prefetchchunks (#168)

* [fix] unit tests for prefetchchunks

* [chore] cleaned up console logs

* [infra] default accessor argument added

* [fix] mistyped expected value (#169)

* [fix] mistyped expected value

* [chore] debugging prefetch chunks test

* [chore] debugging with normal JS functions

* [chore] debugging by adding a real css file

* [chore] debugging

* [chore] debugging

* [chore] debugging

* [chore] debugging

* [chore] debugging

* [chore] debugging

* [chore] debugging

* [chore] debugging

* [chore] debugging tests

* [chore] debugging tests

* [chore] cleaned up

Co-authored-by: Anton Karlovskiy <antonkarlovskiy@outlook.com>
  • Loading branch information
addyosmani and anton-karlovskiy committed Apr 19, 2020
1 parent 836f170 commit 301aedb
Show file tree
Hide file tree
Showing 8 changed files with 284 additions and 6 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ npm-debug.log*
yarn-debug.log*
yarn-error.log*
dist
.DS_Store

# Runtime data
pids
Expand Down
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@
"lint": "eslint src/*.mjs test/*.js demos/*.js",
"lint-fix": "eslint src/*.mjs test/*.js --fix demos/*.js",
"start": "http-server .",
"test": "yarn run build && mocha test/bootstrap.js --recursive test",
"test": "yarn run build-all && mocha test/bootstrap.js --recursive test",
"build": "microbundle src/index.mjs --no-sourcemap --external none",
"build-plugin": "microbundle src/chunks.mjs --no-sourcemap --external none -o dist/chunks",
"build-all": "yarn run build && yarn run build-plugin",
"prepare": "yarn run -s build",
"bundlesize": "bundlesize",
"changelog": "yarn conventional-changelog -i CHANGELOG.md -s -r 0",
Expand Down Expand Up @@ -48,7 +50,8 @@
"lodash": "^4.17.11",
"microbundle": "0.11.0",
"mocha": "^6.2.2",
"puppeteer": "^2.0.0"
"puppeteer": "^2.0.0",
"route-manifest": "^1.0.0"
},
"bundlesize": [
{
Expand Down
141 changes: 141 additions & 0 deletions src/chunks.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/**
* Copyright 2018 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
**/
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();

/**
* Determine if the anchor tag should be prefetched.
* A filter can be a RegExp, Function, or Array of both.
* - Function receives `node.href, node` arguments
* - RegExp receives `node.href` only (the full URL)
* @param {Element} node The anchor (<a>) tag.
* @param {Mixed} filter The custom filter(s)
* @return {Boolean} If true, then it should be ignored
*/
function isIgnored(node, filter) {
return Array.isArray(filter)
? filter.some(x => isIgnored(node, x))
: (filter.test || filter).call(filter, node.href, node);
}

/**
* Prefetch an array of URLs if the user's effective
* connection type and data-saver preferences suggests
* it would be useful. By default, looks at in-viewport
* links for `document`. Can also work off a supplied
* DOM element or static array of URLs.
* @param {Object} options - Configuration options for quicklink
* @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
* @param {Function} [options.onError] - Error handler for failed `prefetch` requests
* @param {Function} [options.prefetchChunks] - Function to prefetch chunks for route URLs (with route manifest for URL mapping)
*/
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;

const allowed = options.origins || [location.hostname];
const ignores = options.ignores || [];

const timeoutFn = options.timeoutFn || requestIdleCallback;

const prefetchChunks = options.prefetchChunks;

const prefetchHandler = urls => {
prefetch(urls, options.priority).then(isDone).catch(err => {
isDone(); if (options.onError) options.onError(err);
});
};

const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
observer.unobserve(entry = entry.target);
// Do not prefetch if will match/exceed limit
if (toPrefetch.size < limit) {
toAdd(() => {
prefetchChunks ? prefetchChunks(entry, prefetchHandler) : prefetchHandler(entry.href);
});
}
}
});
});

timeoutFn(() => {
// 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 || 2000
});

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 (conn = navigator.connection) {
// Don't prefetch if using 2G or if Save-Data is enabled.
if (conn.saveData || /2g/.test(conn.effectiveType)) return;
}

// 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);

return (isPriority ? priority : supported)(
new URL(str, location.href).toString()
);
}
})
);
}
2 changes: 1 addition & 1 deletion test/bootstrap.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ before(async function () {

// close browser and reset global variables
after(function () {
browser.close();
global.browser.close();

global.browser = globalVariables.browser;
global.expect = globalVariables.expect;
Expand Down
31 changes: 28 additions & 3 deletions test/quicklink.spec.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
describe('quicklink tests', function () {
const server = `http://127.0.0.1:8080/test`;
const host = 'http://127.0.0.1:8080';
const server = `${host}/test`;
let page;

before(async function () {
Expand Down Expand Up @@ -194,7 +195,7 @@ describe('quicklink tests', function () {
// don't care about first 4 URLs (markup)
const ours = responseURLs.slice(4);

expect(ours.length).to.equal(2);
expect(ours.length).to.equal(1);
expect(ours).to.include(`${server}/2.html`);
});

Expand Down Expand Up @@ -222,7 +223,7 @@ describe('quicklink tests', function () {
// don't care about first 4 URLs (markup)
const ours = responseURLs.slice(4);

expect(ours.length).to.equal(2);
expect(ours.length).to.equal(1);
expect(ours).to.include(`${server}/1.html`);
});

Expand Down Expand Up @@ -255,4 +256,28 @@ describe('quicklink tests', function () {
await page.waitFor(250);
expect(URLs.length).to.equal(4);
});

it('should prefetch chunks for in-viewport links', async function () {
const responseURLs = [];
page.on('response', resp => {
responseURLs.push(resp.url());
});
await page.goto(`${server}/test-prefetch-chunks.html`);
await page.waitFor(1000);
expect(responseURLs).to.be.an('array');
// should prefetch chunk URLs for /, /blog and /about links
expect(responseURLs).to.include(`${host}/test/static/css/home.6d953f22.chunk.css`);
expect(responseURLs).to.include(`${host}/test/static/js/home.14835906.chunk.js`);
expect(responseURLs).to.include(`${host}/test/static/media/video.b9b6e9e1.svg`);
expect(responseURLs).to.include(`${host}/test/static/css/blog.2a8b6ab6.chunk.css`);
expect(responseURLs).to.include(`${host}/test/static/js/blog.1dcce8a6.chunk.js`);
expect(responseURLs).to.include(`${host}/test/static/css/about.00ec0d84.chunk.css`);
expect(responseURLs).to.include(`${host}/test/static/js/about.921ebc84.chunk.js`);
// should not prefetch /, /blog and /about links
expect(responseURLs).to.not.include(`${server}`);
expect(responseURLs).to.not.include(`${server}/blog`);
expect(responseURLs).to.not.include(`${server}/about`);
// should prefetch regular links
expect(responseURLs).to.include(`${server}/main.css`);
});
});
37 changes: 37 additions & 0 deletions test/rmanifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"/about": [{
"type": "style",
"href": "/test/static/css/about.00ec0d84.chunk.css"
}, {
"type": "script",
"href": "/test/static/js/about.921ebc84.chunk.js"
}],
"/blog": [{
"type": "style",
"href": "/test/static/css/blog.2a8b6ab6.chunk.css"
}, {
"type": "script",
"href": "/test/static/js/blog.1dcce8a6.chunk.js"
}],
"/": [{
"type": "style",
"href": "/test/static/css/home.6d953f22.chunk.css"
}, {
"type": "script",
"href": "/test/static/js/home.14835906.chunk.js"
}, {
"type": "image",
"href": "/test/static/media/video.b9b6e9e1.svg"
}],
"/blog/:title": [{
"type": "style",
"href": "/test/static/css/article.cb6f97df.chunk.css"
}, {
"type": "script",
"href": "/test/static/js/article.cb6f97df.chunk.js"
}],
"*": [{
"type": "script",
"href": "/test/static/js/6.7f61b1a1.chunk.js"
}]
}
59 changes: 59 additions & 0 deletions test/test-prefetch-chunks.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Prefetch: Chunk URL list</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" media="screen" href="main.css">
<script src="https://polyfill.io/v3/polyfill.min.js?features=IntersectionObserver"></script>
</head>

<body>
<a href="/">Home</a>
<a href="/blog">Blog</a>
<a href="/about">About</a>
<section id="stuff">
<a href="main.css">CSS</a>
</section>
<a href="4.html" style="position:absolute;margin-top:900px;">Link 4</a>
<script src="../dist/chunks/quicklink.umd.js"></script>
<script src="../node_modules/route-manifest/dist/rmanifest.min.js"></script>
<script>
const __defaultAccessor = mix => {
return (mix && mix.href) || mix || '';
};

const prefetchChunks = (entry, prefetchHandler, accessor = __defaultAccessor) => {
const { files } = rmanifest(window._rmanifest_, entry.pathname);
const chunkURLs = files.map(accessor).filter(Boolean);
if (chunkURLs.length) {
console.log('[prefetchChunks] chunkURLs => ', chunkURLs);
prefetchHandler(chunkURLs);
} else {
// also prefetch regular links in-viewport
console.log('[prefetchChunks] regularURL => ', entry.href);
prefetchHandler(entry.href);
}
};

const listenAfterFetchingRmanifest = async () => {
if (!window._rmanifest_) {
await fetch('/test/rmanifest.json')
.then(response => response.json())
.then(data => {
// attach route manifest to global
window._rmanifest_ = data;
});
}

quicklink.listen({
prefetchChunks,
origins: []
});
};

listenAfterFetchingRmanifest();
</script>
</body>
</html>
12 changes: 12 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -4450,6 +4450,11 @@ regex-cache@^0.4.2:
dependencies:
is-equal-shallow "^0.1.3"

regexparam@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/regexparam/-/regexparam-1.3.0.tgz#2fe42c93e32a40eff6235d635e0ffa344b92965f"
integrity sha512-6IQpFBv6e5vz1QAqI+V4k8P2e/3gRrqfCJ9FI+O1FLQTO+Uz6RXZEZOPmTJ6hlGj7gkERzY5BRCv09whKP96/g==

regexpp@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-2.0.1.tgz#8d19d31cf632482b589049f8281f93dbcba4d07f"
Expand Down Expand Up @@ -4730,6 +4735,13 @@ rollup@^0.67.3:
"@types/estree" "0.0.39"
"@types/node" "*"

route-manifest@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/route-manifest/-/route-manifest-1.0.0.tgz#0155513f3cd158c18827413845ab1a8ec2ad15e1"
integrity sha512-qn0xJr4nnF4caj0erOLLAHYiNyzqhzpUbgDQcEHrmBoG4sWCDLnIXLH7VccNSxe9cWgbP2Kw/OjME+eH3CeRSA==
dependencies:
regexparam "^1.3.0"

run-async@^2.2.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.3.0.tgz#0371ab4ae0bdd720d4166d7dfda64ff7a445a6c0"
Expand Down

0 comments on commit 301aedb

Please sign in to comment.