Skip to content

Commit ad61d74

Browse files
fix(vanilla): fix base path handling for vanilla push state
This fixes the vanilla PushState location service handling of `base` tags. This change attempts to match the behavior of https://html.spec.whatwg.org/dev/semantics.html#the-base-element If a base tag is '/base/index.html' and a state with a URL of '/foo' is activated, the URL will be '/base/foo'. If the url exactly matches the base tag, it will route to the state matching '/'. This also addresses a longstanding bug where base tags which didn't end in a slash (such as '/base') had their last character stripped (i.e., '/bas'), and therefore didn't work. Now base tags like that should work as described above. Closes #54 Closes angular-ui/ui-router#2357
1 parent a4629ee commit ad61d74

16 files changed

+349
-42
lines changed

karma.conf.js

+4-3
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,12 @@ module.exports = function (karma) {
2626
frameworks: ['jasmine'],
2727

2828
plugins: [
29-
require('karma-webpack'),
30-
require('karma-sourcemap-loader'),
29+
require('karma-chrome-launcher'),
30+
require('karma-firefox-launcher'),
3131
require('karma-jasmine'),
3232
require('karma-phantomjs-launcher'),
33-
require('karma-chrome-launcher')
33+
require('karma-sourcemap-loader'),
34+
require('karma-webpack'),
3435
],
3536

3637
webpack: {

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
"karma": "^1.2.0",
7575
"karma-chrome-launcher": "~0.1.0",
7676
"karma-coverage": "^0.5.3",
77+
"karma-firefox-launcher": "^1.0.1",
7778
"karma-jasmine": "^1.0.2",
7879
"karma-phantomjs-launcher": "^1.0.4",
7980
"karma-script-launcher": "~0.1.0",

src/common/strings.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -111,13 +111,20 @@ export function stringify(o: any) {
111111
}
112112

113113
/** Returns a function that splits a string on a character or substring */
114-
export const beforeAfterSubstr = (char: string) => (str: string) => {
114+
export const beforeAfterSubstr = (char: string) => (str: string): string[] => {
115115
if (!str) return ["", ""];
116116
let idx = str.indexOf(char);
117117
if (idx === -1) return [str, ""];
118118
return [str.substr(0, idx), str.substr(idx + 1)];
119119
};
120120

121+
export const hostRegex = new RegExp('^(?:[a-z]+:)?//[^/]+/');
122+
export const stripFile = (str: string) => str.replace(/\/[^/]*$/, '');
123+
export const splitHash = beforeAfterSubstr("#");
124+
export const splitQuery = beforeAfterSubstr("?");
125+
export const splitEqual = beforeAfterSubstr("=");
126+
export const trimHashVal = (str: string) => str ? str.replace(/^#/, "") : "";
127+
121128
/**
122129
* Splits on a delimiter, but returns the delimiters in the array
123130
*

src/url/urlRouter.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,12 @@ import { UrlRuleFactory } from './urlRule';
1414
import { TargetState } from '../state/targetState';
1515
import { MatcherUrlRule, MatchResult, UrlParts, UrlRule, UrlRuleHandlerFn, UrlRuleMatchFn, UrlRulesApi, UrlSyncApi, } from './interface';
1616
import { TargetStateDef } from '../state/interface';
17+
import { stripFile } from '../common';
1718

1819
/** @hidden */
1920
function appendBasePath(url: string, isHtml5: boolean, absolute: boolean, baseHref: string): string {
2021
if (baseHref === '/') return url;
21-
if (isHtml5) return baseHref.slice(0, -1) + url;
22+
if (isHtml5) return stripFile(baseHref) + url;
2223
if (absolute) return baseHref.slice(1) + url;
2324
return url;
2425
}

src/vanilla/browserLocationConfig.ts

+4-3
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,13 @@ export class BrowserLocationConfig implements LocationConfig {
3939
};
4040

4141
baseHref(href?: string): string {
42-
return isDefined(href) ? this._baseHref = href : this._baseHref || this.applyDocumentBaseHref();
42+
return isDefined(href) ? this._baseHref = href :
43+
isDefined(this._baseHref) ? this._baseHref : this.applyDocumentBaseHref();
4344
}
4445

4546
applyDocumentBaseHref() {
46-
let baseTags = document.getElementsByTagName("base");
47-
return this._baseHref = baseTags.length ? baseTags[0].href.substr(location.origin.length) : "";
47+
let baseTag: HTMLBaseElement = document.getElementsByTagName("base")[0];
48+
return this._baseHref = baseTag ? baseTag.href.substr(location.origin.length) : "";
4849
}
4950

5051
dispose() {}

src/vanilla/hashLocationService.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* @module vanilla
44
*/
55
/** */
6-
import { trimHashVal } from "./utils";
6+
import { trimHashVal } from "../common/strings";
77
import { UIRouter } from "../router";
88
import { BaseLocationServices } from "./baseLocationService";
99

src/vanilla/pushStateLocationService.ts

+26-6
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44
*/
55
/** */
66
import { LocationConfig } from "../common/coreservices";
7-
import { splitQuery, splitHash } from "./utils";
87
import { UIRouter } from "../router";
98
import { BaseLocationServices } from "./baseLocationService";
9+
import { splitQuery, splitHash, stripFile } from "../common/strings";
1010

1111
/**
1212
* A `LocationServices` that gets/sets the current location using the browser's `location` and `history` apis
@@ -22,21 +22,41 @@ export class PushStateLocationService extends BaseLocationServices {
2222
self.addEventListener("popstate", this._listener, false);
2323
};
2424

25+
/**
26+
* Gets the base prefix without:
27+
* - trailing slash
28+
* - trailing filename
29+
* - protocol and hostname
30+
*
31+
* If <base href='/base/index.html'>, this returns '/base'.
32+
* If <base href='http://localhost:8080/base/index.html'>, this returns '/base'.
33+
*
34+
* See: https://html.spec.whatwg.org/dev/semantics.html#the-base-element
35+
*/
36+
_getBasePrefix() {
37+
return stripFile(this._config.baseHref());
38+
}
39+
2540
_get() {
2641
let { pathname, hash, search } = this._location;
2742
search = splitQuery(search)[1]; // strip ? if found
2843
hash = splitHash(hash)[1]; // strip # if found
29-
return pathname + (search ? "?" + search : "") + (hash ? "$" + search : "");
44+
45+
const basePrefix = this._getBasePrefix();
46+
let exactMatch = pathname === this._config.baseHref();
47+
let startsWith = pathname.startsWith(basePrefix);
48+
pathname = exactMatch ? '/' : startsWith ? pathname.substring(basePrefix.length) : pathname;
49+
50+
return pathname + (search ? "?" + search : "") + (hash ? "#" + hash : "");
3051
}
3152

3253
_set(state: any, title: string, url: string, replace: boolean) {
33-
let { _config, _history } = this;
34-
let fullUrl = _config.baseHref() + url;
54+
let fullUrl = this._getBasePrefix() + url;
3555

3656
if (replace) {
37-
_history.replaceState(state, title, fullUrl);
57+
this._history.replaceState(state, title, fullUrl);
3858
} else {
39-
_history.pushState(state, title, fullUrl);
59+
this._history.pushState(state, title, fullUrl);
4060
}
4161
}
4262

src/vanilla/utils.ts

+3-18
Original file line numberDiff line numberDiff line change
@@ -3,25 +3,10 @@
33
* @module vanilla
44
*/
55
/** */
6-
import { isArray } from "../common/index";
7-
import { LocationServices, LocationConfig } from "../common/coreservices";
6+
import {
7+
LocationConfig, LocationServices, identity, unnestR, isArray, splitEqual, splitHash, splitQuery
8+
} from "../common";
89
import { UIRouter } from "../router";
9-
import { identity, unnestR, removeFrom, deregAll, extend } from "../common/common";
10-
import { LocationLike, HistoryLike } from "./interface";
11-
import { isDefined } from "../common/predicates";
12-
import { Disposable } from "../interface";
13-
14-
const beforeAfterSubstr = (char: string) => (str: string): string[] => {
15-
if (!str) return ["", ""];
16-
let idx = str.indexOf(char);
17-
if (idx === -1) return [str, ""];
18-
return [str.substr(0, idx), str.substr(idx + 1)];
19-
};
20-
21-
export const splitHash = beforeAfterSubstr("#");
22-
export const splitQuery = beforeAfterSubstr("?");
23-
export const splitEqual = beforeAfterSubstr("=");
24-
export const trimHashVal = (str) => str ? str.replace(/^#/, "") : "";
2510

2611
export const keyValsToObjectR = (accum, [key, val]) => {
2712
if (!accum.hasOwnProperty(key)) {

test/_testUtils.ts

+4
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ import { map } from "../src/common/common";
33

44
let stateProps = ["resolve", "resolvePolicy", "data", "template", "templateUrl", "url", "name", "params"];
55

6+
const initialUrl = document.location.href;
7+
export const resetBrowserUrl = () =>
8+
history.replaceState(null, null, initialUrl);
9+
610
export const delay = (ms) =>
711
new Promise<any>(resolve => setTimeout(resolve, ms));
812
export const _delay = (ms) => () => delay(ms);

test/index.js

+3
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
require('core-js');
55
require('../src/index');
66
require('./_matchers');
7+
var utils = require('./_testUtils');
78

89
var testsContext = require.context(".", true, /Spec$/);
910
testsContext.keys().forEach(testsContext);
11+
12+
afterAll(utils.resetBrowserUrl);

test/lazyLoadSpec.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { UrlRouter } from "../src/url/urlRouter";
66
import {StateDeclaration} from "../src/state/interface";
77
import {tail} from "../src/common/common";
88
import {Transition} from "../src/transition/transition";
9+
import { TestingPlugin } from './_testingPlugin';
910

1011
describe('future state', function () {
1112
let router: UIRouter;
@@ -16,8 +17,7 @@ describe('future state', function () {
1617

1718
beforeEach(() => {
1819
router = new UIRouter();
19-
router.plugin(vanilla.servicesPlugin);
20-
router.plugin(vanilla.hashLocationPlugin);
20+
router.plugin(TestingPlugin);
2121
$registry = router.stateRegistry;
2222
$state = router.stateService;
2323
$transitions = router.transitionService;

test/urlRouterSpec.ts

+129
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { UrlService } from "../src/url/urlService";
55
import { StateRegistry } from "../src/state/stateRegistry";
66
import { noop } from "../src/common/common";
77
import { UrlRule, MatchResult } from "../src/url/interface";
8+
import { pushStateLocationPlugin } from '../src/vanilla';
89

910
declare let jasmine;
1011
let _anything = jasmine.anything();
@@ -240,6 +241,134 @@ describe("UrlRouter", function () {
240241
let actual = urlRouter.href(matcher('/hello/:name'), { name: 'world', '#': 'frag' }, { absolute: true });
241242
expect(actual).toBe('http://localhost/#/hello/world#frag');
242243
});
244+
245+
describe('in html5mode', () => {
246+
let baseTag: HTMLBaseElement;
247+
const applyBaseTag = (href: string) => {
248+
baseTag = document.createElement('base');
249+
baseTag.href = href;
250+
document.head.appendChild(baseTag);
251+
};
252+
253+
afterEach(() => baseTag.parentElement.removeChild(baseTag));
254+
255+
beforeEach(() => {
256+
router.dispose(router.getPlugin('vanilla.memoryLocation'));
257+
router.plugin(pushStateLocationPlugin);
258+
router.urlService = new UrlService(router, false);
259+
});
260+
261+
describe('with base="/base/"', () => {
262+
beforeEach(() => applyBaseTag('/base/'));
263+
264+
it("should prefix the href with /base/", function () {
265+
expect(urlRouter.href(matcher("/foo"))).toBe('/base/foo');
266+
});
267+
268+
it('should include #fragments', function () {
269+
expect(urlRouter.href(matcher("/foo"), { '#': "hello"})).toBe('/base/foo#hello');
270+
});
271+
272+
it('should return absolute URLs', function () {
273+
// don't use urlService var
274+
const cfg = router.urlService.config;
275+
const href = urlRouter.href(matcher('/hello/:name'), { name: 'world', '#': 'frag' }, { absolute: true });
276+
const prot = cfg.protocol();
277+
const host = cfg.host();
278+
const port = cfg.port();
279+
let portStr = (port === 80 || port === 443) ? '' : `:${port}`;
280+
expect(href).toBe(`${prot}://${host}${portStr}/base/hello/world#frag`);
281+
});
282+
});
283+
284+
describe('with base="/base/index.html"', () => {
285+
beforeEach(() => applyBaseTag('/base/index.html'));
286+
287+
it("should prefix the href with /base/ but not with index.html", function () {
288+
expect(urlRouter.href(matcher("/foo"))).toBe('/base/foo');
289+
});
290+
291+
it('should include #fragments', function () {
292+
expect(urlRouter.href(matcher("/foo"), { '#': "hello"})).toBe('/base/foo#hello');
293+
});
294+
295+
it('should return absolute URLs', function () {
296+
// don't use urlService var
297+
const cfg = router.urlService.config;
298+
const href = urlRouter.href(matcher('/hello/:name'), { name: 'world', '#': 'frag' }, { absolute: true });
299+
const prot = cfg.protocol();
300+
const host = cfg.host();
301+
const port = cfg.port();
302+
let portStr = (port === 80 || port === 443) ? '' : `:${port}`;
303+
expect(href).toBe(`${prot}://${host}${portStr}/base/hello/world#frag`);
304+
});
305+
});
306+
307+
describe('with base="http://localhost:8080/base/"', () => {
308+
beforeEach(() => applyBaseTag('http://localhost:8080/base/'));
309+
310+
it("should prefix the href with /base/", function () {
311+
expect(urlRouter.href(matcher("/foo"))).toBe('/base/foo');
312+
});
313+
314+
it('should include #fragments', function () {
315+
expect(urlRouter.href(matcher("/foo"), { '#': "hello"})).toBe('/base/foo#hello');
316+
});
317+
318+
it('should return absolute URLs', function () {
319+
// don't use urlService var
320+
const cfg = router.urlService.config;
321+
const href = urlRouter.href(matcher('/hello/:name'), { name: 'world', '#': 'frag' }, { absolute: true });
322+
const prot = cfg.protocol();
323+
const host = cfg.host();
324+
const port = cfg.port();
325+
let portStr = (port === 80 || port === 443) ? '' : `:${port}`;
326+
expect(href).toBe(`${prot}://${host}${portStr}/base/hello/world#frag`);
327+
});
328+
});
329+
330+
describe('with base="http://localhost:8080/base"', () => {
331+
beforeEach(() => applyBaseTag('http://localhost:8080/base'));
332+
333+
it("should not prefix the href with /base", function () {
334+
expect(urlRouter.href(matcher("/foo"))).toBe('/foo');
335+
});
336+
337+
it('should return absolute URLs', function () {
338+
// don't use urlService var
339+
const cfg = router.urlService.config;
340+
const href = urlRouter.href(matcher('/hello/:name'), { name: 'world', '#': 'frag' }, { absolute: true });
341+
const prot = cfg.protocol();
342+
const host = cfg.host();
343+
const port = cfg.port();
344+
let portStr = (port === 80 || port === 443) ? '' : `:${port}`;
345+
expect(href).toBe(`${prot}://${host}${portStr}/hello/world#frag`);
346+
});
347+
});
348+
349+
describe('with base="http://localhost:8080/base/index.html"', () => {
350+
beforeEach(() => applyBaseTag('http://localhost:8080/base/index.html'));
351+
352+
it("should prefix the href with /base/", function () {
353+
expect(urlRouter.href(matcher("/foo"))).toBe('/base/foo');
354+
});
355+
356+
it('should include #fragments', function () {
357+
expect(urlRouter.href(matcher("/foo"), { '#': "hello"})).toBe('/base/foo#hello');
358+
});
359+
360+
it('should return absolute URLs', function () {
361+
// don't use urlService var
362+
const cfg = router.urlService.config;
363+
const href = urlRouter.href(matcher('/hello/:name'), { name: 'world', '#': 'frag' }, { absolute: true });
364+
const prot = cfg.protocol();
365+
const host = cfg.host();
366+
const port = cfg.port();
367+
let portStr = (port === 80 || port === 443) ? '' : `:${port}`;
368+
expect(href).toBe(`${prot}://${host}${portStr}/base/hello/world#frag`);
369+
});
370+
});
371+
});
243372
});
244373

245374
describe('Url Rule priority', () => {

test/vanilla.browserHistorySpec.ts renamed to test/vanilla.browserLocationConfigSpec.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ import { UrlService } from "../src/url/urlService";
33
import * as vanilla from "../src/vanilla";
44
import { UrlMatcherFactory } from "../src/url/urlMatcherFactory";
55
import { BrowserLocationConfig } from '../src/vanilla';
6+
import { resetBrowserUrl } from './_testUtils';
67

7-
describe('browserHistory implementation', () => {
8+
describe('BrowserLocationConfig implementation', () => {
9+
afterAll(() => resetBrowserUrl())
810

911
let router: UIRouter;
1012
let $url: UrlService;

0 commit comments

Comments
 (0)