Dogstudio/highway

View on GitHub
src/core.js

Summary

Maintainability
B
6 hrs
Test Coverage
/**
 * @file Highway core that handle all history stuffs.
 * @author Anthony Du Pont <bulldog@dogstudio.co>
 */
import Emitter from 'tiny-emitter';
import Helpers from './helpers';

export default class Core extends Emitter {

  /**
   * @arg {object} opts — User options
   * @arg {object} opts.renderers — List of renderers
   * @arg {object} opts.transitions — List of transitions
   * @extends Emitter
   * @constructor
   */
  constructor({ renderers, transitions } = {}) {
    // Extends the Emitter constructor in order to be able to use its features
    // and send custom events all along the script.
    super();

    // Helpers.
    this.Helpers = new Helpers(renderers, transitions);

    // Prep contextual transition info.
    this.Transitions = transitions;
    this.Contextual = false;

    // Properties & state.
    this.location = this.Helpers.getLocation(window.location.href);
    this.properties = this.Helpers.getProperties(document.cloneNode(true));

    // Status variables.
    this.popping = false;
    this.running = false;

    // Trigger Element
    this.trigger = null;

    // Cache
    this.cache = new Map();
    this.cache.set(this.location.href, this.properties);

    // Get the page renderer and properly setup it.
    this.properties.renderer.then(Renderer => {
      this.From = new Renderer(this.properties);
      this.From.setup();
    });

    // Events variables.
    this._navigate = this.navigate.bind(this);

    // Listen the `popstate` on the window to run the router each time an
    // history entry changes. Basically everytime the backward/forward arrows
    // are triggered by the user.
    window.addEventListener('popstate', this.popState.bind(this));

    // Get all elligible links.
    this.links = document.querySelectorAll('a:not([target]):not([data-router-disabled])');

    // Event attachement
    this.attach(this.links);
  }

  /**
   * Attach `click` event on links.
   *
   * @param {(array|nodeList)} links - Links to use
   */
  attach(links) {
    for (const link of links) {
      link.addEventListener('click', this._navigate);
    }
  }

  /**
   * Detach `click` event on links.
   *
   * @param {(array|nodeList)} links - Links to use
   */
  detach(links) {
    for (const link of links) {
      link.removeEventListener('click', this._navigate);
    }
  }

  /**
   * Click method called on `click` event.
   *
   * @arg {object} e - `click` event
   */
  navigate(e) {
    if (!(e.metaKey || e.ctrlKey)) {
      // Prevent default `click`
      e.preventDefault();

      // Check to see if this navigation will use a contextual transition
      const contextual = e.currentTarget.hasAttribute('data-transition') ? e.currentTarget.dataset.transition : false;

      // We have to redirect to our `href` using Highway
      // There we set up the contextual transition, so this and Core.redirect can pass in either transition name or false
      this.redirect(e.currentTarget.href, contextual, e.currentTarget);
    }
  }

  /**
   * Redirect to URL
   *
   * @param {string} href - URL
   * @param {(object|boolean)} contextual - If the transition is changing on the fly
   * @param {(object|string)} trigger - The trigger element or a string
   */
  redirect(href, contextual = false, trigger = 'script') {
    // Save Trigger Element
    this.trigger = trigger;

    // When our URL is different from the current location `href` and no other
    // navigation is running for the moment we are allowed to start a new one.
    // But if the URL containes anchors or if the origin is different we force
    // the hard reloading of the page to avoid serious errors.
    if (!this.running && href !== this.location.href) {
      // We temporary store the future location.
      const location = this.Helpers.getLocation(href);

      // Set contextual transition values if applicable
      this.Contextual = false;

      if (contextual) {
        this.Contextual = this.Transitions['contextual'][contextual].prototype;
        this.Contextual.name = contextual;
      }

      if (location.origin !== this.location.origin || location.anchor && location.pathname === this.location.pathname) {
        // We redirect when origins are differents or when there is an anchor.
        window.location.href = href;

      } else {
        this.location = location;

        // Now all our conditions are passed we can update our location and do
        // what we need to do before fetching it.
        this.beforeFetch();

      }
    }
  }

  /**
   * Watch history entry changes.
   */
  popState() {
    // Save Trigger Element
    this.trigger = 'popstate';

    // A contextual transition only effects the transition when a certain link is clicked, not when navigating via browser buttons
    this.Contextual = false;

    // We temporary store the future location.
    const location = this.Helpers.getLocation(window.location.href);

    // When users navigate using the browser buttons we check if the locations
    // have no anchors and that our locations are different.
    if (this.location.pathname !== location.pathname || !this.location.anchor && !location.anchor) {
      this.popping = true;
      this.location = location;

      // If everything is fine we can save our location and do what we need to
      // do before fetching it.
      this.beforeFetch();

    } else {
      // Update Location
      this.location = location;

    }
  }

  /**
   * Update DOM on `click` event.
   */
  pushState() {
    if (!this.popping) {
      window.history.pushState(this.location, '', this.location.href);
    }
  }

  /**
   * Fetch the page from URL
   *
   * @return {string} Fetch response
   */
  async fetch() {
    const response = await fetch(this.location.href, {
      mode: 'same-origin',
      method: 'GET',
      headers: { 'X-Requested-With': 'Highway' },
      credentials: 'same-origin'
    });

    // We have to checked if the fetch response is OK otherwise we have to force
    // the hard reloading of the page because we might have an error.
    if (response.status >= 200 && response.status < 300) {
      return response.text();
    }

    window.location.href = this.location.href;
  }

  /**
   * Do some tests before HTTP requests to optimize pipeline.
   */
  async beforeFetch() {
    // Push State
    this.pushState();

    // We lock the navigation to avoid multiples clicks that could overload the
    // navigation process meaning that if the a navigation is running the user
    // cannot trigger a new one while the previous one is running.
    this.running = true;

    // We emit an event right before hiding the current view to create a hook
    // for developers that want to do stuffs when an elligible link is clicked.
    this.emit('NAVIGATE_OUT', {
      from: {
        page: this.From.properties.page,
        view: this.From.properties.view
      },
      trigger: this.trigger,
      location: this.location
    });

    // Transition Datas
    const datas = {
      trigger: this.trigger,
      contextual: this.Contextual
    };

    // We have to verify our cache in order to save some HTTPRequests. If we
    // don't use any caching system everytime we would come back to a page we
    // already saw we will have to fetch it again and it's pointless.
    if (this.cache.has(this.location.href)) {
      // We wait until the view is hidden.
      await this.From.hide(datas);

      // Get Properties
      this.properties = this.cache.get(this.location.href);

    } else {
      // We wait till all our Promises are resolved.
      const results = await Promise.all([
        this.fetch(),
        this.From.hide(datas)
      ]);

      // Now everything went fine we can extract the properties of the view we
      // successfully fetched and keep going.
      this.properties = this.Helpers.getProperties(results[0]);

      // We cache our result
      // eslint-disable-next-line
      this.cache.set(this.location.href, this.properties);

    }

    this.afterFetch();
  }

  /**
   * Push page in DOM
   */
  async afterFetch() {
    // We are calling the renderer attached to the view we just fetched and we
    // are adding the [data-router-view] in our DOM.
    const Renderer = await this.properties.renderer;

    this.To = new Renderer(this.properties);
    this.To.add();

    // We then emit a now event right before the view is shown to create a hook
    // for developers who want to make stuff before the view is visible.
    this.emit('NAVIGATE_IN', {
      to: {
        page: this.To.properties.page,
        view: this.To.wrap.lastElementChild
      },
      trigger: this.trigger,
      location: this.location
    });

    // We wait for the view transition to be over before resetting some variables
    // and reattaching the events to all the new elligible links in our DOM.
    await this.To.show({
      trigger: this.trigger,
      contextual: this.Contextual
    });

    this.popping = false;
    this.running = false;

    // Detach Event on Links
    this.detach(this.links);

    // Get all elligible links.
    this.links = document.querySelectorAll('a:not([target]):not([data-router-disabled])');

    // Attach Event on Links
    this.attach(this.links);

    // Finally we emit a last event to create a hook for developers who want to
    // make stuff when the navigation has ended.
    this.emit('NAVIGATE_END', {
      to: {
        page: this.To.properties.page,
        view: this.To.wrap.lastElementChild
      },
      from: {
        page: this.From.properties.page,
        view: this.From.properties.view
      },
      trigger: this.trigger,
      location: this.location
    });

    // Last but not least we swap the From and To renderers for future navigations.
    this.From = this.To;

    // Reset Trigger
    this.trigger = null;
  }
}