diff --git a/packages/react-router-dom/modules/Link.js b/packages/react-router-dom/modules/Link.js
index 1d16c50b99..c0790bef3c 100644
--- a/packages/react-router-dom/modules/Link.js
+++ b/packages/react-router-dom/modules/Link.js
@@ -1,4 +1,5 @@
import React, { PropTypes } from 'react'
+import { resolveLocation } from 'react-router/resolve'
const isModifiedEvent = (event) =>
!!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey)
@@ -7,11 +8,15 @@ const isModifiedEvent = (event) =>
* The public API for rendering a router-aware .
*/
class Link extends React.Component {
+
static contextTypes = {
router: PropTypes.shape({
push: PropTypes.func.isRequired,
replace: PropTypes.func.isRequired,
- createHref: PropTypes.func.isRequired
+ createHref: PropTypes.func.isRequired,
+ match: PropTypes.shape({
+ url: PropTypes.string
+ })
}).isRequired
}
@@ -42,8 +47,8 @@ class Link extends React.Component {
event.preventDefault()
const { router } = this.context
- const { replace, to } = this.props
-
+ const { replace } = this.props
+ const to = this.absolutePathname()
if (replace) {
router.replace(to)
} else {
@@ -52,11 +57,28 @@ class Link extends React.Component {
}
}
+ absolutePathname() {
+ const { to } = this.props
+ const { match } = this.context.router
+ const base = (match && match.url) ? match.url : ''
+ return resolveLocation(to, base)
+ }
+
+ componentWillMount() {
+ const { router } = this.context
+ this.unlisten = router.listen(() => this.forceUpdate())
+ }
+
+ componentWillUnmount() {
+ this.unlisten()
+ }
+
render() {
+ const { router } = this.context
const { replace, to, ...props } = this.props // eslint-disable-line no-unused-vars
-
- const href = this.context.router.createHref(
- typeof to === 'string' ? { pathname: to } : to
+ const absoluteTo = this.absolutePathname()
+ const href = router.createHref(
+ typeof absoluteTo === 'string' ? { pathname: absoluteTo } : absoluteTo
)
return
diff --git a/packages/react-router-dom/modules/__tests__/Link-test.js b/packages/react-router-dom/modules/__tests__/Link-test.js
index 78ce53f5d1..d2dcf61a81 100644
--- a/packages/react-router-dom/modules/__tests__/Link-test.js
+++ b/packages/react-router-dom/modules/__tests__/Link-test.js
@@ -1,7 +1,10 @@
import expect from 'expect'
import React from 'react'
import ReactDOM from 'react-dom'
+import { Simulate } from 'react-addons-test-utils'
import MemoryRouter from 'react-router/MemoryRouter'
+import Route from 'react-router/Route'
+import Switch from 'react-router/Switch'
import HashRouter from '../HashRouter'
import Link from '../Link'
@@ -89,3 +92,90 @@ describe('A underneath a ', () => {
})
})
})
+
+describe('A with a relative to', () => {
+ const node = document.createElement('div')
+
+ afterEach(() => {
+ ReactDOM.unmountComponentAtNode(node)
+ })
+
+ it('resolves using the parent match', () => {
+ const initialEntries = ['/', '/recipes']
+ ReactDOM.render((
+
+ (
+ Chess
+ )} />
+
+ ), node)
+ const a = node.getElementsByTagName('a')[0]
+ expect(a.pathname).toBe('/recipes/tacos')
+ })
+
+ it('works when not in a route', () => {
+ const initialEntries = ['/']
+ ReactDOM.render((
+
+ Recipes
+
+ ), node)
+ const a = node.getElementsByTagName('a')[0]
+ expect(a.pathname).toBe('/recipes')
+ })
+
+ it('navigates correctly', () => {
+ const initialEntries = ['/', '/recipes']
+ const RESTAURANTS = 'RESTAURANTS'
+ ReactDOM.render((
+
+
+ (
+ Order Takeout
+ )} />
+ (
+ {RESTAURANTS}
+ )} />
+
+
+ ), node)
+ expect(node.textContent).toNotContain(RESTAURANTS)
+ const a = node.getElementsByTagName('a')[0]
+ Simulate.click(a, {
+ defaultPrevented: false,
+ preventDefault() { this.defaultPrevented = true },
+ metaKey: null,
+ altKey: null,
+ ctrlKey: null,
+ shiftKey: null,
+ button: 0
+ })
+ expect(node.textContent).toContain(RESTAURANTS)
+ })
+
+ it('updates regardless of sCU blocks', () => {
+ let goForward
+ class UpdateBlocker extends React.Component {
+ shouldComponentUpdate() {
+ return false
+ }
+ render() {
+ goForward = this.props.goForward
+ return
+ }
+ }
+
+ ReactDOM.render((
+
+
+
+ ), node)
+
+ let href = node.querySelector('a').getAttribute('href')
+ expect(href).toEqual('/bubblegum/store')
+ goForward()
+
+ href = node.querySelector('a').getAttribute('href')
+ expect(href).toEqual('/shoelaces/store')
+ })
+})
diff --git a/packages/react-router-website/modules/components/ExampleRouter.js b/packages/react-router-website/modules/components/ExampleRouter.js
index a23b496e3d..7ed6c8603b 100644
--- a/packages/react-router-website/modules/components/ExampleRouter.js
+++ b/packages/react-router-website/modules/components/ExampleRouter.js
@@ -1,7 +1,40 @@
-import React from 'react'
+import React, { PropTypes } from 'react'
-const ExampleRouter = ({ children }) => (
- children ? React.Children.only(children) : null
-)
+class ExampleRouter extends React.Component {
+
+ static contextTypes = {
+ router: PropTypes.object.isRequired
+ }
+
+ static childContextTypes = {
+ router: PropTypes.object.isRequired
+ }
+
+ getChildContext() {
+ return {
+ router: this.router
+ }
+ }
+
+ componentWillMount() {
+ this.router = {
+ ...this.context.router,
+ match: null
+ }
+
+ this.unlisten = this.router.listen(() => {
+ this.router.location = this.context.router.location
+ })
+ }
+
+ componentWillUnmount() {
+ this.unlisten()
+ }
+
+ render() {
+ const { children } = this.props
+ return children ? React.Children.only(children) : null
+ }
+}
export default ExampleRouter
diff --git a/packages/react-router/modules/Redirect.js b/packages/react-router/modules/Redirect.js
index 00accac0cf..1280d23862 100644
--- a/packages/react-router/modules/Redirect.js
+++ b/packages/react-router/modules/Redirect.js
@@ -1,4 +1,5 @@
import React, { PropTypes } from 'react'
+import { resolveLocation } from './resolve'
/**
* The public API for updating the location programatically
@@ -9,7 +10,10 @@ class Redirect extends React.Component {
router: PropTypes.shape({
push: PropTypes.func.isRequired,
replace: PropTypes.func.isRequired,
- staticContext: PropTypes.object
+ staticContext: PropTypes.object,
+ match: PropTypes.shape({
+ url: PropTypes.string
+ })
}).isRequired
}
@@ -38,11 +42,12 @@ class Redirect extends React.Component {
perform() {
const { router } = this.context
const { push, to } = this.props
-
+ const { match } = router
+ const loc = resolveLocation(to, match && match.url ? match.url : '')
if (push) {
- router.push(to)
+ router.push(loc)
} else {
- router.replace(to)
+ router.replace(loc)
}
}
diff --git a/packages/react-router/modules/Route.js b/packages/react-router/modules/Route.js
index 451427cfb9..4ac54264f6 100644
--- a/packages/react-router/modules/Route.js
+++ b/packages/react-router/modules/Route.js
@@ -3,7 +3,12 @@ import React, { PropTypes } from 'react'
import matchPath from './matchPath'
const computeMatch = (router, { computedMatch, path, exact, strict }) =>
- computedMatch || matchPath(router.location.pathname, path, { exact, strict })
+ computedMatch || matchPath(
+ router.location.pathname,
+ path,
+ { exact, strict },
+ router.match
+ )
/**
* The public API for matching a single path and rendering.
@@ -51,7 +56,8 @@ class Route extends React.Component {
static contextTypes = {
router: PropTypes.shape({
- listen: PropTypes.func.isRequired
+ listen: PropTypes.func.isRequired,
+ match: PropTypes.object
}).isRequired
}
@@ -98,7 +104,7 @@ class Route extends React.Component {
componentWillReceiveProps(nextProps) {
Object.assign(this.router, {
- match: computeMatch(this.router, nextProps)
+ match: computeMatch(this.context.router, nextProps)
})
}
diff --git a/packages/react-router/modules/Switch.js b/packages/react-router/modules/Switch.js
index c7627685ff..643341357e 100644
--- a/packages/react-router/modules/Switch.js
+++ b/packages/react-router/modules/Switch.js
@@ -7,7 +7,8 @@ import matchPath from './matchPath'
class Switch extends React.Component {
static contextTypes = {
router: PropTypes.shape({
- listen: PropTypes.func.isRequired
+ listen: PropTypes.func.isRequired,
+ match: PropTypes.object
}).isRequired
}
@@ -41,12 +42,18 @@ class Switch extends React.Component {
render() {
const { children } = this.props
const { location } = this.state
- const routes = React.Children.toArray(children)
+ const { match:parentMatch } = this.context.router
+ const routes = React.Children.toArray(children)
let route, match
for (let i = 0, length = routes.length; match == null && i < length; ++i) {
route = routes[i]
- match = matchPath(location.pathname, route.props.path, route.props)
+ match = matchPath(
+ location.pathname,
+ route.props.path,
+ route.props,
+ parentMatch
+ )
}
return match ? React.cloneElement(route, { computedMatch: match }) : null
diff --git a/packages/react-router/modules/__tests__/Redirect-test.js b/packages/react-router/modules/__tests__/Redirect-test.js
new file mode 100644
index 0000000000..a609e0c0bc
--- /dev/null
+++ b/packages/react-router/modules/__tests__/Redirect-test.js
@@ -0,0 +1,34 @@
+import expect from 'expect'
+import React from 'react'
+import ReactDOM from 'react-dom'
+import createMemoryHistory from 'history/createMemoryHistory'
+import Router from '../Router'
+import Redirect from '../Redirect'
+
+describe('Redirect', () => {
+ const createHistoryMock = () => {
+ const pushes = []
+ const replaces = []
+ const history = createMemoryHistory()
+ history.push = (loc) => pushes.push(loc)
+ history.replace = (loc) => replaces.push(loc)
+ history.getResults = () => ({ replaces, pushes })
+ return history
+ }
+
+ it('works with relative paths', () => {
+ const div = document.createElement('div')
+ const REDIRECTED = 'REDIRECTED'
+ const history = createHistoryMock()
+ ReactDOM.render((
+
+
+
+ ), div, () => {
+ const { pushes, replaces } = history.getResults()
+ expect(pushes.length).toEqual(1)
+ expect(pushes[0]).toEqual('/' + REDIRECTED)
+ expect(replaces.length).toEqual(0)
+ })
+ })
+})
\ No newline at end of file
diff --git a/packages/react-router/modules/__tests__/Route-test.js b/packages/react-router/modules/__tests__/Route-test.js
index 7fac62affd..afb70e345b 100644
--- a/packages/react-router/modules/__tests__/Route-test.js
+++ b/packages/react-router/modules/__tests__/Route-test.js
@@ -115,4 +115,77 @@ describe('A ', () => {
expect(node.innerHTML).toNotContain(TEXT)
})
+
+})
+
+describe('A with a relative path', () => {
+ it('resolves using the parent match', () => {
+ const initialEntries = ['/', '/recipes', '/recipes/pizza']
+ const TEXT = 'TEXT'
+ const node = document.createElement('div')
+ ReactDOM.render((
+
+ (
+ {TEXT}
} />
+ )} />
+
+ ), node)
+ expect(node.textContent).toContain(TEXT)
+ })
+
+ it('works when match is null', () => {
+ const initialEntries = ['/', '/recipes']
+ const TEXT = 'TEXT'
+ const node = document.createElement('div')
+ ReactDOM.render((
+
+ {TEXT}
} />
+
+ ), node)
+ expect(node.textContent).toContain(TEXT)
+ })
+
+ it('works when path is empty string', () => {
+ const initialEntries = ['/hello']
+ const TEXT = 'TEXT'
+ const node = document.createElement('div')
+ ReactDOM.render((
+
+ (
+ {TEXT}
} />
+ )} />
+
+ ), node)
+ expect(node.textContent).toContain(TEXT)
+ })
+
+ it('works with pathless routes', () => {
+ const initialEntries = ['/', '/pizza']
+ const TEXT = 'TEXT'
+ const node = document.createElement('div')
+ ReactDOM.render((
+
+ (
+ {TEXT}
} />
+ )} />
+
+ ), node)
+ expect(node.textContent).toContain(TEXT)
+ })
+
+ it('inherits params from its parent match', () => {
+ const FOOD = 'pizza'
+ const TOPPING = 'pineapple'
+ const initialEntries = [`/${FOOD}/${TOPPING}`]
+ const TEXT = 'TEXT'
+ const node = document.createElement('div')
+ ReactDOM.render((
+
+ (
+ {FOOD} {TOPPING}
} />
+ )} />
+
+ ), node)
+ expect(node.textContent).toContain(`${FOOD} ${TOPPING}`)
+ })
})
diff --git a/packages/react-router/modules/__tests__/Switch-test.js b/packages/react-router/modules/__tests__/Switch-test.js
index 0cd60effe1..f05f16b77d 100644
--- a/packages/react-router/modules/__tests__/Switch-test.js
+++ b/packages/react-router/modules/__tests__/Switch-test.js
@@ -59,4 +59,19 @@ describe('A ', () => {
expect(node.innerHTML).toNotContain('one')
expect(node.innerHTML).toContain('two')
})
+
+ it('works with relative Routes', () => {
+ const node = document.createElement('div')
+
+ const initialEntries = ['/recipes']
+ const TEXT = 'TEXT'
+ ReactDOM.render((
+
+
+ {TEXT}
} />
+
+
+ ), node)
+ expect(node.textContent).toContain(TEXT)
+ })
})
diff --git a/packages/react-router/modules/__tests__/resolve-test.js b/packages/react-router/modules/__tests__/resolve-test.js
new file mode 100644
index 0000000000..0d90eee34c
--- /dev/null
+++ b/packages/react-router/modules/__tests__/resolve-test.js
@@ -0,0 +1,152 @@
+import expect from 'expect'
+import { resolveLocation, simpleResolve } from '../resolve'
+
+describe('resolve', () => {
+
+ describe('resolveLocation', () => {
+ const BASE = '/a/b'
+
+ describe('empty base', () => {
+ const cases = [
+ ['', '/'],
+ ['recipes', '/recipes'],
+ ['/restaurants', '/restaurants'],
+ ['.', '/'],
+ ['..', '/'],
+ ['../', '/']
+ ]
+
+ it('works when base is empty string', () => {
+ const BASE = ''
+ cases.forEach(([input, output]) => {
+ expect(resolveLocation(input, BASE)).toEqual(output)
+ })
+ })
+
+ it('works when base is null', () => {
+ const BASE = null
+ cases.forEach(([input, output]) => {
+ expect(resolveLocation(input, BASE)).toEqual(output)
+ })
+ })
+
+ it('works when base is undefined', () => {
+ const BASE = undefined
+ cases.forEach(([input, output]) => {
+ expect(resolveLocation(input, BASE)).toEqual(output)
+ })
+ })
+ })
+
+ describe('string location', () => {
+ it('returns a string', () => {
+ const path = 'recipes'
+ expect(resolveLocation(path, BASE)).toBeA('string')
+ })
+
+ it('works for absolute pathnames', () => {
+ const path = '/recipes'
+ expect(resolveLocation(path, BASE)).toBe(path)
+ })
+
+ it('resolves to base when there is no pathname', () => {
+ expect(resolveLocation(undefined, BASE)).toEqual(BASE)
+ })
+ })
+
+ describe('object location', () => {
+ it('returns an object with a pathname', () => {
+ const location = { pathname: 'recipes' }
+ const retLocation = resolveLocation(location)
+ expect(retLocation).toBeA('object')
+ expect(retLocation).toIncludeKey('pathname')
+ })
+
+ it('works for absolute pathname', () => {
+ const descriptor = { pathname: '/recipes' }
+ expect(resolveLocation(descriptor).pathname).toBe(descriptor.pathname)
+ })
+
+ it('resolves to base when there is no pathname', () => {
+ const descriptors = [
+ { query: {a: 'b'} },
+ { query: {} },
+ { search: '?a=b' },
+ { hash: '#recipes' }
+ ]
+ descriptors.forEach(d => {
+ expect(resolveLocation(d, BASE).pathname).toBe(BASE)
+ })
+ })
+ })
+
+ describe('slash strategy', () => {
+ it('preserves pathnames slash strategy', () => {
+ const cases = [
+ ['recipes/', '/a/b/recipes/'],
+ ['recipes', '/a/b/recipes'],
+ ['../', '/a/']
+ ]
+ cases.forEach(([input, output]) => {
+ expect(resolveLocation(input, BASE)).toEqual(output)
+ })
+ })
+
+ it('removes trailing slash when pathname is removed by dot notation', () => {
+ const cases = [
+ ['restaurants/..', '/a/b'],
+ ['restaurants/.', '/a/b/restaurants']
+ ]
+ cases.forEach(([input, output]) => {
+ expect(resolveLocation(input, BASE)).toEqual(output)
+ })
+ })
+ })
+ })
+
+ describe('simpleResolve', () => {
+ it('returns pathname if it is absolute', () => {
+ const pathname = '/recipes'
+ expect(simpleResolve(pathname, '/base')).toEqual(pathname)
+ })
+
+ it('joins pathname with the base', () => {
+ const pathname = 'recipes'
+ expect(simpleResolve(pathname, '/base')).toEqual('/base/recipes')
+ })
+
+ it('correctly joins when base has a trailing slash', () => {
+ const pathname = 'recipes'
+ expect(simpleResolve(pathname, '/base/')).toEqual('/base/recipes')
+ })
+
+ describe('empty base', () => {
+ const cases = [
+ ['', '/'],
+ ['recipes', '/recipes'],
+ ['/restaurants', '/restaurants']
+ ]
+
+ it('works when base is empty string', () => {
+ const BASE = ''
+ cases.forEach(([input, output]) => {
+ expect(simpleResolve(input, BASE)).toEqual(output)
+ })
+ })
+
+ it('works when base is null', () => {
+ const BASE = null
+ cases.forEach(([input, output]) => {
+ expect(simpleResolve(input, BASE)).toEqual(output)
+ })
+ })
+
+ it('works when base is undefined', () => {
+ const BASE = undefined
+ cases.forEach(([input, output]) => {
+ expect(simpleResolve(input, BASE)).toEqual(output)
+ })
+ })
+ })
+ })
+})
diff --git a/packages/react-router/modules/matchPath.js b/packages/react-router/modules/matchPath.js
index ccc1f6824a..db86cc1fbd 100644
--- a/packages/react-router/modules/matchPath.js
+++ b/packages/react-router/modules/matchPath.js
@@ -1,4 +1,5 @@
import pathToRegexp from 'path-to-regexp'
+import { simpleResolve } from './resolve'
const patternCache = {}
const cacheLimit = 10000
@@ -26,11 +27,13 @@ const compilePath = (pattern, options) => {
/**
* Public API for matching a URL pathname to a path pattern.
*/
-const matchPath = (pathname, path, options = {}) => {
+const matchPath = (pathname, path, options = {}, parent = null) => {
const { exact = false, strict = false } = options
if (!path)
- return { url: pathname, isExact: true, params: {} }
+ return { url: parent ? parent.url : '/', isExact: true, params: {} }
+
+ path = simpleResolve(path, parent && parent.url ? parent.url : '')
const { re, keys } = compilePath(path, { end: exact, strict })
const match = re.exec(pathname)
@@ -44,14 +47,19 @@ const matchPath = (pathname, path, options = {}) => {
if (exact && !isExact)
return null
+ const params = Object.assign({},
+ parent && parent.params,
+ keys.reduce((memo, key, index) => {
+ memo[key.name] = values[index]
+ return memo
+ }, {})
+ )
+
return {
path, // the path pattern used to match
url: path === '/' && url === '' ? '/' : url, // the matched portion of the URL
isExact, // whether or not we matched exactly
- params: keys.reduce((memo, key, index) => {
- memo[key.name] = values[index]
- return memo
- }, {})
+ params
}
}
diff --git a/packages/react-router/modules/resolve.js b/packages/react-router/modules/resolve.js
new file mode 100644
index 0000000000..e3b9c69226
--- /dev/null
+++ b/packages/react-router/modules/resolve.js
@@ -0,0 +1,60 @@
+import resolvePathname from 'resolve-pathname'
+
+const hasTrailingSlash = pathname => {
+ if (pathname == null) {
+ return false
+ }
+ return pathname.charAt(pathname.length-1) === '/'
+}
+
+const withTrailingSlash = pathname => {
+ if (pathname == null) {
+ return '/'
+ }
+ return hasTrailingSlash(pathname) ? pathname : pathname + '/'
+}
+
+const stripTrailingSlash = pathname => {
+ if (pathname == null) {
+ return '/'
+ }
+ return pathname.length > 1 && hasTrailingSlash(pathname) ?
+ pathname.slice(0, -1) : pathname
+}
+
+const isAbsolute = pathname => !!(pathname && pathname.charAt(0) === '/')
+
+const sameSlashStrategy = (pathname, toSlash, fromSlash) =>
+ !toSlash && !fromSlash ? stripTrailingSlash(pathname) : pathname
+
+export const resolveLocation = (location, base) => {
+ if (location === undefined) {
+ return base
+ } else if (typeof location === 'string') {
+ return resolve(location, base)
+ } else {
+ location.pathname = resolve(location.pathname, base)
+ return location
+ }
+}
+
+const resolve = (to, from = '') => {
+ const toSlash = hasTrailingSlash(to)
+ const fromSlash = hasTrailingSlash(from)
+ const pathname = resolvePathname(to, withTrailingSlash(from))
+ return sameSlashStrategy(pathname, toSlash, fromSlash)
+}
+
+export const simpleResolve = (pathname, base) => {
+ if (base == null || base === '') {
+ base = '/'
+ }
+
+ if (pathname === undefined || isAbsolute(pathname)) {
+ return pathname
+ } else if (pathname === '') {
+ return base
+ } else {
+ return `${withTrailingSlash(base)}${pathname}`
+ }
+}
diff --git a/packages/react-router/package.json b/packages/react-router/package.json
index 14dd9ed79d..462bbd9913 100644
--- a/packages/react-router/package.json
+++ b/packages/react-router/package.json
@@ -19,6 +19,7 @@
"index.js",
"matchPath.js",
"withRouter.js",
+ "resolve.js",
"README.md",
"es",
"umd"
@@ -45,6 +46,7 @@
"invariant": "^2.2.2",
"loose-envify": "^1.3.1",
"path-to-regexp": "^1.5.3",
+ "resolve-pathname": "^2.0.2",
"warning": "^3.0.0"
},
"devDependencies": {