ember-source
Version:
A JavaScript framework for creating ambitious web applications
272 lines (241 loc) • 6.79 kB
JavaScript
import EmberObject from '../object/index.js';
import '../debug/index.js';
import { getHash } from './lib/location-utils.js';
import { isDevelopingApp } from '@embroider/macros';
import { assert } from '../debug/lib/assert.js';
let popstateFired = false;
function _uuid() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
let r, v;
r = Math.random() * 16 | 0;
v = c === 'x' ? r : r & 3 | 8;
return v.toString(16);
});
}
/**
HistoryLocation implements the location API using the browser's
history.pushState API.
Using `HistoryLocation` results in URLs that are indistinguishable from a
standard URL. This relies upon the browser's `history` API.
Example:
```app/router.js
Router.map(function() {
this.route('posts', function() {
this.route('new');
});
});
Router.reopen({
location: 'history'
});
```
This will result in a posts.new url of `/posts/new`.
Keep in mind that your server must serve the Ember app at all the routes you
define.
Using `HistoryLocation` will also result in location states being recorded by
the browser `history` API with the following schema:
```
window.history.state -> { path: '/', uuid: '3552e730-b4a6-46bd-b8bf-d8c3c1a97e0a' }
```
This allows each in-app location state to be tracked uniquely across history
state changes via the `uuid` field.
@class HistoryLocation
@extends EmberObject
@protected
*/
class HistoryLocation extends EmberObject {
// SAFETY: both of these properties initialized via `init`.
history;
_previousURL;
_popstateHandler;
/**
Will be pre-pended to path upon state change
@property rootURL
@default '/'
@private
*/
rootURL = '/';
/**
@private
Returns normalized location.hash
@method getHash
*/
getHash() {
return getHash(this.location);
}
init() {
this._super(...arguments);
let base = document.querySelector('base');
let baseURL = '';
if (base !== null && base.hasAttribute('href')) {
baseURL = base.getAttribute('href') ?? '';
}
this.baseURL = baseURL;
this.location = this.location ?? window.location;
this._popstateHandler = undefined;
}
/**
Used to set state on first call to setURL
@private
@method initState
*/
initState() {
let history = this.history ?? window.history;
this.history = history;
let {
state
} = history;
let path = this.formatURL(this.getURL());
if (state && state.path === path) {
// preserve existing state
// used for webkit workaround, since there will be no initial popstate event
this._previousURL = this.getURL();
} else {
this.replaceState(path);
}
}
/**
Returns the current `location.pathname` without `rootURL` or `baseURL`
@private
@method getURL
@return url {String}
*/
getURL() {
let {
location,
rootURL,
baseURL
} = this;
let path = location.pathname;
// remove trailing slashes if they exists
rootURL = rootURL.replace(/\/$/, '');
baseURL = baseURL.replace(/\/$/, '');
// remove baseURL and rootURL from start of path
let url = path.replace(new RegExp(`^${baseURL}(?=/|$)`), '').replace(new RegExp(`^${rootURL}(?=/|$)`), '').replace(/\/\//g, '/'); // remove extra slashes
let search = location.search || '';
url += search + this.getHash();
return url;
}
/**
Uses `history.pushState` to update the url without a page reload.
@private
@method setURL
@param path {String}
*/
setURL(path) {
(isDevelopingApp() && !(this.history) && assert('HistoryLocation.history is unexpectedly missing', this.history));
let {
state
} = this.history;
path = this.formatURL(path);
if (!state || state.path !== path) {
this.pushState(path);
}
}
/**
Uses `history.replaceState` to update the url without a page reload
or history modification.
@private
@method replaceURL
@param path {String}
*/
replaceURL(path) {
(isDevelopingApp() && !(this.history) && assert('HistoryLocation.history is unexpectedly missing', this.history));
let {
state
} = this.history;
path = this.formatURL(path);
if (!state || state.path !== path) {
this.replaceState(path);
}
}
/**
Pushes a new state.
@private
@method pushState
@param path {String}
*/
pushState(path) {
let state = {
path,
uuid: _uuid()
};
(isDevelopingApp() && !(this.history) && assert('HistoryLocation.history is unexpectedly missing', this.history));
this.history.pushState(state, '', path);
// used for webkit workaround
this._previousURL = this.getURL();
}
/**
Replaces the current state.
@private
@method replaceState
@param path {String}
*/
replaceState(path) {
let state = {
path,
uuid: _uuid()
};
(isDevelopingApp() && !(this.history) && assert('HistoryLocation.history is unexpectedly missing', this.history));
this.history.replaceState(state, '', path);
// used for webkit workaround
this._previousURL = this.getURL();
}
/**
Register a callback to be invoked whenever the browser
history changes, including using forward and back buttons.
@private
@method onUpdateURL
@param callback {Function}
*/
onUpdateURL(callback) {
this._removeEventListener();
this._popstateHandler = () => {
// Ignore initial page load popstate event in Chrome
if (!popstateFired) {
popstateFired = true;
if (this.getURL() === this._previousURL) {
return;
}
}
callback(this.getURL());
};
window.addEventListener('popstate', this._popstateHandler);
}
/**
Formats url to be placed into href attribute.
@private
@method formatURL
@param url {String}
@return formatted url {String}
*/
formatURL(url) {
let {
rootURL,
baseURL
} = this;
if (url !== '') {
// remove trailing slashes if they exists
rootURL = rootURL.replace(/\/$/, '');
baseURL = baseURL.replace(/\/$/, '');
} else if (baseURL[0] === '/' && rootURL[0] === '/') {
// if baseURL and rootURL both start with a slash
// ... remove trailing slash from baseURL if it exists
baseURL = baseURL.replace(/\/$/, '');
}
return baseURL + rootURL + url;
}
/**
Cleans up the HistoryLocation event listener.
@private
@method willDestroy
*/
willDestroy() {
this._removeEventListener();
}
_removeEventListener() {
if (this._popstateHandler) {
window.removeEventListener('popstate', this._popstateHandler);
}
}
}
export { HistoryLocation as default };