1
0
Fork 0
mirror of https://github.com/terribleplan/next.js.git synced 2024-01-19 02:48:18 +00:00

Implement Router Events (#511)

* Move route loading logic to a common place.

* Add router events.

* Add EventEmitter's core API methods.

* Add example app for loading events and docs.

* Fix some typos.

* Get rid of Router.ready()

* Remove events package.
It's already shipping with webpack.

* Handling aborting properly.

* Expose simple attribute based events listener API.
Removed the proposed event listener API from the public API.

* Remove error logged when there's an abort error.
There are many other ways to capture this error.
So, it doesn't look nice to print this always.

* Change router events to pass only the current URL as arguments.

* Add a section about Cancelled Routes to README.
This commit is contained in:
Arunoda Susiripala 2016-12-31 06:45:22 +05:30 committed by Guillermo Rauch
parent 6d8079a261
commit c890dc3573
10 changed files with 372 additions and 79 deletions

View file

@ -182,13 +182,15 @@ For the initial page load, `getInitialProps` will execute on the server only. `g
### Routing
#### With `<Link>`
<p><details>
<summary><b>Examples</b></summary>
<ul><li><a href="./examples/using-routing">Basic routing</a></li></ul>
<ul>
<li><a href="./examples/hello-world">Hello World</a></li>
</ul>
</details></p>
#### With `<Link>`
Client-side transitions between routes can be enabled via a `<Link>` component. Consider these two pages:
```jsx
@ -225,6 +227,14 @@ The second `as` parameter for `push` and `replace` is an optional _decoration_ o
#### Imperatively
<p><details>
<summary><b>Examples</b></summary>
<ul>
<li><a href="./examples/using-router">Basic routing</a></li>
<li><a href="./examples/with-loading">With a page loading indicator</a></li>
</ul>
</details></p>
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
<p><details>

View file

@ -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.

View file

@ -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 () => (
<div style={{ marginBottom: 20 }}>
<Head>
{/* Import CSS for nprogress */}
<link rel='stylesheet' type='text/css' href='/static/nprogress.css' />
</Head>
<Link href='/'><a style={linkStyle}>Home</a></Link>
<Link href='/about'><a style={linkStyle}>About</a></Link>
<Link href='/forever'><a style={linkStyle}>Forever</a></Link>
<Link href='/non-existing'><a style={linkStyle}>Non Existing Page</a></Link>
</div>
)

View file

@ -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"
}

View file

@ -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 (
<div>
<Header />
<p>This is about Next!</p>
</div>
)
}
}

View file

@ -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 (
<div>
<Header />
<p>This page was rendered for a while!</p>
</div>
)
}
}

View file

@ -0,0 +1,9 @@
import React from 'react'
import Header from '../components/Header'
export default () => (
<div>
<Header />
<p>Hello Next!</p>
</div>
)

View file

@ -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); }
}

View file

@ -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

View file

@ -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
}