From f8f3fa7dcea90353b026061eb87b8fd2d5c8f7f8 Mon Sep 17 00:00:00 2001 From: Arunoda Susiripala Date: Mon, 6 Mar 2017 22:18:35 +0530 Subject: [PATCH] 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. --- examples/with-shallow-routing/README.md | 30 +++++++ examples/with-shallow-routing/package.json | 16 ++++ examples/with-shallow-routing/pages/about.js | 3 + examples/with-shallow-routing/pages/index.js | 46 ++++++++++ lib/app.js | 26 +----- lib/router/router.js | 84 +++++++++++-------- readme.md | 45 ++++++++++ test/integration/basic/pages/nav/index.js | 3 +- .../basic/pages/nav/shallow-routing.js | 44 ++++++++++ .../basic/test/client-navigation.js | 57 +++++++++++++ test/integration/basic/test/index.test.js | 3 +- 11 files changed, 300 insertions(+), 57 deletions(-) create mode 100644 examples/with-shallow-routing/README.md create mode 100644 examples/with-shallow-routing/package.json create mode 100644 examples/with-shallow-routing/pages/about.js create mode 100644 examples/with-shallow-routing/pages/index.js create mode 100644 test/integration/basic/pages/nav/shallow-routing.js diff --git a/examples/with-shallow-routing/README.md b/examples/with-shallow-routing/README.md new file mode 100644 index 00000000..a3cb8506 --- /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 00000000..5f52e996 --- /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 00000000..e5db9621 --- /dev/null +++ b/examples/with-shallow-routing/pages/about.js @@ -0,0 +1,3 @@ +export default () => ( +
About us
+) diff --git a/examples/with-shallow-routing/pages/index.js b/examples/with-shallow-routing/pages/index.js new file mode 100644 index 00000000..fd9a5567 --- /dev/null +++ b/examples/with-shallow-routing/pages/index.js @@ -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 ( +
+

This is the Home Page

+ About + + +

"getInitialProps" ran for "{initialPropsCounter}" times.

+

Counter: "{url.query.counter || 0}".

+
+ ) + } +} diff --git a/lib/app.js b/lib/app.js index 4af8d798..7ca461fd 100644 --- a/lib/app.js +++ b/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
@@ -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 518d1faf..f2307c9f 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 4d4037bc..f5caf746 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 40098689..d283ce58 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 00000000..c649d751 --- /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} +
+ +
+ ) + } +} diff --git a/test/integration/basic/test/client-navigation.js b/test/integration/basic/test/client-navigation.js index 96435c94..8ddb640c 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 d88ace28..9922c703 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))