From 3d26f40f21a759edc50ff081edb145b0562be254 Mon Sep 17 00:00:00 2001
From: hadyan
Date: Sat, 6 Aug 2022 04:53:19 +0800
Subject: [PATCH] Add support for same-site prerendering with Speculation Rules
API (#258)
* initial commit to add support for same-origin prerendering with Speculation Rules API
* reset author
* refactor, best practices, and minor logic update
* refactor, best practices, and minor logic update
* avoid prefetching the link that has been prerendered in listen
* create prerenderLimit as a constant to cater for the current Spec Rules API limitations and ease of change once the limitations are updated
* refactor prerender specific checks and compliance with eslint-config-google formatting
* create new tests for prerender with speculation rules
* bug fix: addSpeculationRules does not resolve
* adding prerendering doc in README
* Update README.md
Co-authored-by: Domenic Denicola
* removed reference to outdated OT
removed extra argument in the promise constructor
fixed the inconsistent application of spacing throughout repo
* fixed promise rejection inside catch handler issue
* Update src/index.mjs
Co-authored-by: Domenic Denicola
* Update src/index.mjs
Co-authored-by: Domenic Denicola
* Update src/prerender.mjs
Co-authored-by: Domenic Denicola
* update return value documentation
* remove conn param from prefetch and prerender functions
* updated addSpeculationRules return value
* restored conn param
Co-authored-by: Addy Osmani
Co-authored-by: Domenic Denicola
---
README.md | 44 ++++++++-
src/index.mjs | 114 +++++++++++++++++++---
src/prerender.mjs | 60 ++++++++++++
test/test-prerender-andPrefetch.html | 27 +++++
test/test-prerender-only.html | 27 +++++
test/test-prerender-wrapper-multiple.html | 27 +++++
test/test-prerender-wrapper-single.html | 27 +++++
7 files changed, 310 insertions(+), 16 deletions(-)
create mode 100644 src/prerender.mjs
create mode 100644 test/test-prerender-andPrefetch.html
create mode 100644 test/test-prerender-only.html
create mode 100644 test/test-prerender-wrapper-multiple.html
create mode 100644 test/test-prerender-wrapper-single.html
diff --git a/README.md b/README.md
index 471dfaec..5c96116c 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@
# quicklink
-> Faster subsequent page-loads by prefetching in-viewport links during idle time
+> Faster subsequent page-loads by prefetching or prerendering in-viewport links during idle time
## How it works
@@ -17,11 +17,11 @@ Quicklink attempts to make navigations to subsequent pages load faster. It:
* **Detects links within the viewport** (using [Intersection Observer](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API))
* **Waits until the browser is idle** (using [requestIdleCallback](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback))
* **Checks if the user isn't on a slow connection** (using `navigator.connection.effectiveType`) or has data-saver enabled (using `navigator.connection.saveData`)
-* **Prefetches URLs to the links** (using [``](https://www.w3.org/TR/resource-hints/#prefetch) or XHR). Provides some control over the request priority (can switch to `fetch()` if supported).
+* **Prefetches** (using [``](https://www.w3.org/TR/resource-hints/#prefetch) or XHR) or **prerenders** (using [Speculation Rules API](/~https://github.com/WICG/nav-speculation/blob/main/triggers.md)) URLs to the links. Provides some control over the request priority (can switch to `fetch()` if supported).
## Why
-This project aims to be a drop-in solution for sites to prefetch links based on what is in the user's viewport. It also aims to be small (**< 1KB minified/gzipped**).
+This project aims to be a drop-in solution for sites to prefetch or prerender links based on what is in the user's viewport. It also aims to be small (**< 1KB minified/gzipped**).
## Multi page apps
@@ -111,7 +111,16 @@ const options = {
### quicklink.listen(options)
Returns: `Function`
-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.
+A "reset" function is returned, which will empty the active `IntersectionObserver` and the cache of URLs that have already been prefetched or prerendered. This can be used between page navigations and/or when significant DOM changes have occurred.
+
+#### options.prerender
+Type: `Boolean`
+Default: `false`
+
+Whether to switch from the default prefetching mode to the prerendering mode for the links inside the viewport.
+
+> **Note:** The prerendering mode (when this option is set to true) will fallback to the prefetching mode if the browser does not support prerender.
+
#### options.delay
Type: `Number`
@@ -226,6 +235,19 @@ By default, calls to `prefetch()` are low priority.
> **Note:** This behaves identically to `listen()`'s `priority` option.
+### quicklink.prerender(urls)
+Returns: `Promise`
+
+> **Important:** You much `catch` you own request error(s).
+
+#### urls
+Type: `String` or `Array`
+Required: `true`
+
+One or many URLs to be prerendered.
+
+> **Note:** As prerendering using Speculative Rules API only supports same-origin at this point, only same-origin urls are accepted. Any non same-origin urls will return a rejected Promise.
+
## Polyfills
`quicklink`:
@@ -277,6 +299,19 @@ quicklink.prefetch(['2.html', '3.html', '4.js']);
quicklink.prefetch(['2.html', '3.html', '4.js'], true);
```
+### Programmatically `prerender()` URLs
+
+If you would prefer to provide a static list of URLs to be prerendered, instead of detecting those in-viewport, customizing URLs is supported.
+
+```js
+// Single URL
+quicklink.prerender('2.html');
+
+// Multiple URLs
+quicklink.prerender(['2.html', '3.html', '4.js']);
+```
+
+
### 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.
@@ -404,6 +439,7 @@ After installing `quicklink` as a dependency, you can use it as follows:
* [Using Quicklink in a multi-page site](/~https://github.com/GoogleChromeLabs/quicklink/tree/master/demos/news)
* [Using Quicklink with Service Workers (via Workbox)](/~https://github.com/GoogleChromeLabs/quicklink/tree/master/demos/news-workbox)
* [Using Quicklink to prefetch API calls instead of `href` attribute](/~https://github.com/GoogleChromeLabs/quicklink/tree/master/demos/hrefFn)
+* [Using Quicklink to prerender a specific page](https://uskay-prerender2.glitch.me/next.html)
### Research
diff --git a/src/index.mjs b/src/index.mjs
index e739dd10..a60b20fb 100644
--- a/src/index.mjs
+++ b/src/index.mjs
@@ -16,11 +16,17 @@
import throttle from 'throttles';
import {priority, supported} from './prefetch.mjs';
import requestIdleCallback from './request-idle-callback.mjs';
+import {isSameOrigin, addSpeculationRules, hasSpecRulesSupport, isSpecRulesExists} from './prerender.mjs';
// Cache of URLs we've prefetched
// Its `size` is compared against `opts.limit` value.
const toPrefetch = new Set();
+// Cache of URLs we've prerendered
+const toPrerender = new Set();
+// global var to keep prerenderAndPrefer option
+let shouldPrerenderAndPrefetch = false;
+
/**
* Determine if the anchor tag should be prefetched.
* A filter can be a RegExp, Function, or Array of both.
@@ -36,6 +42,25 @@ function isIgnored(node, filter) {
: (filter.test || filter).call(filter, node.href, node);
}
+/**
+ * Checks network conditions
+ * @param {NetworkInformation} conn The connection information to be checked
+ * @return {Boolean|Object} Error Object if the constrainsts are met or boolean otherwise
+ */
+function checkConnection (conn) {
+ if (conn) {
+ // Don't pre* if using 2G or if Save-Data is enabled.
+ if (conn.saveData) {
+ return new Error('Save-Data is enabled');
+ }
+ if (/2g/.test(conn.effectiveType)) {
+ return new Error('network conditions are poor');
+ }
+ }
+
+ return true;
+}
+
/**
* Prefetch an array of URLs if the user's effective
* connection type and data-saver preferences suggests
@@ -56,6 +81,8 @@ function isIgnored(node, filter) {
* @param {Function} [options.onError] - Error handler for failed `prefetch` requests
* @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 {Boolean} [options.prerenderAndPrefetch] - Option to use both prerendering and prefetching
* @return {Function}
*/
export function listen(options) {
@@ -73,7 +100,12 @@ export function listen(options) {
const timeoutFn = options.timeoutFn || requestIdleCallback;
const hrefFn = typeof options.hrefFn === 'function' && options.hrefFn;
-
+
+ const shouldOnlyPrerender = options.prerender || false;
+ shouldPrerenderAndPrefetch = options.prerenderAndPrefetch || false;
+
+ const prerenderLimit = 1;
+
const setTimeoutIfDelay = (callback, delay) => {
if (!delay) {
callback();
@@ -96,14 +128,32 @@ export function listen(options) {
if (hrefsInViewport.indexOf(entry.href) === -1) return;
observer.unobserve(entry);
- // Do not prefetch if will match/exceed limit
- if (toPrefetch.size < limit) {
+
+ // prerender, if..
+ // either it's the prerender + prefetch mode or it's prerender *only* mode
+ // && no link has been prerendered before (no spec rules defined)
+ if (shouldPrerenderAndPrefetch || shouldOnlyPrerender) {
+ if (toPrerender.size < prerenderLimit) {
+ prerender(hrefFn ? hrefFn(entry) : entry.href).catch(err => {
+ if (options.onError) {
+ options.onError(err);
+ }else {
+ throw err;
+ }
+ });
+ return;
+ }
+ }
+
+ // 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).then(isDone).catch(err => {
isDone(); if (options.onError) options.onError(err);
});
});
}
+
}, delay);
}
// On exit
@@ -141,7 +191,6 @@ export function listen(options) {
};
}
-
/**
* Prefetch a given URL with an optional preferred fetch priority
* @param {String} url - the URL to fetch
@@ -150,14 +199,13 @@ export function listen(options) {
* @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) {
- return Promise.reject(new Error('Cannot prefetch, Save-Data is enabled'));
- }
- if (/2g/.test(conn.effectiveType)) {
- return Promise.reject(new Error('Cannot prefetch, network conditions are poor'));
- }
+ let chkConn = checkConnection(conn = navigator.connection);
+ if (chkConn instanceof Error) {
+ return Promise.reject(new Error('Cannot prefetch, '+chkConn.message));
+ }
+
+ if(toPrerender.size > 0 && !shouldPrerenderAndPrefetch) {
+ console.warn('[Warning] You are using both prefetching and prerendering on the same document');
}
// Dev must supply own catch()
@@ -175,3 +223,45 @@ export function prefetch(url, isPriority, conn) {
})
);
}
+
+/**
+* Prerender a given URL
+* @param {String} url - the URL to fetch
+* @param {Object} [conn] - navigator.connection (internal)
+* @return {Object} a Promise
+*/
+export function prerender(urls, conn) {
+ let chkConn = checkConnection(conn = navigator.connection);
+ if (chkConn instanceof Error) {
+ return Promise.reject(new Error('Cannot prerender, '+chkConn.message));
+ }
+
+ // prerendering preconditions:
+ // 1) whether UA supports spec rules.. If not, fallback to prefetch
+ if (!hasSpecRulesSupport()) {
+ prefetch (urls);
+ return Promise.reject(new Error('This browser does not support the speculation rules API. Falling back to prefetch.'));
+ }
+
+ // 2) whether spec rules is already defined (and with this we also covered when we have created spec rules before)
+ if (isSpecRulesExists()) {
+ return Promise.reject(new Error('Speculation Rules is already defined and cannot be altered.'));
+ }
+
+ // 3) whether it's a same origin url,
+ for (const url of [].concat(urls)) {
+ if (!isSameOrigin(url)) {
+ return Promise.reject(new Error('Only same origin URLs are allowed: ' + url));
+ }
+
+ toPrerender.add(url);
+ }
+
+ // check if both prerender and prefetch exists.. throw a warning but still proceed
+ if (toPrefetch.size > 0 && !shouldPrerenderAndPrefetch) {
+ console.warn('[Warning] You are using both prefetching and prerendering on the same document');
+ }
+
+ let addSpecRules = addSpeculationRules(toPrerender);
+ return (addSpecRules === true) ? Promise.resolve() : Promise.reject(addSpecRules);
+}
diff --git a/src/prerender.mjs b/src/prerender.mjs
new file mode 100644
index 00000000..93ccabf3
--- /dev/null
+++ b/src/prerender.mjs
@@ -0,0 +1,60 @@
+/**
+ * Portions copyright 2018 Google Inc.
+ * Inspired by Gatsby's prefetching logic, with those portions
+ * remaining MIT. Additions include support for Fetch API,
+ * XHR switching, SaveData and Effective Connection Type checking.
+ *
+ * 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.
+**/
+/**
+ * Checks if the given string is a same origin url
+ * @param {string} str - the URL to check
+ * @return {Boolean} true for same origin url
+ */
+export function isSameOrigin(str) {
+ return window.location.origin === (new URL(str, window.location.href)).origin;
+}
+
+/**
+ * Add a given set of urls to the speculation rules
+ * @param {Set} toPrerender - the URLs to add to speculation rules
+ * @return {Boolean|Object} boolean or Error Object
+ */
+export function addSpeculationRules(urlsToPrerender) {
+ let specScript = document.createElement('script');
+ specScript.type = 'speculationrules';
+ specScript.text = '{"prerender":[{"source": "list","urls": ["'+Array.from(urlsToPrerender).join('","')+'"]}]}';
+ try {
+ document.head.appendChild(specScript);
+ }catch(e) {
+ return e;
+ }
+
+ return true;
+}
+
+/**
+ * Check whether UA supports Speculation Rules API
+ * @return {Boolean} whether UA has support for Speculation Rules API
+ */
+export function hasSpecRulesSupport() {
+ return HTMLScriptElement.supports('speculationrules');
+}
+
+/**
+ * Check whether Spec Rules is already defined in the document
+ * @return {Boolean} whether Spec Rules exists/already defined
+ */
+export function isSpecRulesExists() {
+ return document.querySelector('script[type="speculationrules"]');
+}
diff --git a/test/test-prerender-andPrefetch.html b/test/test-prerender-andPrefetch.html
new file mode 100644
index 00000000..80f7e00f
--- /dev/null
+++ b/test/test-prerender-andPrefetch.html
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+ Prefetch: Basic Usage
+
+
+
+
+
+
+ Link 1
+ Link 2
+ Link 3
+
+ CSS
+
+ Link 4
+
+
+
+
+
diff --git a/test/test-prerender-only.html b/test/test-prerender-only.html
new file mode 100644
index 00000000..21593a5b
--- /dev/null
+++ b/test/test-prerender-only.html
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+ Prefetch: Basic Usage
+
+
+
+
+
+
+ Link 1
+ Link 2
+ Link 3
+
+ CSS
+
+ Link 4
+
+
+
+
+
diff --git a/test/test-prerender-wrapper-multiple.html b/test/test-prerender-wrapper-multiple.html
new file mode 100644
index 00000000..f7dcf849
--- /dev/null
+++ b/test/test-prerender-wrapper-multiple.html
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+ Prefetch: Basic Usage
+
+
+
+
+
+
+ Link 1
+ Link 2
+ Link 3
+
+ CSS
+
+ Link 4
+
+
+
+
+
diff --git a/test/test-prerender-wrapper-single.html b/test/test-prerender-wrapper-single.html
new file mode 100644
index 00000000..d429be36
--- /dev/null
+++ b/test/test-prerender-wrapper-single.html
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+ Prefetch: Basic Usage
+
+
+
+
+
+
+ Link 1
+ Link 2
+ Link 3
+
+ CSS
+
+ Link 4
+
+
+
+
+