Skip to content

Commit

Permalink
Add support for URL objects in Link and Router (#1345)
Browse files Browse the repository at this point in the history
* Add support for URL objects in Link and Router

* Fix typo in comment

* Fix possible bug if the `href` prop is `null`

* Document the usage of URL objects in Link and Router

* Update readme.md

* Parse URL to get the host & hostname in `isLocal`

This should check if the current location and the checked URL have the same `host` or `hostname`.

* Format `as` parameter from object to string if required

* Format `href` and `as` inside the construct and componentWillReceiveProps

* Use `JSON.stringify` to compare objects

* Add usage example

* chore(package): update chromedriver to version 2.28.0 (#1386)

https://greenkeeper.io/

* Refactor the codebase a bit.

* Change the example name.

* Add a few test cases.

* Add the example to the README.
  • Loading branch information
sergiodxa authored and arunoda committed Mar 12, 2017
1 parent 1ae3c2e commit 3882271
Show file tree
Hide file tree
Showing 11 changed files with 242 additions and 12 deletions.
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
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()
})
})
})
}

0 comments on commit 3882271

Please sign in to comment.