diff --git a/examples/with-url-object-routing/README.md b/examples/with-url-object-routing/README.md new file mode 100644 index 0000000000000..e57f71c51b140 --- /dev/null +++ b/examples/with-url-object-routing/README.md @@ -0,0 +1,29 @@ +# URL object routing + +## 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/with-url-object-routing +cd with-url-object-routing +``` + +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 + +Next.js allows using [Node.js URL objects](https://nodejs.org/api/url.html#url_url_strings_and_url_objects) as `href` and `as` values for `` component and parameters of `Router#push` and `Router#replace`. + +This simplify the usage of parameterized URLs when you have many query values. diff --git a/examples/with-url-object-routing/package.json b/examples/with-url-object-routing/package.json new file mode 100644 index 0000000000000..e6fe7e8be43e2 --- /dev/null +++ b/examples/with-url-object-routing/package.json @@ -0,0 +1,13 @@ +{ + "scripts": { + "dev": "node server.js", + "build": "next build", + "start": "NODE_ENV=production node server.js" + }, + "dependencies": { + "next": "beta", + "path-match": "1.2.4", + "react": "^15.4.2", + "react-dom": "^15.4.2" + } +} diff --git a/examples/with-url-object-routing/pages/about.js b/examples/with-url-object-routing/pages/about.js new file mode 100644 index 0000000000000..8d71f4887d3b1 --- /dev/null +++ b/examples/with-url-object-routing/pages/about.js @@ -0,0 +1,28 @@ +import React from 'react' +import Link from 'next/link' +import Router from 'next/router' + +const href = { + pathname: '/about', + query: { name: 'zeit' } +} + +const as = { + pathname: '/about/zeit', + hash: 'title-1' +} + +const handleClick = () => Router.push(href, as) + +export default (props) => ( +
+

About {props.url.query.name}

+ {props.url.query.name === 'zeit' ? ( + + Go to home page + + ) : ( + + )} +
+) diff --git a/examples/with-url-object-routing/pages/index.js b/examples/with-url-object-routing/pages/index.js new file mode 100644 index 0000000000000..4da2d61c505ce --- /dev/null +++ b/examples/with-url-object-routing/pages/index.js @@ -0,0 +1,21 @@ +import React from 'react' +import Link from 'next/link' + +const href = { + pathname: '/about', + query: { name: 'next' } +} + +const as = { + pathname: '/about/next', + hash: 'title-1' +} + +export default () => ( +
+

Home page

+ + Go to /about/next + +
+) diff --git a/examples/with-url-object-routing/server.js b/examples/with-url-object-routing/server.js new file mode 100644 index 0000000000000..54c5559588be1 --- /dev/null +++ b/examples/with-url-object-routing/server.js @@ -0,0 +1,28 @@ +const { createServer } = require('http') +const { parse } = require('url') +const next = require('next') +const pathMatch = require('path-match') + +const dev = process.env.NODE_ENV !== 'production' +const app = next({ dev }) +const handle = app.getRequestHandler() +const route = pathMatch() +const match = route('/about/:name') + +app.prepare() +.then(() => { + createServer((req, res) => { + const { pathname } = parse(req.url) + const params = match(pathname) + if (params === false) { + handle(req, res) + return + } + + app.render(req, res, '/about', params) + }) + .listen(3000, (err) => { + if (err) throw err + console.log('> Ready on http://localhost:3000') + }) +}) diff --git a/lib/link.js b/lib/link.js index 154240c34a80e..79dc8b61d5c10 100644 --- a/lib/link.js +++ b/lib/link.js @@ -1,12 +1,13 @@ -import { resolve } from 'url' +import { resolve, format, parse } from 'url' import React, { Component, Children, PropTypes } from 'react' import Router from './router' import { warn, execOnce, getLocationOrigin } from './utils' export default class Link extends Component { - constructor (props) { - super(props) + constructor (props, ...rest) { + super(props, ...rest) this.linkClicked = this.linkClicked.bind(this) + this.formatUrls(props) } static propTypes = { @@ -25,6 +26,10 @@ export default class Link extends Component { ]).isRequired } + componentWillReceiveProps (nextProps) { + this.formatUrls(nextProps) + } + linkClicked (e) { if (e.currentTarget.nodeName === 'A' && (e.metaKey || e.ctrlKey || e.shiftKey || (e.nativeEvent && e.nativeEvent.which === 2))) { @@ -32,7 +37,7 @@ export default class Link extends Component { return } - let { href, as } = this.props + let { href, as } = this if (!isLocal(href)) { // ignore click if it's outside our scope @@ -68,7 +73,7 @@ export default class Link extends Component { // Prefetch the JSON page if asked (only in the client) const { pathname } = window.location - const href = resolve(pathname, this.props.href) + const href = resolve(pathname, this.href) Router.prefetch(href) } @@ -77,13 +82,25 @@ export default class Link extends Component { } componentDidUpdate (prevProps) { - if (this.props.href !== prevProps.href) { + if (JSON.stringify(this.props.href) !== JSON.stringify(prevProps.href)) { this.prefetch() } } + // We accept both 'href' and 'as' as objects which we can pass to `url.format`. + // We'll handle it here. + formatUrls (props) { + this.href = props.href && typeof props.href === 'object' + ? format(props.href) + : props.href + this.as = props.as && typeof props.as === 'object' + ? format(props.as) + : props.as + } + render () { let { children } = this.props + let { href, as } = this // Deprecated. Warning shown by propType check. If the childen provided is a string (example) we wrap it in an tag if (typeof children === 'string') { children = {children} @@ -97,7 +114,7 @@ export default class Link extends Component { // If child is an tag and doesn't have a href attribute we specify it so that repetition is not needed by the user if (child.type === 'a' && !('href' in child.props)) { - props.href = this.props.as || this.props.href + props.href = as || href } return React.cloneElement(child, props) @@ -105,9 +122,10 @@ export default class Link extends Component { } function isLocal (href) { - const origin = getLocationOrigin() - return !/^(https?:)?\/\//.test(href) || - origin === href.substr(0, origin.length) + const url = parse(href, false, true) + const origin = parse(getLocationOrigin(), false, true) + return (!url.host || !url.hostname) || + (origin.host === url.host || origin.hostname === url.hostname) } const warnLink = execOnce(warn) diff --git a/lib/router/router.js b/lib/router/router.js index ca9484fc56073..d939a7834866b 100644 --- a/lib/router/router.js +++ b/lib/router/router.js @@ -128,7 +128,12 @@ export default class Router extends EventEmitter { return this.change('replaceState', url, as, options) } - async change (method, url, as, options) { + async change (method, _url, _as, options) { + // If url and as provided as an object representation, + // we'll format them into the string version here. + const url = typeof _url === 'object' ? format(_url) : _url + const as = typeof _as === 'object' ? format(_as) : _as + this.abortComponentLoad(as) const { pathname, query } = parse(url, true) diff --git a/package.json b/package.json index 47f05613f3b11..243ce8b13d1db 100644 --- a/package.json +++ b/package.json @@ -95,7 +95,7 @@ "babel-preset-es2015": "6.22.0", "benchmark": "2.1.3", "cheerio": "0.22.0", - "chromedriver": "2.26.1", + "chromedriver": "2.28.0", "coveralls": "2.11.16", "cross-env": "3.1.4", "fly": "2.0.5", diff --git a/readme.md b/readme.md index a026898d5d5c0..4127feaedadfe 100644 --- a/readme.md +++ b/readme.md @@ -271,6 +271,27 @@ Each top-level component receives a `url` property with the following API: The second `as` parameter for `push` and `replace` is an optional _decoration_ of the URL. Useful if you configured custom routes on the server. +##### With URL object + +

+ Examples + +

+ +The component `` can also receive an URL object and it will automatically format it to create the URL string. + +```jsx +// pages/index.js +import Link from 'next/link' +export default () => ( +
Click here to read more
+) +``` + +That will generate the URL string `/about?name=Zeit`, you can use every property as defined in the [Node.js URL module documentation](https://nodejs.org/api/url.html#url_url_strings_and_url_objects). + #### Imperatively

@@ -303,6 +324,24 @@ 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_ +##### With URL object +You can use an URL object the same way you use it in a `` component to `push` and `replace` an url. + +```jsx +import Router from 'next/router' + +const handler = () => Router.push({ + pathname: 'about', + query: { name: 'Zeit' } +}) + +export default () => ( +
Click here to read more
+) +``` + +This uses of the same exact parameters as in the `` component. + ##### Router Events You can also listen to different events happening inside the Router. diff --git a/test/integration/basic/pages/nav/index.js b/test/integration/basic/pages/nav/index.js index d283ce58a5e83..23cf4d23a8ed9 100644 --- a/test/integration/basic/pages/nav/index.js +++ b/test/integration/basic/pages/nav/index.js @@ -1,5 +1,6 @@ import Link from 'next/link' import { Component } from 'react' +import Router from 'next/router' let counter = 0 @@ -13,6 +14,12 @@ export default class extends Component { this.forceUpdate() } + visitQueryStringPage () { + const href = { pathname: '/nav/querystring', query: { id: 10 } } + const as = { pathname: '/nav/querystring/10', hash: '10' } + Router.push(href, as) + } + render () { return (
@@ -20,6 +27,20 @@ export default class extends Component { Empty Props Self Reload Shallow Routing + + QueryString + + +

This is the home.

Counter: {counter} diff --git a/test/integration/basic/pages/nav/querystring.js b/test/integration/basic/pages/nav/querystring.js index 740a4acfe0384..11b31e34b1478 100644 --- a/test/integration/basic/pages/nav/querystring.js +++ b/test/integration/basic/pages/nav/querystring.js @@ -8,7 +8,7 @@ export default class AsyncProps extends React.Component { render () { return ( -
+
Click here diff --git a/test/integration/basic/test/client-navigation.js b/test/integration/basic/test/client-navigation.js index b7bd740bbe5c5..3022502f31047 100644 --- a/test/integration/basic/test/client-navigation.js +++ b/test/integration/basic/test/client-navigation.js @@ -236,5 +236,33 @@ export default (context, render) => { browser.close() }) }) + + describe('with URL objects', () => { + it('should work with ', async () => { + const browser = await webdriver(context.appPort, '/nav') + const text = await browser + .elementByCss('#query-string-link').click() + .waitForElementByCss('.nav-querystring') + .elementByCss('p').text() + expect(text).toBe('10') + + expect(await browser.url()) + .toBe(`http://localhost:${context.appPort}/nav/querystring/10#10`) + browser.close() + }) + + it('should work with "Router.push"', async () => { + const browser = await webdriver(context.appPort, '/nav') + const text = await browser + .elementByCss('#query-string-button').click() + .waitForElementByCss('.nav-querystring') + .elementByCss('p').text() + expect(text).toBe('10') + + expect(await browser.url()) + .toBe(`http://localhost:${context.appPort}/nav/querystring/10#10`) + browser.close() + }) + }) }) }