diff --git a/examples/with-shallow-routing/README.md b/examples/with-shallow-routing/README.md
new file mode 100644
index 000000000000..a3cb85063b32
--- /dev/null
+++ b/examples/with-shallow-routing/README.md
@@ -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`.
\ No newline at end of file
diff --git a/examples/with-shallow-routing/package.json b/examples/with-shallow-routing/package.json
new file mode 100644
index 000000000000..5f52e996771d
--- /dev/null
+++ b/examples/with-shallow-routing/package.json
@@ -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"
+}
diff --git a/examples/with-shallow-routing/pages/about.js b/examples/with-shallow-routing/pages/about.js
new file mode 100644
index 000000000000..e5db96213640
--- /dev/null
+++ b/examples/with-shallow-routing/pages/about.js
@@ -0,0 +1,3 @@
+export default () => (
+
@@ -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
}
}
diff --git a/lib/router/router.js b/lib/router/router.js
index 518d1faff774..f2307c9febe6 100644
--- a/lib/router/router.js
+++ b/lib/router/router.js
@@ -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
}
diff --git a/readme.md b/readme.md
index 4d4037bc43b6..f5caf74612f6 100644
--- a/readme.md
+++ b/readme.md
@@ -25,6 +25,7 @@ _**NOTE! the README on the `master` branch might not match that of the [latest s
- [With `
`](#with-link)
- [Imperatively](#imperatively)
- [Router Events](#router-events)
+ - [Shallow Routing](#shallow-routing)
- [Prefetching Pages](#prefetching-pages)
- [With `
`](#with-link-1)
- [Imperatively](#imperatively-1)
@@ -349,6 +350,50 @@ Router.onAppUpdated = (nextUrl) => {
}
```
+##### Shallow Routing
+
+
+ Examples
+
+
+
+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)
diff --git a/test/integration/basic/pages/nav/index.js b/test/integration/basic/pages/nav/index.js
index 400986897075..d283ce58a5e8 100644
--- a/test/integration/basic/pages/nav/index.js
+++ b/test/integration/basic/pages/nav/index.js
@@ -18,7 +18,8 @@ export default class extends Component {
About
Empty Props
-
Self Reload
+
Self Reload
+
Shallow Routing
This is the home.
Counter: {counter}
diff --git a/test/integration/basic/pages/nav/shallow-routing.js b/test/integration/basic/pages/nav/shallow-routing.js
new file mode 100644
index 000000000000..c649d751212d
--- /dev/null
+++ b/test/integration/basic/pages/nav/shallow-routing.js
@@ -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 (
+
+
Home
+
+ Counter: {this.getCurrentCounter()}
+
+
+ getInitialProps run count: {this.props.getInitialPropsRunCount}
+
+
this.increase()}>Increase
+
+ )
+ }
+}
diff --git a/test/integration/basic/test/client-navigation.js b/test/integration/basic/test/client-navigation.js
index 96435c941c54..8ddb640c46b9 100644
--- a/test/integration/basic/test/client-navigation.js
+++ b/test/integration/basic/test/client-navigation.js
@@ -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()
+ })
+ })
})
}
diff --git a/test/integration/basic/test/index.test.js b/test/integration/basic/test/index.test.js
index d88ace289f69..9922c7032c2e 100644
--- a/test/integration/basic/test/index.test.js
+++ b/test/integration/basic/test/index.test.js
@@ -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))