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 };