From 14c86bef1df0c9a390ea465d6454f84a2b223e2a Mon Sep 17 00:00:00 2001
From: Arunoda Susiripala
Date: Wed, 15 Feb 2017 14:22:22 +0530
Subject: [PATCH] Introduce a simple prefetching solution (#957)
* Implement a very simple prefetching solution.
* Remove next-prefetcher.
* Require 'whatwg-fetch' only in the client.
* Use xhr in the code.
* Use a simple fetching solution.
* Fix 404 and xhr status issue.
* Move the prefetching implementation to next/router.
* Add deprecated warnning for next/prefetch
* Run only 2 parellel prefetching request at a time.
* Change xhr to jsonPageRes.
* Improve the prefetching logic.
* Add unit tests covering the Router.prefetch()
* Update examples to use the new syntax.
* Update docs.
* Use execOnce() to manage warn printing.
* Remove prefetcher building from the flyfile.js
Because, we no longer use it.
---
README.md | 27 ++-
client/next-prefetcher.js | 107 -----------
.../with-prefetching/components/Header.js | 16 +-
flyfile.js | 15 +-
lib/link.js | 11 +-
lib/prefetch.js | 159 +++--------------
lib/router/index.js | 2 +-
lib/router/router.js | 166 ++++++++++--------
lib/utils.js | 28 +++
package.json | 1 +
test/unit/router.test.js | 87 +++++++++
webpack.config.js | 35 ----
yarn.lock | 2 +-
13 files changed, 264 insertions(+), 392 deletions(-)
delete mode 100644 client/next-prefetcher.js
create mode 100644 test/unit/router.test.js
delete mode 100644 webpack.config.js
diff --git a/README.md b/README.md
index 274a275d..87507cc6 100644
--- a/README.md
+++ b/README.md
@@ -344,40 +344,37 @@ Router.onRouteChangeError = (err, url) => {
-Next.js exposes a module that configures a `ServiceWorker` automatically to prefetch pages: `next/prefetch`.
+Next.js has an API which allows you to prefetch pages.
Since Next.js server-renders your pages, this allows all the future interaction paths of your app to be instant. Effectively Next.js gives you the great initial download performance of a _website_, with the ahead-of-time download capabilities of an _app_. [Read more](https://zeit.co/blog/next#anticipation-is-the-key-to-performance).
+> With prefetching Next.js only download JS code. When the page is getting rendered, you may need to wait for the data.
+
#### With `
{ /* Prefetch using the declarative API */ }
-
+
Home
-
+
Features
{ /* we imperatively prefetch on hover */ }
-
- { prefetch('/about'); console.log('prefetching /about!') }}>About
-
+
+
{ Router.prefetch('/about'); console.log('prefetching /about!') }}>About
+
-
+
Contact (NO-PREFETCHING )
diff --git a/flyfile.js b/flyfile.js
index 6de89a09..e254bd93 100644
--- a/flyfile.js
+++ b/flyfile.js
@@ -1,7 +1,6 @@
const webpack = require('webpack')
const notifier = require('node-notifier')
const childProcess = require('child_process')
-const webpackConfig = require('./webpack.config')
const isWindows = /^win/.test(process.platform)
export async function compile(fly) {
@@ -42,15 +41,7 @@ export async function copy(fly) {
}
export async function build(fly) {
- await fly.serial(['copy', 'compile', 'prefetcher'])
-}
-
-const compiler = webpack(webpackConfig)
-export async function prefetcher(fly) {
- compiler.run((err, stats) => {
- if (err) throw err
- notify('Built release prefetcher')
- })
+ await fly.serial(['copy', 'compile'])
}
export async function bench(fly) {
@@ -70,8 +61,8 @@ export default async function (fly) {
await fly.watch('bin/*', 'bin')
await fly.watch('pages/**/*.js', 'copy')
await fly.watch('server/**/*.js', 'server')
- await fly.watch('client/**/*.js', ['client', 'prefetcher'])
- await fly.watch('lib/**/*.js', ['lib', 'prefetcher'])
+ await fly.watch('client/**/*.js', ['client'])
+ await fly.watch('lib/**/*.js', ['lib'])
}
export async function release(fly) {
diff --git a/lib/link.js b/lib/link.js
index 873fe936..d046d2c3 100644
--- a/lib/link.js
+++ b/lib/link.js
@@ -62,7 +62,7 @@ export default class Link extends Component {
}
render () {
- let { children } = this.props
+ let { children, prefetch } = this.props
// Deprecated. Warning shown by propType check. If the childen provided is a string (
example) we wrap it in an
tag
if (typeof children === 'string') {
children = {children}
@@ -79,11 +79,18 @@ export default class Link extends Component {
props.href = this.props.as || this.props.href
}
+ // Prefetch the JSON page if asked (only in the client)
+ if (prefetch) {
+ if (typeof window !== 'undefined') {
+ Router.prefetch(props.href)
+ }
+ }
+
return React.cloneElement(child, props)
}
}
-export function isLocal (href) {
+function isLocal (href) {
const origin = getLocationOrigin()
return !/^(https?:)?\/\//.test(href) ||
origin === href.substr(0, origin.length)
diff --git a/lib/prefetch.js b/lib/prefetch.js
index eef96335..0970ecfd 100644
--- a/lib/prefetch.js
+++ b/lib/prefetch.js
@@ -1,150 +1,33 @@
-/* global __NEXT_DATA__ */
-
import React from 'react'
-import Link, { isLocal } from './link'
-import { parse as urlParse } from 'url'
+import Link from './link'
+import Router from './router'
+import { warn, execOnce } from './utils'
-class Messenger {
- constructor () {
- this.id = 0
- this.callacks = {}
- this.serviceWorkerReadyCallbacks = []
- this.serviceWorkerState = null
+const warnImperativePrefetch = execOnce(() => {
+ const message = '> You are using deprecated "next/prefetch". It will be removed with Next.js 2.0.\n' +
+ '> Use "Router.prefetch(href)" instead.'
+ warn(message)
+})
- navigator.serviceWorker.addEventListener('message', ({ data }) => {
- if (data.action !== 'REPLY') return
- if (this.callacks[data.replyFor]) {
- this.callacks[data.replyFor](data)
- }
- })
+const wantLinkPrefetch = execOnce(() => {
+ const message = '> You are using deprecated "next/prefetch". It will be removed with Next.js 2.0.\n' +
+ '> Use "
" instead.'
+ warn(message)
+})
- // Reset the cache always.
- // Sometimes, there's an already running service worker with cached requests.
- // If the app doesn't use any prefetch calls, `ensureInitialized` won't get
- // called and cleanup resources.
- // So, that's why we do this.
- this._resetCache()
- }
-
- send (payload) {
- return new Promise((resolve, reject) => {
- if (this.serviceWorkerState === 'REGISTERED') {
- this._send(payload, handleCallback)
- } else {
- this.serviceWorkerReadyCallbacks.push(() => {
- this._send(payload, handleCallback)
- })
- }
-
- function handleCallback (err) {
- if (err) return reject(err)
- return resolve()
- }
- })
- }
-
- _send (payload, cb = () => {}) {
- const id = this.id ++
- const newPayload = { ...payload, id }
-
- this.callacks[id] = (data) => {
- if (data.error) {
- cb(data.error)
- } else {
- cb(null, data.result)
- }
-
- delete this.callacks[id]
- }
-
- navigator.serviceWorker.controller.postMessage(newPayload)
- }
-
- _resetCache (cb) {
- const reset = () => {
- this._send({ action: 'RESET' }, cb)
- }
-
- if (navigator.serviceWorker.controller) {
- reset()
- } else {
- navigator.serviceWorker.oncontrollerchange = reset
- }
- }
-
- ensureInitialized () {
- if (this.serviceWorkerState) {
- return
- }
-
- this.serviceWorkerState = 'REGISTERING'
- navigator.serviceWorker.register('/_next-prefetcher.js')
-
- // Reset the cache after registered
- // We don't need to have any old caches since service workers lives beyond
- // life time of the webpage.
- // With this prefetching won't work 100% if multiple pages of the same app
- // loads in the same browser in same time.
- // Basically, cache will only have prefetched resourses for the last loaded
- // page of a given app.
- // We could mitigate this, when we add a hash to a every file we fetch.
- this._resetCache((err) => {
- if (err) throw err
- this.serviceWorkerState = 'REGISTERED'
- this.serviceWorkerReadyCallbacks.forEach(cb => cb())
- this.serviceWorkerReadyCallbacks = []
- })
- }
-}
-
-function hasServiceWorkerSupport () {
- return (typeof navigator !== 'undefined' && navigator.serviceWorker)
-}
-
-const PREFETCHED_URLS = {}
-let messenger
-
-if (hasServiceWorkerSupport()) {
- messenger = new Messenger()
-}
-
-function getPrefetchUrl (href) {
- let { pathname } = urlParse(href)
- const url = `/_next/${__NEXT_DATA__.buildId}/pages${pathname}`
-
- return url
-}
-
-export async function prefetch (href) {
- if (!hasServiceWorkerSupport()) return
- if (!isLocal(href)) return
-
- // Register the service worker if it's not.
- messenger.ensureInitialized()
-
- const url = getPrefetchUrl(href)
- if (!PREFETCHED_URLS[url]) {
- PREFETCHED_URLS[url] = messenger.send({ action: 'ADD_URL', url: url })
- }
-
- return PREFETCHED_URLS[url]
-}
-
-export async function reloadIfPrefetched (href) {
- const url = getPrefetchUrl(href)
- if (!PREFETCHED_URLS[url]) return
-
- delete PREFETCHED_URLS[url]
- await prefetch(href)
+export function prefetch (href) {
+ warnImperativePrefetch()
+ return Router.prefetch(href)
}
export default class LinkPrefetch extends React.Component {
render () {
- const { href } = this.props
- if (this.props.prefetch !== false) {
- prefetch(href)
+ wantLinkPrefetch()
+ const props = {
+ ...this.props,
+ prefetch: this.props.prefetch === false ? this.props.prefetch : true
}
- return (
)
+ return (
)
}
}
diff --git a/lib/router/index.js b/lib/router/index.js
index 21ac3ca6..1df0ef8a 100644
--- a/lib/router/index.js
+++ b/lib/router/index.js
@@ -13,7 +13,7 @@ const SingletonRouter = {
// Create public properties and methods of the router in the SingletonRouter
const propertyFields = ['components', 'pathname', 'route', 'query']
-const coreMethodFields = ['push', 'replace', 'reload', 'back']
+const coreMethodFields = ['push', 'replace', 'reload', 'back', 'prefetch']
const routerEvents = ['routeChangeStart', 'routeChangeComplete', 'routeChangeError']
propertyFields.forEach((field) => {
diff --git a/lib/router/router.js b/lib/router/router.js
index dc1cd919..8df8ffe1 100644
--- a/lib/router/router.js
+++ b/lib/router/router.js
@@ -1,11 +1,15 @@
-/* global __NEXT_DATA__ */
+/* global __NEXT_DATA__, fetch */
import { parse, format } from 'url'
import evalScript from '../eval-script'
import shallowEquals from '../shallow-equals'
import { EventEmitter } from 'events'
-import { reloadIfPrefetched } from '../prefetch'
-import { loadGetInitialProps, getLocationOrigin } from '../utils'
+import { loadGetInitialProps, LockManager, getLocationOrigin } from '../utils'
+
+// Add "fetch" polyfill for older browsers
+if (typeof window !== 'undefined') {
+ require('whatwg-fetch')
+}
export default class Router extends EventEmitter {
constructor (pathname, query, { Component, ErrorComponent, err } = {}) {
@@ -15,6 +19,10 @@ export default class Router extends EventEmitter {
// set up the component cache (by route keys)
this.components = { [this.route]: { Component, err } }
+ // contain a map of response of prefetched routes
+ this.prefetchedRoutes = {}
+ this.prefetchingLockManager = new LockManager(2)
+ this.prefetchingRoutes = {}
this.ErrorComponent = ErrorComponent
this.pathname = pathname
@@ -96,7 +104,8 @@ export default class Router extends EventEmitter {
async reload (route) {
delete this.components[route]
- await reloadIfPrefetched(route)
+ delete this.prefetchedRoutes[route]
+ this.prefetchingRoutes[route] = 'IGNORE'
if (route !== this.route) return
@@ -184,8 +193,8 @@ export default class Router extends EventEmitter {
const routeInfo = {}
try {
- const { Component, err, xhr } = routeInfo.data = await this.fetchComponent(route)
- const ctx = { err, xhr, pathname, query }
+ const { Component, err, jsonPageRes } = routeInfo.data = await this.fetchComponent(route)
+ const ctx = { err, pathname, query, jsonPageRes }
routeInfo.props = await this.getInitialProps(Component, ctx)
} catch (err) {
if (err.cancelled) {
@@ -214,35 +223,74 @@ export default class Router extends EventEmitter {
return this.pathname !== pathname || !shallowEquals(query, this.query)
}
+ async prefetch (url) {
+ const { pathname } = parse(url)
+ const route = toRoute(pathname)
+
+ const done = await this.prefetchingLockManager.get()
+ // It's possible for some other "prefetch" process
+ // to start prefetching the same route
+ // So, we should not fetch it again
+ if (this.prefetchingRoutes[route]) return done()
+
+ // It's possible that, this is already prefetched.
+ // So, we should not fetch it again
+ if (this.prefetchedRoutes[route]) return done()
+
+ // Mark as we are prefetching the route
+ this.prefetchingRoutes[route] = true
+
+ const complete = () => {
+ delete this.prefetchingRoutes[route]
+ done()
+ }
+
+ try {
+ const res = await this.fetchUrl(route)
+ // Router.relaod() process may ask us to ignore the current prefetching route
+ // In that case, we need to discard it
+ if (this.prefetchingRoutes[route] !== 'IGNORE') {
+ this.prefetchedRoutes[route] = res
+ }
+ complete()
+ } catch (ex) {
+ complete()
+ throw ex
+ }
+ }
+
async fetchComponent (route) {
let data = this.components[route]
- if (!data) {
- let cancel
+ if (data) return data
- data = await new Promise((resolve, reject) => {
- this.componentLoadCancel = cancel = () => {
- if (xhr.abort) {
- xhr.abort()
- const error = new Error('Fetching componenet cancelled')
- error.cancelled = true
- reject(error)
- }
- }
-
- const url = `/_next/${__NEXT_DATA__.buildId}/pages${route}`
- const xhr = loadComponent(url, (err, data) => {
- if (err) return reject(err)
- resolve({ ...data, xhr })
- })
- })
-
- if (cancel === this.componentLoadCancel) {
- this.componentLoadCancel = null
- }
-
- this.components[route] = data
+ let cancelled = false
+ const cancel = this.componentLoadCancel = function () {
+ cancelled = true
}
- return data
+
+ let jsonPageRes = this.prefetchedRoutes[route]
+ if (!jsonPageRes) {
+ jsonPageRes = await this.fetchUrl(route)
+ }
+
+ const jsonData = await jsonPageRes.json()
+ const newData = {
+ ...loadComponent(jsonData),
+ jsonPageRes
+ }
+
+ if (cancelled) {
+ const error = new Error(`Abort fetching component for route: "${route}"`)
+ error.cancelled = true
+ throw error
+ }
+
+ if (cancel === this.componentLoadCancel) {
+ this.componentLoadCancel = null
+ }
+
+ this.components[route] = newData
+ return newData
}
async getInitialProps (Component, ctx) {
@@ -265,6 +313,16 @@ export default class Router extends EventEmitter {
return props
}
+ async fetchUrl (route) {
+ const url = `/_next/${__NEXT_DATA__.buildId}/pages${route}`
+ const res = await fetch(url, {
+ method: 'GET',
+ headers: { 'Accept': 'application/json' }
+ })
+
+ return res
+ }
+
abortComponentLoad () {
if (this.componentLoadCancel) {
this.componentLoadCancel()
@@ -292,47 +350,9 @@ function toRoute (path) {
return path.replace(/\/$/, '') || '/'
}
-function loadComponent (url, fn) {
- return loadJSON(url, (err, data) => {
- if (err) return fn(err)
+function loadComponent (jsonData) {
+ const module = evalScript(jsonData.component)
+ const Component = module.default || module
- let module
- try {
- module = evalScript(data.component)
- } catch (err) {
- return fn(err)
- }
-
- const Component = module.default || module
- fn(null, { Component, err: data.err })
- })
-}
-
-function loadJSON (url, fn) {
- const xhr = new window.XMLHttpRequest()
- xhr.onload = () => {
- let data
-
- try {
- data = JSON.parse(xhr.responseText)
- } catch (err) {
- fn(new Error('Failed to load JSON for ' + url))
- return
- }
-
- fn(null, data)
- }
- xhr.onerror = () => {
- fn(new Error('XHR failed. Status: ' + xhr.status))
- }
- xhr.onabort = () => {
- const err = new Error('XHR aborted')
- err.cancelled = true
- fn(err)
- }
- xhr.open('GET', url)
- xhr.setRequestHeader('Accept', 'application/json')
- xhr.send()
-
- return xhr
+ return { Component, err: jsonData.err }
}
diff --git a/lib/utils.js b/lib/utils.js
index d5ba6c5f..23a517f2 100644
--- a/lib/utils.js
+++ b/lib/utils.js
@@ -54,6 +54,34 @@ export async function loadGetInitialProps (Component, ctx) {
return props
}
+export class LockManager {
+ constructor (maxLocks) {
+ this.maxLocks = maxLocks
+ this.clients = []
+ this.runningLocks = 0
+ }
+
+ get () {
+ return new Promise((resolve) => {
+ this.clients.push(resolve)
+ this._giveLocksIfPossible()
+ })
+ }
+
+ _giveLocksIfPossible () {
+ if (this.runningLocks < this.maxLocks) {
+ const client = this.clients.shift()
+ if (!client) return
+
+ this.runningLocks ++
+ client(() => {
+ this.runningLocks --
+ this._giveLocksIfPossible()
+ })
+ }
+ }
+}
+
export function getLocationOrigin () {
const { protocol, hostname, port } = window.location
return `${protocol}//${hostname}${port ? ':' + port : ''}`
diff --git a/package.json b/package.json
index a18e6ee3..faf20e0c 100644
--- a/package.json
+++ b/package.json
@@ -85,6 +85,7 @@
"webpack": "2.2.1",
"webpack-dev-middleware": "1.10.0",
"webpack-hot-middleware": "2.16.1",
+ "whatwg-fetch": "^2.0.2",
"write-file-webpack-plugin": "3.4.2"
},
"devDependencies": {
diff --git a/test/unit/router.test.js b/test/unit/router.test.js
new file mode 100644
index 00000000..2b647aed
--- /dev/null
+++ b/test/unit/router.test.js
@@ -0,0 +1,87 @@
+/* global describe, it, expect */
+import Router from '../../dist/lib/router/router'
+
+describe('Router', () => {
+ describe('.prefetch()', () => {
+ it('should prefetch a given page', async () => {
+ const router = new Router('/', {})
+ const res = { aa: 'res' }
+ const route = 'routex'
+ router.fetchUrl = (r) => {
+ expect(r).toBe(route)
+ return Promise.resolve(res)
+ }
+ await router.prefetch(route)
+
+ expect(router.prefetchedRoutes[route]).toBe(res)
+ })
+
+ it('should stop if it\'s prefetched already', async () => {
+ const router = new Router('/', {})
+ const route = 'routex'
+ router.prefetchedRoutes[route] = { aa: 'res' }
+ router.fetchUrl = () => { throw new Error('Should not happen') }
+ await router.prefetch(route)
+ })
+
+ it('should stop if it\'s currently prefetching', async () => {
+ const router = new Router('/', {})
+ const route = 'routex'
+ router.prefetchingRoutes[route] = true
+ router.fetchUrl = () => { throw new Error('Should not happen') }
+ await router.prefetch(route)
+ })
+
+ it('should ignore the response if it asked to do', async () => {
+ const router = new Router('/', {})
+ const res = { aa: 'res' }
+ const route = 'routex'
+
+ let called = false
+ router.fetchUrl = () => {
+ called = true
+ router.prefetchingRoutes[route] = 'IGNORE'
+ return Promise.resolve(res)
+ }
+ await router.prefetch(route)
+
+ expect(router.prefetchedRoutes[route]).toBeUndefined()
+ expect(called).toBe(true)
+ })
+
+ it('should only run two jobs at a time', async () => {
+ const router = new Router('/', {})
+ let count = 0
+
+ router.fetchUrl = () => {
+ count++
+ return new Promise((resolve) => {})
+ }
+
+ router.prefetch('route1')
+ router.prefetch('route2')
+ router.prefetch('route3')
+ router.prefetch('route4')
+
+ await new Promise((resolve) => setTimeout(resolve, 50))
+
+ expect(count).toBe(2)
+ expect(Object.keys(router.prefetchingRoutes)).toEqual(['route1', 'route2'])
+ })
+
+ it('should run all the jobs', async () => {
+ const router = new Router('/', {})
+ const routes = ['route1', 'route2', 'route3', 'route4']
+
+ router.fetchUrl = () => Promise.resolve({ aa: 'res' })
+
+ await router.prefetch(routes[0])
+ await router.prefetch(routes[1])
+ await router.prefetch(routes[2])
+ await router.prefetch(routes[3])
+
+ expect(Object.keys(router.prefetchedRoutes)).toEqual(routes)
+ expect(Object.keys(router.prefetchingRoutes)).toEqual([])
+ })
+ })
+})
diff --git a/webpack.config.js b/webpack.config.js
deleted file mode 100644
index 750e272b..00000000
--- a/webpack.config.js
+++ /dev/null
@@ -1,35 +0,0 @@
-const resolve = require('path').resolve
-const webpack = require('webpack')
-
-module.exports = {
- entry: './client/next-prefetcher.js',
- output: {
- filename: 'next-prefetcher-bundle.js',
- path: resolve(__dirname, 'dist/client')
- },
- plugins: [
- new webpack.DefinePlugin({
- 'process.env': {
- NODE_ENV: JSON.stringify('production')
- }
- })
- ],
- module: {
- rules: [{
- test: /\.js$/,
- exclude: /node_modules/,
- loader: 'babel-loader',
- options: {
- babelrc: false,
- presets: [
- ['env', {
- targets: {
- // All browsers which supports service workers
- browsers: ['chrome 49', 'firefox 49', 'opera 41']
- }
- }]
- ]
- }
- }]
- }
-}
diff --git a/yarn.lock b/yarn.lock
index 1fe50086..8fc37c15 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4835,7 +4835,7 @@ whatwg-encoding@^1.0.1:
dependencies:
iconv-lite "0.4.13"
-whatwg-fetch@>=0.10.0:
+whatwg-fetch@>=0.10.0, whatwg-fetch@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-2.0.2.tgz#fe294d1d89e36c5be8b3195057f2e4bc74fc980e"