diff --git a/client/index.js b/client/index.js index 4d5c847a81468..073274eafd892 100644 --- a/client/index.js +++ b/client/index.js @@ -4,9 +4,9 @@ import mitt from 'mitt' import HeadManager from './head-manager' import { createRouter } from '../lib/router' import App from '../lib/app' -import evalScript from '../lib/eval-script' import { loadGetInitialProps, getURL } from '../lib/utils' import ErrorDebugComponent from '../lib/error-debug' +import PageLoader from '../lib/page-loader' // Polyfill Promise globally // This is needed because Webpack2's dynamic loading(common chunks) code @@ -19,31 +19,49 @@ if (!window.Promise) { const { __NEXT_DATA__: { - component, - errorComponent, props, err, pathname, - query + query, + buildId }, location } = window -const Component = evalScript(component).default -const ErrorComponent = evalScript(errorComponent).default -let lastAppProps - -export const router = createRouter(pathname, query, getURL(), { - Component, - ErrorComponent, - err +const pageLoader = new PageLoader(buildId) +window.__NEXT_LOADED_PAGES__.forEach(({ route, fn }) => { + pageLoader.registerPage(route, fn) }) +delete window.__NEXT_LOADED_PAGES__ + +window.__NEXT_REGISTER_PAGE = pageLoader.registerPage.bind(pageLoader) const headManager = new HeadManager() const appContainer = document.getElementById('__next') const errorContainer = document.getElementById('__next-error') -export default () => { +let lastAppProps +export let router +export let ErrorComponent +let Component + +export default async () => { + ErrorComponent = await pageLoader.loadPage('/_error') + + try { + Component = await pageLoader.loadPage(pathname) + } catch (err) { + console.error(`${err.message}\n${err.stack}`) + Component = ErrorComponent + } + + router = createRouter(pathname, query, getURL(), { + pageLoader, + Component, + ErrorComponent, + err + }) + const emitter = mitt() router.subscribe(({ Component, props, hash, err }) => { @@ -57,7 +75,10 @@ export default () => { } export async function render (props) { - if (props.err) { + // There are some errors we should ignore. + // Next.js rendering logic knows how to handle them. + // These are specially 404 errors + if (props.err && !props.err.ignore) { await renderError(props.err) return } @@ -103,7 +124,7 @@ async function doRender ({ Component, props, hash, err, emitter }) { } if (emitter) { - emitter.emit('before-reactdom-render', { Component }) + emitter.emit('before-reactdom-render', { Component, ErrorComponent }) } Component = Component || lastAppProps.Component @@ -118,6 +139,6 @@ async function doRender ({ Component, props, hash, err, emitter }) { ReactDOM.render(createElement(App, appProps), appContainer) if (emitter) { - emitter.emit('after-reactdom-render', { Component }) + emitter.emit('after-reactdom-render', { Component, ErrorComponent }) } } diff --git a/client/next-dev.js b/client/next-dev.js index 071ec03df4e07..b25f41695fb49 100644 --- a/client/next-dev.js +++ b/client/next-dev.js @@ -1,14 +1,40 @@ -import evalScript from '../lib/eval-script' +import 'react-hot-loader/patch' import ReactReconciler from 'react-dom/lib/ReactReconciler' - -const { __NEXT_DATA__: { errorComponent } } = window -const ErrorComponent = evalScript(errorComponent).default - -require('react-hot-loader/patch') +import initOnDemandEntries from './on-demand-entries-client' +import initWebpackHMR from './webpack-hot-middleware-client' const next = window.next = require('./') -const emitter = next.default() +next.default() + .then((emitter) => { + initOnDemandEntries() + initWebpackHMR() + + let lastScroll + + emitter.on('before-reactdom-render', ({ Component, ErrorComponent }) => { + // Remember scroll when ErrorComponent is being rendered to later restore it + if (!lastScroll && Component === ErrorComponent) { + const { pageXOffset, pageYOffset } = window + lastScroll = { + x: pageXOffset, + y: pageYOffset + } + } + }) + + emitter.on('after-reactdom-render', ({ Component, ErrorComponent }) => { + if (lastScroll && Component !== ErrorComponent) { + // Restore scroll after ErrorComponent was replaced with a page component by HMR + const { x, y } = lastScroll + window.scroll(x, y) + lastScroll = null + } + }) + }) + .catch((err) => { + console.error(`${err.message}\n${err.stack}`) + }) // This is a patch to catch most of the errors throw inside React components. const originalMountComponent = ReactReconciler.mountComponent @@ -21,25 +47,3 @@ ReactReconciler.mountComponent = function (...args) { throw err } } - -let lastScroll - -emitter.on('before-reactdom-render', ({ Component }) => { - // Remember scroll when ErrorComponent is being rendered to later restore it - if (!lastScroll && Component === ErrorComponent) { - const { pageXOffset, pageYOffset } = window - lastScroll = { - x: pageXOffset, - y: pageYOffset - } - } -}) - -emitter.on('after-reactdom-render', ({ Component }) => { - if (lastScroll && Component !== ErrorComponent) { - // Restore scroll after ErrorComponent was replaced with a page component by HMR - const { x, y } = lastScroll - window.scroll(x, y) - lastScroll = null - } -}) diff --git a/client/next.js b/client/next.js index 5400a409161c3..25654b7eadcb6 100644 --- a/client/next.js +++ b/client/next.js @@ -1,3 +1,6 @@ import next from './' next() + .catch((err) => { + console.error(`${err.message}\n${err.stack}`) + }) diff --git a/client/on-demand-entries-client.js b/client/on-demand-entries-client.js index 7fcd8c186a190..3bd379dcf19a6 100644 --- a/client/on-demand-entries-client.js +++ b/client/on-demand-entries-client.js @@ -3,31 +3,33 @@ import Router from '../lib/router' import fetch from 'unfetch' -Router.ready(() => { - Router.router.events.on('routeChangeComplete', ping) -}) +export default () => { + Router.ready(() => { + Router.router.events.on('routeChangeComplete', ping) + }) -async function ping () { - try { - const url = `/_next/on-demand-entries-ping?page=${Router.pathname}` - const res = await fetch(url) - const payload = await res.json() - if (payload.invalid) { - location.reload() + async function ping () { + try { + const url = `/_next/on-demand-entries-ping?page=${Router.pathname}` + const res = await fetch(url) + const payload = await res.json() + if (payload.invalid) { + location.reload() + } + } catch (err) { + console.error(`Error with on-demand-entries-ping: ${err.message}`) } - } catch (err) { - console.error(`Error with on-demand-entries-ping: ${err.message}`) } -} -async function runPinger () { - while (true) { - await new Promise((resolve) => setTimeout(resolve, 5000)) - await ping() + async function runPinger () { + while (true) { + await new Promise((resolve) => setTimeout(resolve, 5000)) + await ping() + } } -} -runPinger() - .catch((err) => { - console.error(err) - }) + runPinger() + .catch((err) => { + console.error(err) + }) +} diff --git a/client/webpack-hot-middleware-client.js b/client/webpack-hot-middleware-client.js index 7dfc49492c12b..38b55df6cdaa9 100644 --- a/client/webpack-hot-middleware-client.js +++ b/client/webpack-hot-middleware-client.js @@ -1,48 +1,50 @@ import webpackHotMiddlewareClient from 'webpack-hot-middleware/client?overlay=false&reload=true' import Router from '../lib/router' -const handlers = { - reload (route) { - if (route === '/_error') { - for (const r of Object.keys(Router.components)) { - const { err } = Router.components[r] - if (err) { - // reload all error routes - // which are expected to be errors of '/_error' routes - Router.reload(r) +export default () => { + const handlers = { + reload (route) { + if (route === '/_error') { + for (const r of Object.keys(Router.components)) { + const { err } = Router.components[r] + if (err) { + // reload all error routes + // which are expected to be errors of '/_error' routes + Router.reload(r) + } } + return } - return - } - if (route === '/_document') { - window.location.reload() - return - } + if (route === '/_document') { + window.location.reload() + return + } - Router.reload(route) - }, + Router.reload(route) + }, - change (route) { - if (route === '/_document') { - window.location.reload() - return - } + change (route) { + if (route === '/_document') { + window.location.reload() + return + } - const { err } = Router.components[route] || {} - if (err) { - // reload to recover from runtime errors - Router.reload(route) + const { err } = Router.components[route] || {} + if (err) { + // reload to recover from runtime errors + Router.reload(route) + } } } -} -webpackHotMiddlewareClient.subscribe((obj) => { - const fn = handlers[obj.action] - if (fn) { - const data = obj.data || [] - fn(...data) - } else { - throw new Error('Unexpected action ' + obj.action) - } -}) + webpackHotMiddlewareClient.subscribe((obj) => { + const fn = handlers[obj.action] + if (fn) { + const data = obj.data || [] + fn(...data) + } else { + throw new Error('Unexpected action ' + obj.action) + } + }) +} diff --git a/lib/error.js b/lib/error.js index 1b4890836d509..9d22e9e4f7a06 100644 --- a/lib/error.js +++ b/lib/error.js @@ -3,8 +3,8 @@ import HTTPStatus from 'http-status' import Head from './head' export default class Error extends React.Component { - static getInitialProps ({ res, jsonPageRes }) { - const statusCode = res ? res.statusCode : (jsonPageRes ? jsonPageRes.status : null) + static getInitialProps ({ res, err }) { + const statusCode = res ? res.statusCode : (err ? err.statusCode : null) return { statusCode } } diff --git a/lib/eval-script.js b/lib/eval-script.js deleted file mode 100644 index 031d53e46fd62..0000000000000 --- a/lib/eval-script.js +++ /dev/null @@ -1,18 +0,0 @@ -/** - * IMPORTANT: This module is compiled *without* `use strict` - * so that when we `eval` a dependency below, we don't enforce - * `use strict` implicitly. - * - * Otherwise, modules like `d3` get `eval`d and forced into - * `use strict` where they don't work (at least in current versions) - * - * To see the compilation details, look at `flyfile.js` and the - * usage of `babel-plugin-transform-remove-strict-mode`. - */ - -export default function evalScript (script) { - const module = { exports: {} } - - eval(script) // eslint-disable-line no-eval - return module.exports -} diff --git a/lib/page-loader.js b/lib/page-loader.js new file mode 100644 index 0000000000000..0dac7030650c9 --- /dev/null +++ b/lib/page-loader.js @@ -0,0 +1,100 @@ +/* global window, document */ +import mitt from 'mitt' + +const webpackModule = module + +export default class PageLoader { + constructor (buildId) { + this.buildId = buildId + this.pageCache = {} + this.pageLoadedHandlers = {} + this.registerEvents = mitt() + this.loadingRoutes = {} + } + + normalizeRoute (route) { + if (route[0] !== '/') { + throw new Error('Route name should start with a "/"') + } + + return route.replace(/index$/, '') + } + + loadPage (route) { + route = this.normalizeRoute(route) + + const cachedPage = this.pageCache[route] + if (cachedPage) { + return new Promise((resolve, reject) => { + if (cachedPage.error) return reject(cachedPage.error) + return resolve(cachedPage.page) + }) + } + + return new Promise((resolve, reject) => { + const fire = ({ error, page }) => { + this.registerEvents.off(route, fire) + + if (error) { + reject(error) + } else { + resolve(page) + } + } + + this.registerEvents.on(route, fire) + + // Load the script if not asked to load yet. + if (!this.loadingRoutes[route]) { + this.loadScript(route) + this.loadingRoutes[route] = true + } + }) + } + + loadScript (route) { + route = this.normalizeRoute(route) + + const script = document.createElement('script') + const url = `/_next/${encodeURIComponent(this.buildId)}/page${route}` + script.src = url + script.type = 'text/javascript' + script.onerror = () => { + const error = new Error(`Error when loading route: ${route}`) + this.registerEvents.emit(route, { error }) + } + + document.body.appendChild(script) + } + + // This method if called by the route code. + registerPage (route, regFn) { + const register = () => { + const { error, page } = regFn() + this.pageCache[route] = { error, page } + this.registerEvents.emit(route, { error, page }) + } + + // Wait for webpack to became idle if it's not. + // More info: https://github.com/zeit/next.js/pull/1511 + if (webpackModule && webpackModule.hot && webpackModule.hot.status() !== 'idle') { + console.log(`Waiting webpack to became "idle" to initialize the page: "${route}"`) + + const check = (status) => { + if (status === 'idle') { + webpackModule.hot.removeStatusHandler(check) + register() + } + } + webpackModule.hot.status(check) + } else { + register() + } + } + + clearCache (route) { + route = this.normalizeRoute(route) + delete this.pageCache[route] + delete this.loadingRoutes[route] + } +} diff --git a/lib/router/router.js b/lib/router/router.js index ecf7ac7bb3977..5272030e3e79c 100644 --- a/lib/router/router.js +++ b/lib/router/router.js @@ -1,28 +1,28 @@ import { parse, format } from 'url' import mitt from 'mitt' -import fetch from 'unfetch' -import evalScript from '../eval-script' import shallowEquals from '../shallow-equals' import PQueue from '../p-queue' import { loadGetInitialProps, getURL } from '../utils' import { _notifyBuildIdMismatch } from './' -const webpackModule = module - export default class Router { - constructor (pathname, query, as, { Component, ErrorComponent, err } = {}) { + constructor (pathname, query, as, { pageLoader, Component, ErrorComponent, err } = {}) { // represents the current component key this.route = toRoute(pathname) // set up the component cache (by route keys) - this.components = { [this.route]: { Component, err } } - - // contain a map of promise of fetch routes - this.fetchingRoutes = {} + this.components = {} + // We should not keep the cache, if there's an error + // Otherwise, this cause issues when when going back and + // come again to the errored page. + if (Component !== ErrorComponent) { + this.components[this.route] = { Component, err } + } // Handling Router Events this.events = mitt() + this.pageLoader = pageLoader this.prefetchQueue = new PQueue({ concurrency: 2 }) this.ErrorComponent = ErrorComponent this.pathname = pathname @@ -77,7 +77,7 @@ export default class Router { async reload (route) { delete this.components[route] - delete this.fetchingRoutes[route] + this.pageLoader.clearCache(route) if (route !== this.route) return @@ -186,11 +186,11 @@ export default class Router { try { routeInfo = this.components[route] if (!routeInfo) { - routeInfo = await this.fetchComponent(route, as) + routeInfo = { Component: await this.fetchComponent(route, as) } } - const { Component, err, jsonPageRes } = routeInfo - const ctx = { err, pathname, query, jsonPageRes } + const { Component } = routeInfo + const ctx = { pathname, query } routeInfo.props = await this.getInitialProps(Component, ctx) this.components[route] = routeInfo @@ -199,13 +199,27 @@ export default class Router { return { error: err } } + if (err.buildIdMismatched) { + // Now we need to reload the page or do the action asked by the user + _notifyBuildIdMismatch(as) + // We also need to cancel this current route change. + // We do it like this. + err.cancelled = true + return { error: err } + } + + if (err.statusCode === 404) { + // Indicate main error display logic to + // ignore rendering this error as a runtime error. + err.ignore = true + } + const Component = this.ErrorComponent routeInfo = { Component, err } const ctx = { err, pathname, query } routeInfo.props = await this.getInitialProps(Component, ctx) routeInfo.error = err - console.error(err) } return routeInfo @@ -268,28 +282,7 @@ export default class Router { cancelled = true } - const jsonPageRes = await this.fetchRoute(route) - let jsonData - // We can call .json() only once for a response. - // That's why we need to keep a copy of data if we already parsed it. - if (jsonPageRes.data) { - jsonData = jsonPageRes.data - } else { - jsonData = jsonPageRes.data = await jsonPageRes.json() - } - - if (jsonData.buildIdMismatch) { - _notifyBuildIdMismatch(as) - - const error = Error('Abort due to BUILD_ID mismatch') - error.cancelled = true - throw error - } - - const newData = { - ...await loadComponent(jsonData), - jsonPageRes - } + const Component = await this.fetchRoute(route) if (cancelled) { const error = new Error(`Abort fetching component for route: "${route}"`) @@ -301,7 +294,7 @@ export default class Router { this.componentLoadCancel = null } - return newData + return Component } async getInitialProps (Component, ctx) { @@ -324,24 +317,8 @@ export default class Router { return props } - fetchRoute (route) { - let promise = this.fetchingRoutes[route] - if (!promise) { - promise = this.fetchingRoutes[route] = this.doFetchRoute(route) - } - - return promise - } - - doFetchRoute (route) { - const { buildId } = window.__NEXT_DATA__ - const url = `/_next/${encodeURIComponent(buildId)}/pages${route}` - - return fetch(url, { - method: 'GET', - credentials: 'same-origin', - headers: { 'Accept': 'application/json' } - }) + async fetchRoute (route) { + return await this.pageLoader.loadPage(route) } abortComponentLoad (as) { @@ -365,22 +342,3 @@ export default class Router { function toRoute (path) { return path.replace(/\/$/, '') || '/' } - -async function loadComponent (jsonData) { - if (webpackModule && webpackModule.hot && webpackModule.hot.status() !== 'idle') { - await new Promise((resolve) => { - const check = (status) => { - if (status === 'idle') { - webpackModule.hot.removeStatusHandler(check) - resolve() - } - } - webpackModule.hot.status(check) - }) - } - - const module = evalScript(jsonData.component) - const Component = module.default || module - - return { Component, err: jsonData.err } -} diff --git a/server/build/plugins/json-pages-plugin.js b/server/build/plugins/json-pages-plugin.js deleted file mode 100644 index 06e34851b5f4d..0000000000000 --- a/server/build/plugins/json-pages-plugin.js +++ /dev/null @@ -1,24 +0,0 @@ -export default class JsonPagesPlugin { - apply (compiler) { - compiler.plugin('after-compile', (compilation, callback) => { - const pages = Object - .keys(compilation.assets) - .filter((filename) => /^bundles[/\\]pages.*\.js$/.test(filename)) - - pages.forEach((pageName) => { - const page = compilation.assets[pageName] - delete compilation.assets[pageName] - - const content = page.source() - const newContent = JSON.stringify({ component: content }) - - compilation.assets[`${pageName}on`] = { - source: () => newContent, - size: () => newContent.length - } - }) - - callback() - }) - } -} diff --git a/server/build/plugins/pages-plugin.js b/server/build/plugins/pages-plugin.js new file mode 100644 index 0000000000000..203fbe6013435 --- /dev/null +++ b/server/build/plugins/pages-plugin.js @@ -0,0 +1,33 @@ +export default class PagesPlugin { + apply (compiler) { + const isBundledPage = /^bundles[/\\]pages.*\.js$/ + const matchRouteName = /^bundles[/\\]pages[/\\](.*)\.js$/ + + compiler.plugin('after-compile', (compilation, callback) => { + const pages = Object + .keys(compilation.namedChunks) + .map(key => compilation.namedChunks[key]) + .filter(chunk => isBundledPage.test(chunk.name)) + + pages.forEach((chunk) => { + const page = compilation.assets[chunk.name] + const pageName = matchRouteName.exec(chunk.name)[1] + const routeName = `/${pageName.replace(/[/\\]?index$/, '')}` + + const content = page.source() + const newContent = ` + window.__NEXT_REGISTER_PAGE('${routeName}', function() { + var comp = ${content} + return { page: comp.default } + }) + ` + // Replace the exisiting chunk with the new content + compilation.assets[chunk.name] = { + source: () => newContent, + size: () => newContent.length + } + }) + callback() + }) + } +} diff --git a/server/build/webpack.js b/server/build/webpack.js index 5e9785edcd6ae..d32360c8ab455 100644 --- a/server/build/webpack.js +++ b/server/build/webpack.js @@ -6,7 +6,7 @@ import WriteFilePlugin from 'write-file-webpack-plugin' import FriendlyErrorsWebpackPlugin from 'friendly-errors-webpack-plugin' import CaseSensitivePathPlugin from 'case-sensitive-paths-webpack-plugin' import UnlinkFilePlugin from './plugins/unlink-file-plugin' -import JsonPagesPlugin from './plugins/json-pages-plugin' +import PagesPlugin from './plugins/pages-plugin' import CombineAssetsPlugin from './plugins/combine-assets-plugin' import getConfig from '../config' import * as babelCore from 'babel-core' @@ -116,7 +116,7 @@ export default async function createCompiler (dir, { dev = false, quiet = false, new webpack.DefinePlugin({ 'process.env.NODE_ENV': JSON.stringify(dev ? 'development' : 'production') }), - new JsonPagesPlugin(), + new PagesPlugin(), new CaseSensitivePathPlugin() ] diff --git a/server/document.js b/server/document.js index 6c698da29ef48..134f4e54c7c96 100644 --- a/server/document.js +++ b/server/document.js @@ -33,9 +33,45 @@ export class Head extends Component { _documentProps: PropTypes.any } + getChunkPreloadLink (filename) { + const { __NEXT_DATA__ } = this.context._documentProps + let { buildStats } = __NEXT_DATA__ + const hash = buildStats ? buildStats[filename].hash : '-' + + return ( + + ) + } + + getPreloadMainLinks () { + const { dev } = this.context._documentProps + if (dev) { + return [ + this.getChunkPreloadLink('manifest.js'), + this.getChunkPreloadLink('commons.js'), + this.getChunkPreloadLink('main.js') + ] + } + + // In the production mode, we have a single asset with all the JS content. + return [ + this.getChunkPreloadLink('app.js') + ] + } + render () { - const { head, styles } = this.context._documentProps + const { head, styles, __NEXT_DATA__ } = this.context._documentProps + const { pathname, buildId } = __NEXT_DATA__ + return + + + {this.getPreloadMainLinks()} {(head || []).map((h, i) => React.cloneElement(h, { key: i }))} {styles || null} {this.props.children} @@ -97,11 +133,22 @@ export class NextScript extends Component { render () { const { staticMarkup, __NEXT_DATA__ } = this.context._documentProps + const { pathname, buildId } = __NEXT_DATA__ return
{staticMarkup ? null :