diff --git a/modules/Link.js b/modules/Link.js index e47f7cf096..704f28ad8c 100644 --- a/modules/Link.js +++ b/modules/Link.js @@ -1,4 +1,5 @@ import React, { PropTypes } from 'react' +import { resolveLocation } from './LocationUtils' class Link extends React.Component { static defaultProps = { @@ -56,8 +57,8 @@ class Link extends React.Component { event.preventDefault() const { router } = this.context - const { to, replace } = this.props - + const { replace } = this.props + const to = this.absoluteLocation() if (replace) { router.replaceWith(to) } else { @@ -66,8 +67,21 @@ class Link extends React.Component { } } + absoluteLocation = () => { + const { router } = this.context + let { to } = this.props + let base + // use the context.router's match if it exists + if (router.match) { + const matchState = router.match.getState() + base = matchState && matchState.match.pathname + } + return resolveLocation(to, base) + } + getIsActive() { - const { to, isActive } = this.props + const { isActive } = this.props + const to = this.absoluteLocation() return isActive( this.context.router.getState().location, @@ -79,7 +93,7 @@ class Link extends React.Component { render() { const { isActive } = this.state const { - to, + to: undefTo, // eslint-disable-line style, activeStyle, className, activeClassName, activeOnlyWhenExact, // eslint-disable-line @@ -88,6 +102,8 @@ class Link extends React.Component { ...rest } = this.props + const to = this.absoluteLocation() + return ( { ) }) } + +export const resolveLocation = (location, base) => { + if (!isRelative(location)) { + return location + } + + if (typeof location === 'string') { + return resolve(location, base) + } else { + location.pathname = resolve(location.pathname, base) + return location + } +} + +const isRelative = (location) => { + if (typeof location === 'string') { + return location.charAt(0) !== '/' + } else { + const { pathname, query, search, hash } = location + // If there is no pathname, the location should still be + // considered relative if it has a query, search, or hash + // (but not an empty query) + if (!pathname) { + return (query && Object.keys(query).length) || !!search || !!hash + } + return pathname.charAt(0) !== '/' + } +} + +// works similarly, but not exactly the same as RFC 1808 +// https://tools.ietf.org/html/rfc1808#section-4 +// base is the base URL and path is the URL to resolve. +// this differentiates from the RFC because it treats +// the url pattern "foo/bar" the same as "foo/bar/" where the +// relative path will be joined after "bar" +const resolve = (path, base = '') => { + if (path === undefined) { + return base + } else if (base === '') { + return '/' + path + } + + // RFC 1808 drops the last base segment (step 6) and joins off + // of its parent. This does not happen in here because the last + // segment of the base because we want to join off of the last + // segment. If the base ends in a forward slash, strip it so that + // we join off of the segment before that instead. + if (base[base.length-1] === '/') { + base = base.slice(0,-1) + } + + const baseSegments = splitSegments(base) + const { pathname, extra } = removeExtra(path) + // don't need to resolve if there is no pathname + if (pathname === '') { + return base + extra + } + + // filter out all ./ segments (step 6.a) + const relativeSegments = splitSegments(pathname).filter(s => s !== '.') + return joinSegments([...baseSegments, ...relativeSegments]) + extra +} + +// remove any '//' from the path because this doesn't mean anything +// and will interfere in replacement +const splitSegments = (path) => path.replace('//','/').split('/') + +// if the original location was a string (not a descriptor), then the search +// or hash can still be attached to the path, so remove it before resolving +const removeExtra = (path) => { + const hashIndex = path.indexOf('#') + const searchIndex = path.indexOf('?') + let splitIndex + if (searchIndex >= 0 && hashIndex >= 0) { + splitIndex = Math.min(searchIndex, hashIndex) + } else if (searchIndex >=0) { + splitIndex = searchIndex + } else if (hashIndex >= 0) { + splitIndex = hashIndex + } + + return { + pathname: splitIndex !== undefined ? path.slice(0, splitIndex) : path, + extra: splitIndex !== undefined ? path.slice(splitIndex) : '' + } +} + +const joinSegments = (segments) => { + // (step 6.c) + const output = [] + const length = segments.length + for (let i=0; i/.. but not ../.. (or /.., but the only empty + // string segment should be the root segment). To do this, + // we want to verify that the last kept item is not a '..' or a ''. + // If it is a '..', that means that we already failed to match + // (['', 'foo', '..'] will never occur in the output array) + const last = output[output.length-1] + if (last !== '..' && last !== '') { + output.pop() + } else { + output.push('..') + } + } + return output.join('/') +} diff --git a/modules/Redirect.js b/modules/Redirect.js index 2d74a1d728..b54cce7277 100644 --- a/modules/Redirect.js +++ b/modules/Redirect.js @@ -1,4 +1,5 @@ import React, { PropTypes } from 'react' +import { resolveLocation } from './LocationUtils' class Redirect extends React.Component { static defaultProps = { @@ -34,7 +35,14 @@ class Redirect extends React.Component { redirect() { const { router } = this.context - const { to, push } = this.props + let { to } = this.props + const { push } = this.props + let base + if (router.match) { + const matchState = router.match.getState() + base = matchState && matchState.match.pathname + } + to = resolveLocation(to, base) const navigate = push ? router.transitionTo : router.replaceWith navigate(to) } diff --git a/modules/__tests__/HashRouter-test.js b/modules/__tests__/HashRouter-test.js index 1630d6ed00..a38c5cf5b6 100644 --- a/modules/__tests__/HashRouter-test.js +++ b/modules/__tests__/HashRouter-test.js @@ -8,6 +8,7 @@ describe('HashRouter', () => { const div = document.createElement('div') afterEach(() => { + history.replaceState(null, document.title, '#') unmountComponentAtNode(div) }) diff --git a/modules/__tests__/Link-test.js b/modules/__tests__/Link-test.js index 33733d84ac..f7e4959227 100644 --- a/modules/__tests__/Link-test.js +++ b/modules/__tests__/Link-test.js @@ -4,6 +4,7 @@ import Link from '../Link' import MemoryRouter from '../MemoryRouter' import StaticRouter from '../StaticRouter' import { render, unmountComponentAtNode } from 'react-dom' +import Match from '../Match' import { Simulate } from 'react-addons-test-utils' const { click } = Simulate @@ -29,6 +30,40 @@ describe('Link', () => { expect(div.querySelector('a').getAttribute('href')).toEqual('/foo') }) + describe('relative path', () => { + it('works with search/hash pathnames', () => { + const pathname = 'test?this/../isfine' + const div = document.createElement('div') + render(, div) + expect(div.querySelector('a').getAttribute('href')).toEqual(`/${pathname}`) + }) + + describe('with context.match', () => { + const BASE = '/a/b' + const LinkInMatch = ({pattern = BASE, ...props}) => ( + + ( + + )} /> + + ) + + it('resolves using parent pathname', () => { + const div = document.createElement('div') + render(, div) + expect(div.querySelector('a').getAttribute('href')).toEqual('/a/b/foo') + }) + }) + + describe('without context.match', () => { + it('resolves using root', () => { + const div = document.createElement('div') + render(, div) + expect(div.querySelector('a').getAttribute('href')).toEqual('/foo') + }) + }) + }) + describe('with context.router', () => { it('uses router.createHref to build the href', () => { const CONTEXT_HREF = 'CONTEXT_HREF' @@ -223,6 +258,19 @@ describe('Link', () => { const a = div.querySelector('a') expect(a.className).toEqual('active') }) + + it('works with relative links', () => { + const div = document.createElement('div') + render(( + + ), div) + const a = div.querySelector('a') + expect(a.className).toEqual('active') + }) }) }) diff --git a/modules/__tests__/LocationUtils-test.js b/modules/__tests__/LocationUtils-test.js index 4f1390f499..74ec75cd0e 100644 --- a/modules/__tests__/LocationUtils-test.js +++ b/modules/__tests__/LocationUtils-test.js @@ -1,6 +1,10 @@ import expect from 'expect' import { parse, stringify } from 'query-string' -import { createRouterLocation, createRouterPath } from '../LocationUtils' +import { + createRouterLocation, + createRouterPath, + resolveLocation +} from '../LocationUtils' describe('LocationUtils', () => { describe('createRouterLocation', () => { @@ -195,4 +199,109 @@ describe('LocationUtils', () => { }) }) }) + + describe('resolveLocation', () => { + const BASE = '/a/b' + + describe('string location', () => { + it('returns path if absolute', () => { + const path = '/foo' + expect(resolveLocation(path)).toBe(path) + }) + }) + + describe('object location', () => { + it('does not affect the pathname if absolute', () => { + const descriptor = { pathname: '/foo' } + expect(resolveLocation(descriptor).pathname).toBe(descriptor.pathname) + }) + + describe('no pathname', () => { + it('resolves to base if there is a query', () => { + const descriptor = { + query: {a: 'b'} + } + expect(resolveLocation(descriptor, BASE).pathname).toBe(BASE) + }) + + it('ignores empty query when determining if relative', () => { + const descriptor = { + query: {} + } + expect(resolveLocation(descriptor, BASE).pathname).toBe(undefined) + }) + + it('resolves to base if there is a query', () => { + const descriptor = { + search: '?a=b' + } + expect(resolveLocation(descriptor, BASE).pathname).toBe(BASE) + }) + + it('resolves to base if there is a hash', () => { + const descriptor = { + hash: '#foo' + } + expect(resolveLocation(descriptor, BASE).pathname).toBe(BASE) + }) + }) + }) + + it('removes unnecessary segments', () => { + const input = 'foo//../bar' + expect(resolveLocation(input, BASE)).toEqual('/a/b/bar') + }) + + describe('rfc1808', () => { + + // https://tools.ietf.org/html/rfc1808#section-5.1 + it('passes normal examples', () => { + const cases = [ + { input: 'g', output: '/a/b/g' }, + { input: './g', output: '/a/b/g' }, + { input: 'g/', output: '/a/b/g/' }, + { input: '/g', output: '/g' }, + { input: '?y', output: '/a/b?y' }, + { input: 'g?y', output: '/a/b/g?y' }, + { input: 'g?y/./x', output: '/a/b/g?y/./x' }, + { input: '#s', output: '/a/b#s' }, + { input: 'g#s', output: '/a/b/g#s' }, + { input: 'g#s/./x', output: '/a/b/g#s/./x' }, + { input: 'g?y#s', output: '/a/b/g?y#s' }, + { input: '.', output: '/a/b' }, + { input: './', output: '/a/b/' }, + { input: '..', output: '/a' }, + { input: '../', output: '/a/' }, + { input: '../g', output: '/a/g' }, + { input: '../..', output: '' }, + { input: '../../', output: '/' }, + { input: '../../g', output: '/g' } + ] + cases.forEach(test => { + expect(resolveLocation(test.input, BASE)).toBe(test.output) + }) + }) + + // https://tools.ietf.org/html/rfc1808#section-5.2 + it('passes abnormal examples', () => { + const cases = [ + { input: '../../g', output: '/g' }, + { input: '../../../g', output: '/../g' }, + { input: '/./g', output: '/./g' }, + { input: '/../g', output: '/../g' }, + { input: 'g.', output: '/a/b/g.' }, + { input: '.g', output: '/a/b/.g' }, + { input: 'g..', output: '/a/b/g..' }, + { input: '..g', output: '/a/b/..g' }, + { input: './../g', output: '/a/g' }, + { input: './g/.', output: '/a/b/g' }, + { input: 'g/./h', output: '/a/b/g/h' }, + { input: 'g/../h', output: '/a/b/h' } + ] + cases.forEach(test => { + expect(resolveLocation(test.input, BASE)).toBe(test.output) + }) + }) + }) + }) }) diff --git a/modules/__tests__/integration-test.js b/modules/__tests__/integration-test.js index d2b501179b..8fbef071fd 100644 --- a/modules/__tests__/integration-test.js +++ b/modules/__tests__/integration-test.js @@ -484,3 +484,44 @@ describe('Integration Tests', () => { }) }) }) + +describe('Link with relative to', () => { + it('navigates', () => { + const leftClickEvent = { + defaultPrevented: false, + preventDefault() { this.defaultPrevented = true }, + metaKey: null, + altKey: null, + ctrlKey: null, + shiftKey: null, + button: 0 + } + const div = document.createElement('div') + const TEXT1 = 'I AM PAGE 1' + const TEXT2 = 'I AM PAGE 2' + render(( + +
+ One + ( +
+

{TEXT1}

+ Two + ( +

{TEXT2}

+ )}/> +
+ )}/> +
+
+ ), div) + expect(div.innerHTML).toNotContain(TEXT1) + + Simulate.click(div.querySelector('#one'), leftClickEvent) + expect(div.innerHTML).toContain(TEXT1) + expect(div.innerHTML).toNotContain(TEXT2) + + Simulate.click(div.querySelector('#two'), leftClickEvent) + expect(div.innerHTML).toContain(TEXT2) + }) +}) diff --git a/modules/index.js b/modules/index.js index 26dad307a1..28e816491d 100644 --- a/modules/index.js +++ b/modules/index.js @@ -17,3 +17,6 @@ export StaticRouter from './StaticRouter' // Util for creating who-knows-what! export matchPattern from './matchPattern' + +// Util for resolving relative paths +export { resolveLocation } from './LocationUtils'