Skip to content

Commit 3882271

Browse files
sergiodxaarunoda
authored andcommitted
Add support for URL objects in Link and Router (vercel#1345)
* 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 (vercel#1386) https://greenkeeper.io/ * Refactor the codebase a bit. * Change the example name. * Add a few test cases. * Add the example to the README.
1 parent 1ae3c2e commit 3882271

File tree

11 files changed

+242
-12
lines changed

11 files changed

+242
-12
lines changed
+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# URL object routing
2+
3+
## How to use
4+
5+
Download the example [or clone the repo](https://github.com/zeit/next.js):
6+
7+
```bash
8+
curl https://codeload.github.com/zeit/next.js/tar.gz/master | tar -xz --strip=2 next.js-master/examples/with-url-object-routing
9+
cd with-url-object-routing
10+
```
11+
12+
Install it and run:
13+
14+
```bash
15+
npm install
16+
npm run dev
17+
```
18+
19+
Deploy it to the cloud with [now](https://zeit.co/now) ([download](https://zeit.co/download))
20+
21+
```bash
22+
now
23+
```
24+
25+
## The idea behind the example
26+
27+
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`.
28+
29+
This simplify the usage of parameterized URLs when you have many query values.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"scripts": {
3+
"dev": "node server.js",
4+
"build": "next build",
5+
"start": "NODE_ENV=production node server.js"
6+
},
7+
"dependencies": {
8+
"next": "beta",
9+
"path-match": "1.2.4",
10+
"react": "^15.4.2",
11+
"react-dom": "^15.4.2"
12+
}
13+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import React from 'react'
2+
import Link from 'next/link'
3+
import Router from 'next/router'
4+
5+
const href = {
6+
pathname: '/about',
7+
query: { name: 'zeit' }
8+
}
9+
10+
const as = {
11+
pathname: '/about/zeit',
12+
hash: 'title-1'
13+
}
14+
15+
const handleClick = () => Router.push(href, as)
16+
17+
export default (props) => (
18+
<div>
19+
<h1>About {props.url.query.name}</h1>
20+
{props.url.query.name === 'zeit' ? (
21+
<Link href='/'>
22+
<a>Go to home page</a>
23+
</Link>
24+
) : (
25+
<button onClick={handleClick}>Go to /about/zeit</button>
26+
)}
27+
</div>
28+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import React from 'react'
2+
import Link from 'next/link'
3+
4+
const href = {
5+
pathname: '/about',
6+
query: { name: 'next' }
7+
}
8+
9+
const as = {
10+
pathname: '/about/next',
11+
hash: 'title-1'
12+
}
13+
14+
export default () => (
15+
<div>
16+
<h1>Home page</h1>
17+
<Link href={href} as={as}>
18+
<a>Go to /about/next</a>
19+
</Link>
20+
</div>
21+
)
+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
const { createServer } = require('http')
2+
const { parse } = require('url')
3+
const next = require('next')
4+
const pathMatch = require('path-match')
5+
6+
const dev = process.env.NODE_ENV !== 'production'
7+
const app = next({ dev })
8+
const handle = app.getRequestHandler()
9+
const route = pathMatch()
10+
const match = route('/about/:name')
11+
12+
app.prepare()
13+
.then(() => {
14+
createServer((req, res) => {
15+
const { pathname } = parse(req.url)
16+
const params = match(pathname)
17+
if (params === false) {
18+
handle(req, res)
19+
return
20+
}
21+
22+
app.render(req, res, '/about', params)
23+
})
24+
.listen(3000, (err) => {
25+
if (err) throw err
26+
console.log('> Ready on http://localhost:3000')
27+
})
28+
})

lib/link.js

+28-10
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
1-
import { resolve } from 'url'
1+
import { resolve, format, parse } from 'url'
22
import React, { Component, Children, PropTypes } from 'react'
33
import Router from './router'
44
import { warn, execOnce, getLocationOrigin } from './utils'
55

66
export default class Link extends Component {
7-
constructor (props) {
8-
super(props)
7+
constructor (props, ...rest) {
8+
super(props, ...rest)
99
this.linkClicked = this.linkClicked.bind(this)
10+
this.formatUrls(props)
1011
}
1112

1213
static propTypes = {
@@ -25,14 +26,18 @@ export default class Link extends Component {
2526
]).isRequired
2627
}
2728

29+
componentWillReceiveProps (nextProps) {
30+
this.formatUrls(nextProps)
31+
}
32+
2833
linkClicked (e) {
2934
if (e.currentTarget.nodeName === 'A' &&
3035
(e.metaKey || e.ctrlKey || e.shiftKey || (e.nativeEvent && e.nativeEvent.which === 2))) {
3136
// ignore click for new tab / new window behavior
3237
return
3338
}
3439

35-
let { href, as } = this.props
40+
let { href, as } = this
3641

3742
if (!isLocal(href)) {
3843
// ignore click if it's outside our scope
@@ -68,7 +73,7 @@ export default class Link extends Component {
6873

6974
// Prefetch the JSON page if asked (only in the client)
7075
const { pathname } = window.location
71-
const href = resolve(pathname, this.props.href)
76+
const href = resolve(pathname, this.href)
7277
Router.prefetch(href)
7378
}
7479

@@ -77,13 +82,25 @@ export default class Link extends Component {
7782
}
7883

7984
componentDidUpdate (prevProps) {
80-
if (this.props.href !== prevProps.href) {
85+
if (JSON.stringify(this.props.href) !== JSON.stringify(prevProps.href)) {
8186
this.prefetch()
8287
}
8388
}
8489

90+
// We accept both 'href' and 'as' as objects which we can pass to `url.format`.
91+
// We'll handle it here.
92+
formatUrls (props) {
93+
this.href = props.href && typeof props.href === 'object'
94+
? format(props.href)
95+
: props.href
96+
this.as = props.as && typeof props.as === 'object'
97+
? format(props.as)
98+
: props.as
99+
}
100+
85101
render () {
86102
let { children } = this.props
103+
let { href, as } = this
87104
// Deprecated. Warning shown by propType check. If the childen provided is a string (<Link>example</Link>) we wrap it in an <a> tag
88105
if (typeof children === 'string') {
89106
children = <a>{children}</a>
@@ -97,17 +114,18 @@ export default class Link extends Component {
97114

98115
// 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
99116
if (child.type === 'a' && !('href' in child.props)) {
100-
props.href = this.props.as || this.props.href
117+
props.href = as || href
101118
}
102119

103120
return React.cloneElement(child, props)
104121
}
105122
}
106123

107124
function isLocal (href) {
108-
const origin = getLocationOrigin()
109-
return !/^(https?:)?\/\//.test(href) ||
110-
origin === href.substr(0, origin.length)
125+
const url = parse(href, false, true)
126+
const origin = parse(getLocationOrigin(), false, true)
127+
return (!url.host || !url.hostname) ||
128+
(origin.host === url.host || origin.hostname === url.hostname)
111129
}
112130

113131
const warnLink = execOnce(warn)

lib/router/router.js

+6-1
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,12 @@ export default class Router extends EventEmitter {
128128
return this.change('replaceState', url, as, options)
129129
}
130130

131-
async change (method, url, as, options) {
131+
async change (method, _url, _as, options) {
132+
// If url and as provided as an object representation,
133+
// we'll format them into the string version here.
134+
const url = typeof _url === 'object' ? format(_url) : _url
135+
const as = typeof _as === 'object' ? format(_as) : _as
136+
132137
this.abortComponentLoad(as)
133138
const { pathname, query } = parse(url, true)
134139

readme.md

+39
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,27 @@ Each top-level component receives a `url` property with the following API:
271271

272272
The second `as` parameter for `push` and `replace` is an optional _decoration_ of the URL. Useful if you configured custom routes on the server.
273273

274+
##### With URL object
275+
276+
<p><details>
277+
<summary><b>Examples</b></summary>
278+
<ul>
279+
<li><a href="./examples/with-url-object-routing">With URL Object Routing</a></li>
280+
</ul>
281+
</details></p>
282+
283+
The component `<Link>` can also receive an URL object and it will automatically format it to create the URL string.
284+
285+
```jsx
286+
// pages/index.js
287+
import Link from 'next/link'
288+
export default () => (
289+
<div>Click <Link href={{ pathname: 'about', query: { name: 'Zeit' }}}<a>here</a></Link> to read more</div>
290+
)
291+
```
292+
293+
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).
294+
274295
#### Imperatively
275296

276297
<p><details>
@@ -303,6 +324,24 @@ The second `as` parameter for `push` and `replace` is an optional _decoration_ o
303324

304325
_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_
305326

327+
##### With URL object
328+
You can use an URL object the same way you use it in a `<Link>` component to `push` and `replace` an url.
329+
330+
```jsx
331+
import Router from 'next/router'
332+
333+
const handler = () => Router.push({
334+
pathname: 'about',
335+
query: { name: 'Zeit' }
336+
})
337+
338+
export default () => (
339+
<div>Click <span onClick={handler}>here</span> to read more</div>
340+
)
341+
```
342+
343+
This uses of the same exact parameters as in the `<Link>` component.
344+
306345
##### Router Events
307346

308347
You can also listen to different events happening inside the Router.

test/integration/basic/pages/nav/index.js

+21
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import Link from 'next/link'
22
import { Component } from 'react'
3+
import Router from 'next/router'
34

45
let counter = 0
56

@@ -13,13 +14,33 @@ export default class extends Component {
1314
this.forceUpdate()
1415
}
1516

17+
visitQueryStringPage () {
18+
const href = { pathname: '/nav/querystring', query: { id: 10 } }
19+
const as = { pathname: '/nav/querystring/10', hash: '10' }
20+
Router.push(href, as)
21+
}
22+
1623
render () {
1724
return (
1825
<div className='nav-home'>
1926
<Link href='/nav/about'><a id='about-link' style={linkStyle}>About</a></Link>
2027
<Link href='/empty-get-initial-props'><a id='empty-props' style={linkStyle}>Empty Props</a></Link>
2128
<Link href='/nav/self-reload'><a id='self-reload-link' style={linkStyle}>Self Reload</a></Link>
2229
<Link href='/nav/shallow-routing'><a id='shallow-routing-link' style={linkStyle}>Shallow Routing</a></Link>
30+
<Link
31+
href={{ pathname: '/nav/querystring', query: { id: 10 } }}
32+
as={{ pathname: '/nav/querystring/10', hash: '10' }}
33+
>
34+
<a id='query-string-link' style={linkStyle}>QueryString</a>
35+
</Link>
36+
<button
37+
onClick={() => this.visitQueryStringPage()}
38+
style={linkStyle}
39+
id='query-string-button'
40+
>
41+
Visit QueryString Page
42+
</button>
43+
2344
<p>This is the home.</p>
2445
<div id='counter'>
2546
Counter: {counter}

test/integration/basic/pages/nav/querystring.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export default class AsyncProps extends React.Component {
88

99
render () {
1010
return (
11-
<div>
11+
<div className='nav-querystring'>
1212
<Link href={`/nav/querystring?id=${parseInt(this.props.id) + 1}`}>
1313
<a id='next-id-link'>Click here</a>
1414
</Link>

test/integration/basic/test/client-navigation.js

+28
Original file line numberDiff line numberDiff line change
@@ -236,5 +236,33 @@ export default (context, render) => {
236236
browser.close()
237237
})
238238
})
239+
240+
describe('with URL objects', () => {
241+
it('should work with <Link/>', async () => {
242+
const browser = await webdriver(context.appPort, '/nav')
243+
const text = await browser
244+
.elementByCss('#query-string-link').click()
245+
.waitForElementByCss('.nav-querystring')
246+
.elementByCss('p').text()
247+
expect(text).toBe('10')
248+
249+
expect(await browser.url())
250+
.toBe(`http://localhost:${context.appPort}/nav/querystring/10#10`)
251+
browser.close()
252+
})
253+
254+
it('should work with "Router.push"', async () => {
255+
const browser = await webdriver(context.appPort, '/nav')
256+
const text = await browser
257+
.elementByCss('#query-string-button').click()
258+
.waitForElementByCss('.nav-querystring')
259+
.elementByCss('p').text()
260+
expect(text).toBe('10')
261+
262+
expect(await browser.url())
263+
.toBe(`http://localhost:${context.appPort}/nav/querystring/10#10`)
264+
browser.close()
265+
})
266+
})
239267
})
240268
}

0 commit comments

Comments
 (0)