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:
parent
76698eaa08
commit
f8f3fa7dce
30
examples/with-shallow-routing/README.md
Normal file
30
examples/with-shallow-routing/README.md
Normal 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`.
|
16
examples/with-shallow-routing/package.json
Normal file
16
examples/with-shallow-routing/package.json
Normal 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"
|
||||
}
|
3
examples/with-shallow-routing/pages/about.js
Normal file
3
examples/with-shallow-routing/pages/about.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
export default () => (
|
||||
<div>About us</div>
|
||||
)
|
46
examples/with-shallow-routing/pages/index.js
Normal file
46
examples/with-shallow-routing/pages/index.js
Normal 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>
|
||||
)
|
||||
}
|
||||
}
|
26
lib/app.js
26
lib/app.js
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
45
readme.md
45
readme.md
|
@ -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)
|
||||
|
|
|
@ -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}
|
||||
|
|
44
test/integration/basic/pages/nav/shallow-routing.js
Normal file
44
test/integration/basic/pages/nav/shallow-routing.js
Normal 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>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
|
|
Loading…
Reference in a new issue