diff --git a/README.md b/README.md index 2fb33133..b095f692 100644 --- a/README.md +++ b/README.md @@ -182,13 +182,15 @@ For the initial page load, `getInitialProps` will execute on the server only. `g ### Routing +#### With `` +

Examples - +

-#### With `` - Client-side transitions between routes can be enabled via a `` component. Consider these two pages: ```jsx @@ -225,6 +227,14 @@ The second `as` parameter for `push` and `replace` is an optional _decoration_ o #### Imperatively +

+ Examples + +

+ You can also do client-side page transitions using the `next/router` ```jsx @@ -247,6 +257,49 @@ The second `as` parameter for `push` and `replace` is an optional _decoration_ o _Note: in order to programmatically change the route without triggering navigation and component-fetching, use `props.url.push` and `props.url.replace` within a component_ +##### Router Events + +You can also listen to different events happening inside the Router. +Here's a list of supported events: + +- `routeChangeStart(url)` - Fires when a route starts to change +- `routeChangeComplete(url)` - Fires when a route changed completely +- `routeChangeError(err, url)` - Fires when there's an error when changing routes + +> Here `url` is the URL shown in the browser. If you call `Router.push(url, as)` (or similar), then the value of `url` will be `as`. + +Here's how to property listen to the router event `routeChangeStart`: + +```js +Router.onRouteChangeStart = (url) => { + console.log('App is changing to: ', url) +} +``` + +If you are no longer want to listen to that event, you can simply unset the event listener like this: + +```js +Router.onRouteChangeStart = null +``` + +##### Cancelled (Abort) Routes + +Sometimes, you might want to change a route before the current route gets completed. Current route may be downloading the page or running `getInitialProps`. +In that case, we abort the current route and process with the new route. + +If you need, you could capture those cancelled routes via `routeChangeError` router event. See: + +```js +Router.onRouteChangeError = (err, url) => { + if (err.cancelled) { + console.log(`Route to ${url} is cancelled!`) + return + } + + // Some other error +} +``` + ### Prefetching Pages

diff --git a/examples/with-loading/README.md b/examples/with-loading/README.md new file mode 100644 index 00000000..89f2ad39 --- /dev/null +++ b/examples/with-loading/README.md @@ -0,0 +1,35 @@ +# Example app with page loading indicator + +## How to use + +Download the example (or clone the repo)[https://github.com/zeit/next.js.git]: + +```bash +curl https://codeload.github.com/zeit/next.js/tar.gz/master | tar -xz --strip=2 next.js-master/examples/with-loading +cd with-loading +``` + +Install it and run: + +```bash +npm install +npm run dev +``` + +Deploy it to the cloud with [now](https://zeit.co/now) ([download](https://zeit.co/download)) + +```bash +now +``` + +## The idea behind the example + +Sometimes when switching between pages, Next.js needs to download pages(chunks) from the server before rendering the page. And it may also need to wait for the data. So while doing these tasks, browser might be non responsive. + +We can simply fix this issue by showing a loading indicator. That's what this examples shows. + +It features: + +* An app with two pages which uses a common [Header](./components/Header.js) component for navigation links. +* Using `next/router` to identify different router events +* Uses [nprogress](https://github.com/rstacruz/nprogress) as the loading indicator. diff --git a/examples/with-loading/components/Header.js b/examples/with-loading/components/Header.js new file mode 100644 index 00000000..a0a62f15 --- /dev/null +++ b/examples/with-loading/components/Header.js @@ -0,0 +1,30 @@ +import React from 'react' +import Head from 'next/head' +import Link from 'next/link' +import NProgress from 'nprogress' +import Router from 'next/router' + +Router.onRouteChangeStart = (url) => { + console.log(`Loading: ${url}`) + NProgress.start() +} +Router.onRouteChangeComplete = () => NProgress.done() +Router.onRouteChangeError = () => NProgress.done() + +const linkStyle = { + margin: '0 10px 0 0' +} + +export default () => ( +
+ + {/* Import CSS for nprogress */} + + + + Home + About + Forever + Non Existing Page +
+) diff --git a/examples/with-loading/package.json b/examples/with-loading/package.json new file mode 100644 index 00000000..0408a7b2 --- /dev/null +++ b/examples/with-loading/package.json @@ -0,0 +1,17 @@ +{ + "name": "with-loading", + "version": "1.0.0", + "description": "This example features:", + "main": "index.js", + "scripts": { + "dev": "next", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "next": "^2.0.0-beta", + "nprogress": "^0.2.0" + }, + "author": "", + "license": "ISC" +} diff --git a/examples/with-loading/pages/about.js b/examples/with-loading/pages/about.js new file mode 100644 index 00000000..5fd2ba79 --- /dev/null +++ b/examples/with-loading/pages/about.js @@ -0,0 +1,20 @@ +import React, { Component } from 'react' +import Header from '../components/Header' + +export default class About extends Component { + // Add some delay + static getInitialProps () { + return new Promise((resolve) => { + setTimeout(resolve, 500) + }) + } + + render () { + return ( +
+
+

This is about Next!

+
+ ) + } +} diff --git a/examples/with-loading/pages/forever.js b/examples/with-loading/pages/forever.js new file mode 100644 index 00000000..5e4075fb --- /dev/null +++ b/examples/with-loading/pages/forever.js @@ -0,0 +1,20 @@ +import React, { Component } from 'react' +import Header from '../components/Header' + +export default class Forever extends Component { + // Add some delay + static getInitialProps () { + return new Promise((resolve) => { + setTimeout(resolve, 3000) + }) + } + + render () { + return ( +
+
+

This page was rendered for a while!

+
+ ) + } +} diff --git a/examples/with-loading/pages/index.js b/examples/with-loading/pages/index.js new file mode 100644 index 00000000..e147bfe8 --- /dev/null +++ b/examples/with-loading/pages/index.js @@ -0,0 +1,9 @@ +import React from 'react' +import Header from '../components/Header' + +export default () => ( +
+
+

Hello Next!

+
+) diff --git a/examples/with-loading/static/nprogress.css b/examples/with-loading/static/nprogress.css new file mode 100644 index 00000000..12d8abdb --- /dev/null +++ b/examples/with-loading/static/nprogress.css @@ -0,0 +1,73 @@ +/* Make clicks pass-through */ +#nprogress { + pointer-events: none; +} + +#nprogress .bar { + background: #29d; + + position: fixed; + z-index: 1031; + top: 0; + left: 0; + + width: 100%; + height: 2px; +} + +/* Fancy blur effect */ +#nprogress .peg { + display: block; + position: absolute; + right: 0px; + width: 100px; + height: 100%; + box-shadow: 0 0 10px #29d, 0 0 5px #29d; + opacity: 1.0; + + -webkit-transform: rotate(3deg) translate(0px, -4px); + -ms-transform: rotate(3deg) translate(0px, -4px); + transform: rotate(3deg) translate(0px, -4px); +} + +/* Remove these to get rid of the spinner */ +#nprogress .spinner { + display: block; + position: fixed; + z-index: 1031; + top: 15px; + right: 15px; +} + +#nprogress .spinner-icon { + width: 18px; + height: 18px; + box-sizing: border-box; + + border: solid 2px transparent; + border-top-color: #29d; + border-left-color: #29d; + border-radius: 50%; + + -webkit-animation: nprogress-spinner 400ms linear infinite; + animation: nprogress-spinner 400ms linear infinite; +} + +.nprogress-custom-parent { + overflow: hidden; + position: relative; +} + +.nprogress-custom-parent #nprogress .spinner, +.nprogress-custom-parent #nprogress .bar { + position: absolute; +} + +@-webkit-keyframes nprogress-spinner { + 0% { -webkit-transform: rotate(0deg); } + 100% { -webkit-transform: rotate(360deg); } +} +@keyframes nprogress-spinner { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} diff --git a/lib/router/index.js b/lib/router/index.js index 20c9e7e6..21ac3ca6 100644 --- a/lib/router/index.js +++ b/lib/router/index.js @@ -1,13 +1,20 @@ import _Router from './router' -// holds the actual router instance -let router = null - -const SingletonRouter = {} +const SingletonRouter = { + router: null, // holds the actual router instance + readyCallbacks: [], + ready (cb) { + if (this.router) return cb() + if (typeof window !== 'undefined') { + this.readyCallbacks.push(cb) + } + } +} // Create public properties and methods of the router in the SingletonRouter const propertyFields = ['components', 'pathname', 'route', 'query'] -const methodFields = ['push', 'replace', 'reload', 'back'] +const coreMethodFields = ['push', 'replace', 'reload', 'back'] +const routerEvents = ['routeChangeStart', 'routeChangeComplete', 'routeChangeError'] propertyFields.forEach((field) => { // Here we need to use Object.defineProperty because, we need to return @@ -17,20 +24,31 @@ propertyFields.forEach((field) => { Object.defineProperty(SingletonRouter, field, { get () { throwIfNoRouter() - return router[field] + return SingletonRouter.router[field] } }) }) -methodFields.forEach((field) => { +coreMethodFields.forEach((field) => { SingletonRouter[field] = (...args) => { throwIfNoRouter() - return router[field](...args) + return SingletonRouter.router[field](...args) } }) +routerEvents.forEach((event) => { + SingletonRouter.ready(() => { + SingletonRouter.router.on(event, (...args) => { + const eventField = `on${event.charAt(0).toUpperCase()}${event.substring(1)}` + if (SingletonRouter[eventField]) { + SingletonRouter[eventField](...args) + } + }) + }) +}) + function throwIfNoRouter () { - if (!router) { + if (!SingletonRouter.router) { const message = 'No router instance found.\n' + 'You should only use "next/router" inside the client side of your app.\n' throw new Error(message) @@ -48,8 +66,11 @@ export default SingletonRouter // This is used in client side when we are initilizing the app. // This should **not** use inside the server. export const createRouter = function (...args) { - router = new _Router(...args) - return router + SingletonRouter.router = new _Router(...args) + SingletonRouter.readyCallbacks.forEach(cb => cb()) + SingletonRouter.readyCallbacks = [] + + return SingletonRouter.router } // Export the actual Router class, which is usually used inside the server diff --git a/lib/router/router.js b/lib/router/router.js index 8cefcdba..0a121069 100644 --- a/lib/router/router.js +++ b/lib/router/router.js @@ -1,9 +1,11 @@ import { parse, format } from 'url' import evalScript from '../eval-script' import shallowEquals from '../shallow-equals' +import { EventEmitter } from 'events' -export default class Router { +export default class Router extends EventEmitter { constructor (pathname, query, { Component, ErrorComponent, ctx } = {}) { + super() // represents the current component key this.route = toRoute(pathname) @@ -14,6 +16,7 @@ export default class Router { this.pathname = pathname this.query = query this.subscriptions = new Set() + this.componentLoadCancel = null this.onPopState = this.onPopState.bind(this) @@ -26,40 +29,37 @@ export default class Router { } } - onPopState (e) { + async onPopState (e) { this.abortComponentLoad() - const as = getURL() - const { url = as } = e.state || {} + const { url, as } = e.state const { pathname, query } = parse(url, true) - if (!this.urlIsNew(pathname, query)) return + if (!this.urlIsNew(pathname, query)) { + this.emit('routeChangeStart', as) + this.emit('routeChangeComplete', as) + return + } const route = toRoute(pathname) - Promise.resolve() - .then(async () => { - const data = await this.fetchComponent(route) - const ctx = { ...data.ctx, pathname, query } - const props = await this.getInitialProps(data.Component, ctx) + this.emit('routeChangeStart', as) + const { + data, + props, + error + } = await this.getRouteInfo(route, pathname, query) - this.route = route - this.set(pathname, query, { ...data, props }) - }) - .catch(async (err) => { - if (err.cancelled) return + if (error) { + this.emit('routeChangeError', error, as) + // We don't need to throw here since the error is already logged by + // this.getRouteInfo + return + } - const data = { Component: this.ErrorComponent, ctx: { err } } - const ctx = { ...data.ctx, pathname, query } - const props = await this.getInitialProps(data.Component, ctx) - - this.route = route - this.set(pathname, query, { ...data, props }) - console.error(err) - }) - .catch((err) => { - console.error(err) - }) + this.route = route + this.set(pathname, query, { ...data, props }) + this.emit('routeChangeComplete', as) } update (route, Component) { @@ -77,29 +77,24 @@ export default class Router { if (route !== this.route) return - const { pathname, query } = parse(window.location.href, true) + const url = window.location.href + const { pathname, query } = parse(url, true) - let data - let props - let _err - try { - data = await this.fetchComponent(route) - const ctx = { ...data.ctx, pathname, query } - props = await this.getInitialProps(data.Component, ctx) - } catch (err) { - if (err.cancelled) return false + this.emit('routeChangeStart', url) + const { + data, + props, + error + } = await this.getRouteInfo(route, pathname, query) - data = { Component: this.ErrorComponent, ctx: { err } } - const ctx = { ...data.ctx, pathname, query } - props = await this.getInitialProps(data.Component, ctx) - - _err = err - console.error(err) + if (error) { + this.emit('routeChangeError', error, url) + throw error } this.notify({ ...data, props }) - if (_err) throw _err + this.emit('routeChangeComplete', url) } back () { @@ -115,33 +110,26 @@ export default class Router { } async change (method, url, as) { + this.abortComponentLoad() const { pathname, query } = parse(url, true) if (!this.urlIsNew(pathname, query)) { + this.emit('routeChangeStart', as) changeState() + this.emit('routeChangeComplete', as) return true } const route = toRoute(pathname) - this.abortComponentLoad() + this.emit('routeChangeStart', as) + const { + data, props, error + } = await this.getRouteInfo(route, pathname, query) - let data - let props - let _err - try { - data = await this.fetchComponent(route) - const ctx = { ...data.ctx, pathname, query } - props = await this.getInitialProps(data.Component, ctx) - } catch (err) { - if (err.cancelled) return false - - data = { Component: this.ErrorComponent, ctx: { err } } - const ctx = { ...data.ctx, pathname, query } - props = await this.getInitialProps(data.Component, ctx) - - _err = err - console.error(err) + if (error) { + this.emit('routeChangeError', error, as) + throw error } changeState() @@ -149,17 +137,39 @@ export default class Router { this.route = route this.set(pathname, query, { ...data, props }) - if (_err) throw _err - + this.emit('routeChangeComplete', as) return true function changeState () { if (method !== 'pushState' || getURL() !== as) { - window.history[method]({ url }, null, as) + window.history[method]({ url, as }, null, as) } } } + async getRouteInfo (route, pathname, query) { + const routeInfo = {} + + try { + const data = routeInfo.data = await this.fetchComponent(route) + const ctx = { ...data.ctx, pathname, query } + routeInfo.props = await this.getInitialProps(data.Component, ctx) + } catch (err) { + if (err.cancelled) { + return { error: err } + } + + const data = routeInfo.data = { Component: this.ErrorComponent, ctx: { err } } + const ctx = { ...data.ctx, pathname, query } + routeInfo.props = await this.getInitialProps(data.Component, ctx) + + routeInfo.error = err + console.error(err) + } + + return routeInfo + } + set (pathname, query, data) { this.pathname = pathname this.query = query @@ -177,7 +187,12 @@ export default class Router { data = await new Promise((resolve, reject) => { this.componentLoadCancel = cancel = () => { - if (xhr.abort) xhr.abort() + if (xhr.abort) { + xhr.abort() + const error = new Error('Fetching componenet cancelled') + error.cancelled = true + reject(error) + } } const url = `/_next/pages${route}` @@ -211,7 +226,7 @@ export default class Router { } if (cancelled) { - const err = new Error('Cancelled') + const err = new Error('Loading initial props cancelled') err.cancelled = true throw err }