Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for URL objects in Link and Router #1345

Merged
merged 19 commits into from
Mar 12, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions examples/with-url-object-routing/README.md
Original file line number Diff line number Diff line change
@@ -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 `<Link>` component and parameters of `Router#push` and `Router#replace`.

This simplify the usage of parameterized URLs when you have many query values.
13 changes: 13 additions & 0 deletions examples/with-url-object-routing/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
28 changes: 28 additions & 0 deletions examples/with-url-object-routing/pages/about.js
Original file line number Diff line number Diff line change
@@ -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) => (
<div>
<h1>About {props.url.query.name}</h1>
{props.url.query.name === 'zeit' ? (
<Link href='/'>
<a>Go to home page</a>
</Link>
) : (
<button onClick={handleClick}>Go to /about/zeit</button>
)}
</div>
)
21 changes: 21 additions & 0 deletions examples/with-url-object-routing/pages/index.js
Original file line number Diff line number Diff line change
@@ -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 () => (
<div>
<h1>Home page</h1>
<Link href={href} as={as}>
<a>Go to /about/next</a>
</Link>
</div>
)
28 changes: 28 additions & 0 deletions examples/with-url-object-routing/server.js
Original file line number Diff line number Diff line change
@@ -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')
})
})
38 changes: 28 additions & 10 deletions lib/link.js
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -25,14 +26,18 @@ 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))) {
// ignore click for new tab / new window behavior
return
}

let { href, as } = this.props
let { href, as } = this

if (!isLocal(href)) {
// ignore click if it's outside our scope
Expand Down Expand Up @@ -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)
}

Expand All @@ -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 (<Link>example</Link>) we wrap it in an <a> tag
if (typeof children === 'string') {
children = <a>{children}</a>
Expand All @@ -97,17 +114,18 @@ export default class Link extends Component {

// If child is an <a> 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)
}
}

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)
7 changes: 6 additions & 1 deletion lib/router/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
39 changes: 39 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

<p><details>
<summary><b>Examples</b></summary>
<ul>
<li><a href="./examples/with-url-object-routing">With URL Object Routing</a></li>
</ul>
</details></p>

The component `<Link>` 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 () => (
<div>Click <Link href={{ pathname: 'about', query: { name: 'Zeit' }}}<a>here</a></Link> to read more</div>
)
```

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

<p><details>
Expand Down Expand Up @@ -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 `<Link>` component to `push` and `replace` an url.

```jsx
import Router from 'next/router'

const handler = () => Router.push({
pathname: 'about',
query: { name: 'Zeit' }
})

export default () => (
<div>Click <span onClick={handler}>here</span> to read more</div>
)
```

This uses of the same exact parameters as in the `<Link>` component.

##### Router Events

You can also listen to different events happening inside the Router.
Expand Down
21 changes: 21 additions & 0 deletions test/integration/basic/pages/nav/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Link from 'next/link'
import { Component } from 'react'
import Router from 'next/router'

let counter = 0

Expand All @@ -13,13 +14,33 @@ 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 (
<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' style={linkStyle}>Self Reload</a></Link>
<Link href='/nav/shallow-routing'><a id='shallow-routing-link' style={linkStyle}>Shallow Routing</a></Link>
<Link
href={{ pathname: '/nav/querystring', query: { id: 10 } }}
as={{ pathname: '/nav/querystring/10', hash: '10' }}
>
<a id='query-string-link' style={linkStyle}>QueryString</a>
</Link>
<button
onClick={() => this.visitQueryStringPage()}
style={linkStyle}
id='query-string-button'
>
Visit QueryString Page
</button>

<p>This is the home.</p>
<div id='counter'>
Counter: {counter}
Expand Down
2 changes: 1 addition & 1 deletion test/integration/basic/pages/nav/querystring.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export default class AsyncProps extends React.Component {

render () {
return (
<div>
<div className='nav-querystring'>
<Link href={`/nav/querystring?id=${parseInt(this.props.id) + 1}`}>
<a id='next-id-link'>Click here</a>
</Link>
Expand Down
28 changes: 28 additions & 0 deletions test/integration/basic/test/client-navigation.js
Original file line number Diff line number Diff line change
Expand Up @@ -236,5 +236,33 @@ export default (context, render) => {
browser.close()
})
})

describe('with URL objects', () => {
it('should work with <Link/>', 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()
})
})
})
}