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

Introducing Shallow Routing (#1357)

* Simplify route info handling.

* Add basic resolve=false support.

* Make sure to render getInitialProps always if it's the first render.

* Change resolve=false to shallow routing.

* Add test cases for shallow routing.

* Update README for shallow routing docs.

* Update docs.

* Update docs.

* Update docs.
This commit is contained in:
Arunoda Susiripala 2017-03-06 22:18:35 +05:30 committed by Guillermo Rauch
parent 76698eaa08
commit f8f3fa7dce
11 changed files with 300 additions and 57 deletions

View file

@ -0,0 +1,30 @@
# Shallow Routing Example
## How to use
Download the example [or clone the repo](https://github.com/zeit/next.js):
```bash
curl https://codeload.github.com/zeit/next.js/tar.gz/master | tar -xz --strip=2 next.js-master/examples/hello-world
cd hello-world
```
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
With shallow routing, we could change the URL without actually running the `getInitialProps` every time you change the URL.
We do this passing the `shallow: true` option to `Router.push` or `Router.replace`.

View file

@ -0,0 +1,16 @@
{
"name": "with-shallow-routing",
"version": "1.0.0",
"scripts": {
"dev": "next",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "next@beta",
"react": "^15.4.2",
"react-dom": "^15.4.2"
},
"author": "",
"license": "ISC"
}

View file

@ -0,0 +1,3 @@
export default () => (
<div>About us</div>
)

View file

@ -0,0 +1,46 @@
import React from 'react'
import Link from 'next/link'
import Router from 'next/router'
import { format } from 'url'
let counter = 1
export default class Index extends React.Component {
static getInitialProps ({ res }) {
if (res) {
return { initialPropsCounter: 1 }
}
counter++
return {
initialPropsCounter: counter
}
}
reload () {
const { pathname, query } = Router
Router.push(format({ pathname, query }))
}
incrementStateCounter () {
const { url } = this.props
const currentCounter = url.query.counter ? parseInt(url.query.counter) : 0
const href = `/?counter=${currentCounter + 1}`
Router.push(href, href, { shallow: true })
}
render () {
const { initialPropsCounter, url } = this.props
return (
<div>
<h2>This is the Home Page</h2>
<Link href='/about'><a>About</a></Link>
<button onClick={() => this.reload()}>Reload</button>
<button onClick={() => this.incrementStateCounter()}>Change State Counter</button>
<p>"getInitialProps" ran for "{initialPropsCounter}" times.</p>
<p>Counter: "{url.query.counter || 0}".</p>
</div>
)
}
}

View file

@ -1,7 +1,6 @@
import React, { Component, PropTypes } from 'react'
import { AppContainer } from 'react-hot-loader'
import shallowEquals from './shallow-equals'
import { warn } from './utils'
const ErrorDebug = process.env.NODE_ENV === 'production'
? null : require('./error-debug').default
@ -18,7 +17,8 @@ export default class App extends Component {
render () {
const { Component, props, hash, err, router } = this.props
const containerProps = { Component, props, hash, router }
const url = createUrl(router)
const containerProps = { Component, props, hash, router, url }
return <div>
<Container {...containerProps} />
@ -52,8 +52,7 @@ class Container extends Component {
}
render () {
const { Component, props, router } = this.props
const url = createUrl(router)
const { Component, props, url } = this.props
// includes AppContainer which bypasses shouldComponentUpdate method
// https://github.com/gaearon/react-hot-loader/issues/442
@ -66,23 +65,6 @@ class Container extends Component {
function createUrl (router) {
return {
query: router.query,
pathname: router.pathname,
back: () => router.back(),
push: (url, as) => router.push(url, as),
pushTo: (href, as) => {
warn(`Warning: 'url.pushTo()' is deprecated. Please use 'url.push()' instead.`)
const pushRoute = as ? href : null
const pushUrl = as || href
return router.push(pushRoute, pushUrl)
},
replace: (url, as) => router.replace(url, as),
replaceTo: (href, as) => {
warn(`Warning: 'url.replaceTo()' is deprecated. Please use 'url.replace()' instead.`)
const replaceRoute = as ? href : null
const replaceUrl = as || href
return router.replace(replaceRoute, replaceUrl)
}
pathname: router.pathname
}
}

View file

@ -71,12 +71,16 @@ export default class Router extends EventEmitter {
return
}
const { url, as } = e.state
this.replace(url, as)
const { url, as, options } = e.state
this.replace(url, as, options)
}
update (route, Component) {
const data = this.components[route] || {}
const data = this.components[route]
if (!data) {
throw new Error(`Cannot update unavailable route: ${route}`)
}
const newData = { ...data, Component }
this.components[route] = newData
@ -95,17 +99,14 @@ export default class Router extends EventEmitter {
const { pathname, query } = parse(url, true)
this.emit('routeChangeStart', url)
const {
data,
props,
error
} = await this.getRouteInfo(route, pathname, query, url)
const routeInfo = await this.getRouteInfo(route, pathname, query, url)
const { error } = routeInfo
if (error && error.cancelled) {
return
}
this.notify({ ...data, props })
this.notify(routeInfo)
if (error) {
this.emit('routeChangeError', error, url)
@ -119,15 +120,15 @@ export default class Router extends EventEmitter {
window.history.back()
}
push (url, as = url) {
return this.change('pushState', url, as)
push (url, as = url, options = {}) {
return this.change('pushState', url, as, options)
}
replace (url, as = url) {
return this.change('replaceState', url, as)
replace (url, as = url, options = {}) {
return this.change('replaceState', url, as, options)
}
async change (method, url, as) {
async change (method, url, as, options) {
this.abortComponentLoad(as)
const { pathname, query } = parse(url, true)
@ -147,21 +148,30 @@ export default class Router extends EventEmitter {
}
const route = toRoute(pathname)
const { shallow = false } = options
let routeInfo = null
this.emit('routeChangeStart', as)
const {
data, props, error
} = await this.getRouteInfo(route, pathname, query, as)
// If shallow === false and other conditions met, we reuse the
// existing routeInfo for this route.
// Because of this, getInitialProps would not run.
if (shallow && this.isShallowRoutingPossible(route)) {
routeInfo = this.components[route]
} else {
routeInfo = await this.getRouteInfo(route, pathname, query, as)
}
const { error } = routeInfo
if (error && error.cancelled) {
return false
}
this.changeState(method, url, as)
this.changeState(method, url, as, options)
const hash = window.location.hash.substring(1)
this.route = route
this.set(pathname, query, as, { ...data, props, hash })
this.set(route, pathname, query, as, { ...routeInfo, hash })
if (error) {
this.emit('routeChangeError', error, as)
@ -172,31 +182,33 @@ export default class Router extends EventEmitter {
return true
}
changeState (method, url, as) {
changeState (method, url, as, options = {}) {
if (method !== 'pushState' || getURL() !== as) {
window.history[method]({ url, as }, null, as)
window.history[method]({ url, as, options }, null, as)
}
}
async getRouteInfo (route, pathname, query, as) {
const routeInfo = {}
let routeInfo = null
try {
routeInfo.data = await this.fetchComponent(route, as)
if (!routeInfo.data) {
return null
routeInfo = this.components[route]
if (!routeInfo) {
routeInfo = await this.fetchComponent(route, as)
}
const { Component, err, jsonPageRes } = routeInfo.data
const { Component, err, jsonPageRes } = routeInfo
const ctx = { err, pathname, query, jsonPageRes }
routeInfo.props = await this.getInitialProps(Component, ctx)
this.components[route] = routeInfo
} catch (err) {
if (err.cancelled) {
return { error: err }
}
const Component = this.ErrorComponent
routeInfo.data = { Component, err }
routeInfo = { Component, err }
const ctx = { err, pathname, query }
routeInfo.props = await this.getInitialProps(Component, ctx)
@ -207,7 +219,8 @@ export default class Router extends EventEmitter {
return routeInfo
}
set (pathname, query, as, data) {
set (route, pathname, query, as, data) {
this.route = route
this.pathname = pathname
this.query = query
this.as = as
@ -238,6 +251,15 @@ export default class Router extends EventEmitter {
return this.pathname !== pathname || !shallowEquals(query, this.query)
}
isShallowRoutingPossible (route) {
return (
// If there's cached routeInfo for the route.
Boolean(this.components[route]) &&
// If the route is already rendered on the screen.
this.route === route
)
}
async prefetch (url) {
// We don't add support for prefetch in the development mode.
// If we do that, our on-demand-entries optimization won't performs better
@ -249,9 +271,6 @@ export default class Router extends EventEmitter {
}
async fetchComponent (route, as) {
let data = this.components[route]
if (data) return data
let cancelled = false
const cancel = this.componentLoadCancel = function () {
cancelled = true
@ -283,7 +302,6 @@ export default class Router extends EventEmitter {
this.componentLoadCancel = null
}
this.components[route] = newData
return newData
}

View file

@ -25,6 +25,7 @@ _**NOTE! the README on the `master` branch might not match that of the [latest s
- [With `<Link>`](#with-link)
- [Imperatively](#imperatively)
- [Router Events](#router-events)
- [Shallow Routing](#shallow-routing)
- [Prefetching Pages](#prefetching-pages)
- [With `<Link>`](#with-link-1)
- [Imperatively](#imperatively-1)
@ -349,6 +350,50 @@ Router.onAppUpdated = (nextUrl) => {
}
```
##### Shallow Routing
<p><details>
<summary><b>Examples</b></summary>
<ul>
<li><a href="./examples/with-shallow-routing">Shallow Routing</a></li>
</ul>
</details></p>
With shallow routing you could chnage the URL without running `getInitialProps` of the page. You'll receive the updated "pathname" and the "query" via the `url` prop of the page.
You can do this by invoking the eith `Router.push` or `Router.replace` with `shallow: true` option. Here's an example:
```js
// Current URL is "/"
const href = '/?counter=10'
const as = href
Router.push(href, as, { shallow: true })
```
Now, the URL is updated to "/?counter=10" and page is re-rendered.
You can see the updated URL with `this.props.url` inside the Component.
You can also watch for URL changes via [`componentWillReceiveProps`](https://facebook.github.io/react/docs/react-component.html#componentwillreceiveprops) hook as shown below:
```
componentWillReceiveProps(nextProps) {
const { pathname, query } = nextProps.url
// fetch data based on the new query
}
```
> NOTES:
>
> Shallow routing works **only** for same page URL changes.
>
> For an example, let's assume we've another page called "about".
> Now you are changing a URL like this:
> ```js
> Router.push('/about?counter=10', '/about?counter=10', { shallow: true })
> ```
> Since that's a new page, it'll run "getInitialProps" of the "about" page even we asked to do shallow routing.
### Prefetching Pages
(This is a production only feature)

View file

@ -18,7 +18,8 @@ export default class extends Component {
<div className='nav-home'>
<Link href='/nav/about'><a id='about-link' style={linkStyle}>About</a></Link>
<Link href='/empty-get-initial-props'><a id='empty-props' style={linkStyle}>Empty Props</a></Link>
<Link href='/nav/self-reload'><a id='self-reload-link'>Self Reload</a></Link>
<Link href='/nav/self-reload'><a id='self-reload-link' style={linkStyle}>Self Reload</a></Link>
<Link href='/nav/shallow-routing'><a id='shallow-routing-link' style={linkStyle}>Shallow Routing</a></Link>
<p>This is the home.</p>
<div id='counter'>
Counter: {counter}

View file

@ -0,0 +1,44 @@
import { Component } from 'react'
import Link from 'next/link'
import Router from 'next/router'
let getInitialPropsRunCount = 1
const linkStyle = {
marginRight: 10
}
export default class extends Component {
static getInitialProps ({ res }) {
if (res) return { getInitialPropsRunCount: 1 }
getInitialPropsRunCount++
return { getInitialPropsRunCount }
}
increase () {
const counter = this.getCurrentCounter()
const href = `/nav/shallow-routing?counter=${counter + 1}`
Router.push(href, href, { shallow: true })
}
getCurrentCounter () {
const { url } = this.props
return url.query.counter ? parseInt(url.query.counter) : 0
}
render () {
return (
<div className='shallow-routing'>
<Link href='/nav'><a id='home-link' style={linkStyle}>Home</a></Link>
<div id='counter'>
Counter: {this.getCurrentCounter()}
</div>
<div id='get-initial-props-run-count'>
getInitialProps run count: {this.props.getInitialPropsRunCount}
</div>
<button id='increase' onClick={() => this.increase()}>Increase</button>
</div>
)
}
}

View file

@ -179,5 +179,62 @@ export default (context, render) => {
})
})
})
describe('with shallow routing', () => {
it('should not update the url without running getInitialProps', async () => {
const browser = await webdriver(context.appPort, '/nav/shallow-routing')
const counter = await browser
.elementByCss('#increase').click()
.elementByCss('#increase').click()
.elementByCss('#counter').text()
expect(counter).toBe('Counter: 2')
const getInitialPropsRunCount = await browser
.elementByCss('#get-initial-props-run-count').text()
expect(getInitialPropsRunCount).toBe('getInitialProps run count: 1')
await browser.close()
})
it('should handle back button and should not run getInitialProps', async () => {
const browser = await webdriver(context.appPort, '/nav/shallow-routing')
let counter = await browser
.elementByCss('#increase').click()
.elementByCss('#increase').click()
.elementByCss('#counter').text()
expect(counter).toBe('Counter: 2')
counter = await browser
.back()
.elementByCss('#counter').text()
expect(counter).toBe('Counter: 1')
const getInitialPropsRunCount = await browser
.elementByCss('#get-initial-props-run-count').text()
expect(getInitialPropsRunCount).toBe('getInitialProps run count: 1')
await browser.close()
})
it('should run getInitialProps always when rending the page to the screen', async () => {
const browser = await webdriver(context.appPort, '/nav/shallow-routing')
const counter = await browser
.elementByCss('#increase').click()
.elementByCss('#increase').click()
.elementByCss('#home-link').click()
.waitForElementByCss('.nav-home')
.back()
.waitForElementByCss('.shallow-routing')
.elementByCss('#counter').text()
expect(counter).toBe('Counter: 2')
const getInitialPropsRunCount = await browser
.elementByCss('#get-initial-props-run-count').text()
expect(getInitialPropsRunCount).toBe('getInitialProps run count: 2')
await browser.close()
})
})
})
}

View file

@ -46,7 +46,8 @@ describe('Basic Features', () => {
renderViaHTTP(context.appPort, '/nav/about'),
renderViaHTTP(context.appPort, '/nav/querystring'),
renderViaHTTP(context.appPort, '/nav/self-reload'),
renderViaHTTP(context.appPort, '/nav/hash-changes')
renderViaHTTP(context.appPort, '/nav/hash-changes'),
renderViaHTTP(context.appPort, '/nav/shallow-routing')
])
})
afterAll(() => stopApp(context.server))