How does history.js work?

luicoding

Lui

Posted on December 6, 2023

How does history.js work?

Motivation

Recently, I've been delving into React Router. As we know, it provides two primary methods for implementing routing in our applications: HashRouter and BrowserRouter.

My curiosity was piqued by how React Router do this behind the scenes. I discovered that the Remix team maintains a repository named history.js, which serves as the underlying layer for React Router.

Let 's explore this further and enjoy the learning journey 🥳.

Prepare

  • You 'd better have a solid understanding about the History API.

  • Obtain the source code, and here, we will specifically analyze the core part.

Note: this analysis is besed on history.js version 5.3.0.

Overview

What is history.js ?

The history library lets you easily manage session history anywhere JavaScript runs. A history object abstracts away the differences in various environments and provides a minimal API that lets you manage the history stack, navigate, and persist state between sessions.

Keywords:

  • Manage History anywhere JavaScript Runs:
  • Abstract APIs
  • Persist State

Code analysis

Basis

Path & Location

The Path interface declares the visible part of a Location, such as http://example/home?type=foo#bar, which can be divided into different parts. On the other hand, Location contains state and key properties that are invisible yet crucial. Developers have the ability to conceal certain information from the URL by storing it in the state property. Additionally, valuable data can be stored in localStorage based on the key property. This flexibility allows developers to manage the visibility of information in the URL and handle sensitive or persistent data according to their specific needs.

export type Pathname = string;
export type Search = string;
export type Hash = string;
export type State = unknown;
export type Key = string;

/** 
 * The pathname, search, and hash values of a URL.
 */
export interface Path {
  pathname: Pathname;
  search: Search;
  hash: Hash;
}

/** 
 * An entry in a history stack. A location contains information about the
 * URL path, as well as possibly some arbitrary state and a key.
 */
export interface Location extends Path {
  state: unknown;
  key: Key;
}
Enter fullscreen mode Exit fullscreen mode

Action

/** 
 * Actions represent the type of change to a location value.
 */
export enum Action {
  Pop = 'POP',
  Push = 'PUSH',
  Replace = 'REPLACE'
}
Enter fullscreen mode Exit fullscreen mode

Update & Transition

Something like Redux 🤔

/**
 * A change to the current location.
 */
export interface Update {
  action: Action;
  location: Location;
}

/**
 * A change to the current location that was blocked. May be retried
 * after obtaining user confirmation.
 */
export interface Transition extends Update {
  retry(): void;
}
Enter fullscreen mode Exit fullscreen mode

Events

A simple implementation of a publish-subscribe model

/**
 * A function that receives notifications about location changes.
 */
export interface Listener {
  (update: Update): void;
}

/**
 * A function that receives transitions when navigation is blocked.
 */
export interface Blocker {
  (tx: Transition): void;
}

type Events<F> = {
  length: number;
  push: (fn: F) => () => void;
  call: (arg: any) => void;
};
function createEvents<F extends Function>(): Events<F> {
  // event handlers list
  let handlers: F[] = [];

  return {
    get length() {
      return handlers.length;
    },
    // Register a handler and return a function that can be used to remove it
    push(fn: F) {
      handlers.push(fn);
      return function () {
        handlers = handlers.filter((handler) => handler !== fn);
      };
    },
    // When the event is triggered, execute all handlers.
    call(arg) {
      handlers.forEach((fn) => fn && fn(arg));
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

Methods

history.js provides three methods to create history object.

// BROWSER
export function createBrowserHistory(
  options: BrowserHistoryOptions = {}
): BrowserHistory {}
// HASH
export function createHashHistory(
  options: HashHistoryOptions = {}
): HashHistory {...}
// MEMORY
export function createMemoryHistory(
  options: MemoryHistoryOptions = {}
): MemoryHistory {...}
Enter fullscreen mode Exit fullscreen mode

Input

In both createBrowserHistory and createHashHistory, to set up the history, provide options with a window object that is used to establish a connection with the browser.

// BROWSER
export type BrowserHistoryOptions = { window?: Window };
function createBrowserHistory(options) {
  // use document.defaultView as a defaultValue 
  // BTW , in ts 'defaultView!' indicates that you are asserting it is not empty
    let { window = document.defaultView! } = options;
    let globalHistory = window.history;

  // ...
} 
// HASH is same with BROWSER
Enter fullscreen mode Exit fullscreen mode

In the createMemoryHistory, to configure the history, provide options with an initialEntries object, used to initialize entries similar to the history stack in a browser, and initialIndex to set the current position in the entries.

This history implementation internally stores its locations in an array and is not tied to an external source. Therefore, it is suitable for Node.js, React Native, or test environments.

export type InitialEntry = string | Partial<Location>;
export type MemoryHistoryOptions = {
  initialEntries?: InitialEntry[];
  initialIndex?: number;
};

function createMemeryHistory(options) {
  let { initialEntries = ['/'], initialIndex } = options;
  // using a map to generate entries    
    let entries: Location[] = initialEntries.map((entry) => {
    let location = readOnly<Location>({
      pathname: '/',
      search: '',
      hash: '',
      state: null,
      key: createKey(),
      ...(typeof entry === 'string' ? parsePath(entry) : entry)
    });
    return location;
  });
  // limit the initialIndex within 0 to entries.length - 1
  let index = clamp(
    initialIndex == null ? entries.length - 1 : initialIndex,
    0,
    entries.length - 1
  );
}

Enter fullscreen mode Exit fullscreen mode

Output

// BROWSER
export interface BrowserHistory extends History {}
// HASH
export interface HashHistory extends History {}
// MEMORY
export interface MemoryHistory extends History {
  readonly index: number;
}
Enter fullscreen mode Exit fullscreen mode

As we can see, the output object of these three methods is a subtype derived from History. Let's check it.

History Interface

/**
 * A history is an interface to the navigation stack. The history serves as the
 * source of truth for the current location, as well as provides a set of
 * methods that may be used to change it.
 *
 * It is similar to the DOM's `window.history` object, but with a smaller, more
 * focused API.
 */
export interface History {
  readonly action: Action;
  readonly location: Location;
  createHref(to: To): string;
  push(to: To, state?: any): void;
  replace(to: To, state?: any): void;
  go(delta: number): void;
  back(): void;
  forward(): void;
  listen(listener: Listener): () => void;
  block(blocker: Blocker): () => void;
}
Enter fullscreen mode Exit fullscreen mode

Inner Functions

OK! Now that we have taken a glance at what we need to know beforehand, let's break down the details of these methods. Let's examine some internal functions first

HistoryState

It is used in createBrowserHistory and createHashHistory

/**
  * An internal object utilized to store the 'key' and 'state' properties of 
  * a Location in window.history.state, while maintaining a count of 'index'
  * for going back in block events .
*/
type HistoryState = {
  // Location's state that stores the real user's state value
  usr: any;
  // Location's key
  key?: string;
  // index used for blocking purposes init value is 0.
  idx: number;
};

Enter fullscreen mode Exit fullscreen mode
getIndexAndLocation

It is used in createBrowserHistory and createHashHistory

function getIndexAndLocation(): [number, Location] {
    /**
      * get Path properties from window.loaction or window.location.hash
      *
        * << Code in createHashHistory >>
        * let {
      *   pathname = '/',
      *     search = '',
      *     hash = ''
      * } = parsePath(window.location.hash.substr(1));
    */
    let { pathname, search, hash } = window.location;

        /**
            * The state declared here is either a HistoryState or an empty object.
            * When a user opens a new window,window.state is null. By default the 
            * state here is set to empty object
            * Upon window refresh, it will retrieve state from window.state.
            * So it can persist state even when the user refreshes the page.
        */
    let state = globalHistory.state || {};

    // return the index and the created Location 
    return [
      state.idx,
      readOnly<Location>({
        pathname,
        search,
        hash,
        state: state.usr || null,
        key: state.key || 'default'
      })
    ];
  }
Enter fullscreen mode Exit fullscreen mode
getHistoryStateAndUrl

It is used in createBrowserHistory and createHashHistory.

// generate the parameters for history.pushState or history.replaceState
function getHistoryStateAndUrl(
    nextLocation: Location,
    index: number
  ): [HistoryState, string] {
    return [
      {
        usr: nextLocation.state,
        key: nextLocation.key,
        idx: index
      },
      createHref(nextLocation)
    ];
  }
Enter fullscreen mode Exit fullscreen mode
getNextLocation

It is used in all of three methods.

// generate a new Location object
function getNextLocation(to: To, state: any = null): Location {
    return readOnly<Location>({
      pathname: location.pathname,
      hash: '',
      search: '',
      ...(typeof to === 'string' ? parsePath(to) : to),
      state,
      key: createKey()
    });
  }
Enter fullscreen mode Exit fullscreen mode
allowTx & applyTx
// check if the transcation can be applied
function allowTx(action: Action, location: Location, retry: () => void) {
    // if these is no blocker, just return true
    // else exec all blockers and return false
    return (
      !blockers.length || (blockers.call({ action, location, retry }), false)
    );
  }

// upadate action,location and index that is maintanined in the history,
// and exec all listeners.
function applyTx(nextAction: Action) {
    action = nextAction;
    [index, location] = getIndexAndLocation();
    listeners.call({ action, location });
  }
// in createMemeryHistory
function applyTx(nextAction: Action, nextLocation: Location) {
    action = nextAction;
    location = nextLocation;
    listeners.call({ action, location });
 }
Enter fullscreen mode Exit fullscreen mode

Exposed Porperties

Let's proceed to see how the exposed porperties are implemented.

action & location & index
// use getter function for real-time data
let history: MemoryHistory = {
    // only in createMemoryHistory
    get index() {
      return index;
    },
    get action() {
      return action;
    },
    get location() {
      return location;
    },
    ...
  };
Enter fullscreen mode Exit fullscreen mode
createHref
// in createBrowserHistory and createMemeryHistory 
function createHref(to: To) {
   return typeof to === 'string' ? to : createPath(to);
}

// in createHashHistory 
function getBaseHref() {
    let base = document.querySelector('base');
    let href = '';
        // Check base value here but actually use 'window.location.href' to ensure the href created by 'creatHref' is an absolute path. Because linking to a relative path erases the hash part of a URL 
    if (base && base.getAttribute('href')) {
      let url = window.location.href;
      let hashIndex = url.indexOf('#');
      href = hashIndex === -1 ? url : url.slice(0, hashIndex);
    }

    return href;
}

function createHref(to: To) {
   return getBaseHref() + '#' + (typeof to === 'string' ? to : createPath(to));
}
Enter fullscreen mode Exit fullscreen mode
push & replace

When push or replace is called in hash and browser mode , it checks for the presence of a blocker. If no blocker is found, it proceeds to invoke the pushState or replaceState with the newly generated HistoryState and index.On the other hand, it executes all blockers, typically allowing users to discard the blocker if they approve the navigation.Beside, hash mode does not support relative path.

In memory mode, instead of invoking the HTML5 API, it directly manipulates the entries stack created by itself. The blocking process operates in a similar manner.

    // in createBrowserHistory and createMemeryHistory 
    function push(to: To, state?: any) {
    let nextAction = Action.Push;
    let nextLocation = getNextLocation(to, state);    
    // set retry for resuming the push operation after the execution of blockers
    function retry() {
      push(to, state);
    }



    if (allowTx(nextAction, nextLocation, retry)) {
      let [historyState, url] = getHistoryStateAndUrl(nextLocation, index + 1);

      // try...catch because iOS limits us to 100 pushState calls :/
      try {
        globalHistory.pushState(historyState, '', url);
      } catch (error) {
        // They are going to lose state here, but there is no real
        // way to warn them about it since the page will refresh...
        window.location.assign(url);
      }

      applyTx(nextAction);
    }
  }

  function replace(to: To, state?: any) {
        ...
    if (allowTx(nextAction, nextLocation, retry)) {
      ...
      globalHistory.replaceState(historyState, '', url);

      applyTx(nextAction);
    }
  }

  // in createMemeryHistory
    function push(to: To, state?: any) {
    ...
    if (allowTx(nextAction, nextLocation, retry)) {
      index += 1;
      entries.splice(index, entries.length, nextLocation);
      applyTx(nextAction, nextLocation);
    }
  }

  function replace(to: To, state?: any) {
    ...
    if (allowTx(nextAction, nextLocation, retry)) {
      entries[index] = nextLocation;
      applyTx(nextAction, nextLocation);
    }
  }
Enter fullscreen mode Exit fullscreen mode
go & back & forward

Cause back equal to go(-1), forward equal to go(1) ,we just need to focus on go's implementation.

In browser and hash mode, it simply invokes the HTML5 go. However, in memory mode, it invokes allowTx and applyTx. This distinction arises because go triggers an event called 'popstate' in the browser environment, and we'll discuss this later.

// in createBrowserHistory and createMemeryHistory 
function go(delta: number) {
    globalHistory.go(delta);
}

// in createMemoryHistory
function go(delta: number) {
    // prevent the array from exceeding its limits.
    let nextIndex = clamp(index + delta, 0, entries.length - 1);

    let nextAction = Action.Pop;
    let nextLocation = entries[nextIndex];
    function retry() {
      go(delta);
    }
        // like push and repalce
    if (allowTx(nextAction, nextLocation, retry)) {
      index = nextIndex;
      applyTx(nextAction, nextLocation);
    }
  }

Enter fullscreen mode Exit fullscreen mode
listen & block

If you have forgotten what is the type definition in this context, you can go back and check it

const BeforeUnloadEventType = 'beforeunload';

// init the events
let listeners = createEvents<Listener>();
let blockers = createEvents<Blocker>();

function promptBeforeUnload(event: BeforeUnloadEvent) {
  // Cancel the event.
  event.preventDefault();
  // Chrome (and legacy IE) requires returnValue to be set.
  event.returnValue = '';
}

let history = {
        // ...
    // add a listener and return a dismiss function
    // just like 'useEffect' 👀
    listen(listener) {
      return listeners.push(listener);
    },
    // add blocker and return a dismiss function
    block(blocker) {
      let unblock = blockers.push(blocker);

         // In browser and hash mode, it handles the beforeunload event to
     //  provide a warning before the user leaves the page.
      if (blockers.length === 1) {
        window.addEventListener(BeforeUnloadEventType, promptBeforeUnload);
      }

      return function () {
        unblock();

        // Remove the beforeunload listener so the document may
        // still be salvageable in the pagehide event.
        // See https://html.spec.whatwg.org/#unloading-documents
        if (!blockers.length) {
          window.removeEventListener(BeforeUnloadEventType, promptBeforeUnload);
        }
      };
    }
  };

// in createMemoryHistory
let history = {
    //  ... 
  block(blocker) {
     let unblock = blockers.push(blocker);
  }
}
Enter fullscreen mode Exit fullscreen mode

Browser Events

Here, we reach the final part of this code analysis 🙌.There are two browser events that history.js uses to establish client router: popstate and hashchange. The MDN explanations are as follow.

The hashchange event is fired when the fragment identifier of the URL has changed (the part of the URL beginning with and following the # symbol).

The popstate event of the Window interface is fired when the active history entry changes while the user navigates the session history. It changes the current history entry to that of the last page the user visited or, if history.pushState() has been used to add a history entry to the history stack, that history entry is used instead.

So, we use hashchange for hash mode and popstate for both hash and browser modes.

const HashChangeEventType = 'hashchange';
const PopStateEventType = 'popstate';

// store the last transcation using a closure
let blockedPopTx: Transition | null = null;

function handlePop() {
    // if blockedPopTx exsit, use it to execute all blokcers and set blockedPopTx to null
    if (blockedPopTx) {
      blockers.call(blockedPopTx);
      blockedPopTx = null;
    } else {
      // When the code reaches this point, the browser's location has changed, allowing us to retrieve the next index and Location.
            // To be explicit, 'next' has no relation to the direction; going back can also be  treated as 'next'.

      let nextAction = Action.Pop;
      let [nextIndex, nextLocation] = getIndexAndLocation();

      // if there is blockers
      if (blockers.length) {
        // Usually, we can obtain a valid index and location here because all history entries are produced from history.js unless you invoke the HTML5 APIs directly.
        if (nextIndex != null) {
          // calculate the steps of change
          let delta = index - nextIndex;
          if (delta) {
            // create and save the transaction to 'blockedPopTx'
            blockedPopTx = {
              action: nextAction,
              location: nextLocation,
              retry() {
                go(delta * -1);
              }
            };
                        // Go back to the former page, and this action will trigger the 'handlePop' function again. This allows it to execute blockers saved in 'blockedPopTx'.
            go(delta);
          }
        } else {
          // Trying to POP to a location with no index. We did not create
          // this location, so we can't effectively block the navigation.
          warning(
            false,
            // TODO: Write up a doc that explains our blocking strategy in
            // detail and link to it here so people can understand better
            // what is going on and how to avoid it.
            `You are trying to block a POP navigation to a location that was not ` +
              `created by the history library. The block will fail silently in ` +
              `production, but in general you should do all navigation with the ` +
              `history library (instead of using window.history.pushState directly) ` +
              `to avoid this situation.`
          );
        }
      } else {
        // no blocker 
        applyTx(nextAction);
      }
    }
  }

  window.addEventListener(PopStateEventType, handlePop);

  // popstate does not fire on hashchange in IE 11 and old (trident) Edge
  // https://developer.mozilla.org/de/docs/Web/API/Window/popstate_event
  window.addEventListener(HashChangeEventType, () => {
    let [, nextLocation] = getIndexAndLocation();

    // Ignore extraneous hashchange events.
    if (createPath(nextLocation) !== createPath(location)) {
      handlePop();
    }
  });
Enter fullscreen mode Exit fullscreen mode

Process

Initialization Phase
  • Bind window.history to globalHistory.
  • Initialize index, action, and location.
  • Create listener and blocker events.
  • Add hashchange and popstate listeners to the window.
Runtime Phase
  • Developers register listener and blocker.
  • Users perform operations in the app that affect routers.
  • history.js stores every router action in window.history so that it persists even after refreshing the page.
  • history.js watches router changes and invokes listener handlers and blocker handlers at the right time.

Summary

history.js is a minimal yet crucial library for React Router. It implements both hash router and browser router through the secondary encapsulation of HTML5 history APIs. Additionally, it offers a simulation for non-browser environments such as Node and React Native, ensuring compatibility across different platforms. By utilizing a transaction and events mode, it empowers users to block URL changes and exec extra task when the URL changes.

Thanks for reading!😋

💖 💪 🙅 🚩
luicoding
Lui

Posted on December 6, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

How does history.js work?
javascript How does history.js work?

December 6, 2023