Skip to content

catamphetamine/navigation-stack

 
 

Repository files navigation

navigation-stack

npm version npm downloads

Handles navigation in a web browser. Represents web browser navigation history as a "stack" data structure. Provides operations to perform programmatic navigation such as "push" (go to new URL), "replace" (redirect to new URL), "shift" (rewind to a previously visited URL). Provides a subscription mechanism to get notified on location changes.

Also supports automatic scroll position restoration on "Back"/"Forward" navigation.

Originally forked from farce package to fix a couple of small bugs (1, 2). Then merged it with scroll-behavior package to fix a couple of small bugs (1, 2). Then decided to completely rewrite the entire code and changed the API to my liking.

Install

npm install navigation-stack

Use

Any changes to a NavigationStack instance are "magically" reflected in the web browser's address bar and navigation history, and vice versa: any changes to the URL in the web browser's address bar are "magically" reflected in the NavigationStack instance. So one could think of NavigationStack as a very convenient proxy to web browser's address bar and navigation history. What's left to the application is to subscribe to navigationStack changes and re-render the page accordingly.

Start by creating a NavigationStack instance.

import { NavigationStack, WebBrowserSession } from 'navigation-stack'

// Create a `NavigationStack` instance.
// It should be tied to a navigation "session".
const navigationStack = new NavigationStack(new WebBrowserSession())

Then subscribe to changes:

// Subscribe to location changes.
// The first call happens for the initial location.
// Next calls will happen in case of navigation.
const unsubscribe = navigationStack.subscribe((location) => {
  console.log('Current location', location)
  document.body.innerHTML = '<div>' + location.pathname + '</div>'
})

Now ready to perform navigation actions.

// Sets the initial location.
navigationStack.init()

// Sets the `location` to be a new location.
//
// Also updates the URL in the web browser's address bar.
//
// Also adds a new entry in the web browser's navigation history.
//
navigationStack.push('/new-location')

// Sets the `location` to be a new location.
//
// Also updates the URL in the web browser's address bar.
//
// Does not add a new entry in the web browser's navigation history
// which is the only difference between this and `Actions.push()`.
//
navigationStack.replace('/new-location')

// Sets the `location` to be a previous one (if there is one).
// If there's no such `location` in the navigation history,
// throws a `NavigationOutOfBoundsError` error that has an `index` property.
//
// One could think of it as an equivalent of clicking a "Back" button in a web browser.
//
// Also updates the URL in the web browser's address bar.
//
// Also shifts the current position in the web browser's navigation history.
//
navigationStack.shift(-1)

// Sets the `location` to be a next one (if there is one).
// If there's no such `location` in the navigation history,
// throws a `NavigationOutOfBoundsError` error that has an `index` property.
//
// One could think of it as an equivalent of clicking a "Forward" button in a web browser.
//
// Also updates the URL in the web browser's address bar.
//
// Also shifts the current position in the web browser's navigation history.
//
navigationStack.shift(1)

To get the current location:

const location = navigationStack.current()
console.log(location)

(optional) After the user is done using the app, stop the session and clean up any listeners.

// (optional)
// When the user closes the application,
// stop the session and clean up any listeners.
// There's no need to do this in a web browser.
unsubscribe()
navigationStack.stop()

Current Location

To get the current location, use navigationStack.current().

Current location object has all the properties of a standard web browser location with the addition of:

  • query: object — URL query parameters.
  • key: string — A string ID of the location that is guaranteed to be unique within the session's limits and could be used as a "key" to store any supplementary data associated to this location.
  • index: number — The index of the location in the navigation stack, starting with 0 for the initial location.

Scroll Position Restoration

Pass maintainScrollPosition: true option to keep track of scroll position on every page and then automatically restore it on "Back" or "Forward" navigation.

import { NavigationStack, WebBrowserSession } from 'navigation-stack'

// Create a `NavigationStack` instance with a `maintainScrollPosition: true` option.
const navigationStack = new NavigationStack(new WebBrowserSession(), {
  maintainScrollPosition: true
})

//----------------------------------------------------------------------------------------

// Sets the initial location.
navigationStack.init()

// Render the initial location.
document.body.innerHTML = '<div> Initial Location </div>'

// As soon as a page has been rendered, without any delay, tell `NavigationStack` to restore
// a previously-saved scroll position, if there's any.
//
// This method must be called both for the initial location and any subsequent location.
//
navigationStack.locationRendered()

//----------------------------------------------------------------------------------------

// Set the `location` to be a new location.
//
// This also updates the URL in the web browser's address bar
// and adds a new entry in the web browser's navigation history.
//
navigationStack.push('/new-location')

// Render the new location.
document.body.innerHTML = '<div> New Location </div>'

// The new location is now rendered.
// Immediately after it has been rendered, call `.locationRenered()`.
// There's no scroll position to restore because it's not a previously-visited location.
navigationStack.locationRendered()

//----------------------------------------------------------------------------------------

// Set `location` "back" to the initial location.
//
// This also updates the URL in the web browser's address bar
// and repositions the "current location" pointer in the web browser's navigation history.
//
navigationStack.shift(-1)

// Render the initial location.
document.body.innerHTML = '<div> Initial Location </div>'

// The initial location is now rendered.
// Immediately after it has been rendered, call `.locationRenered()`.
// Restores the scroll position at the initial location.
navigationStack.locationRendered()

//----------------------------------------------------------------------------------------

// (optional)
// When the user is about to close the application,
// stop the `NavigationStack` and clean up any of its listeners.
// This is not required in a web browser because it cleans up all listeners
// automatically when closing a tab.
navigationStack.stop()

NavigationStack provides methods:

  • addScrollableContainer(key: string, element: Element) — Use it in cases when it should restore not only the page scroll position but also the scroll position(s) of any other scrollable container(s). Returns a "remove scrollable container" function.
  • locationRendered() — Call it every time a different location has been rendered, including the initial location, without any delay, i.e. immediately after a different location has been rendered.
Using scroll position restoration feature without NavigationStack

To use the scroll position restoration feature independently of a NavigationStack instance (e.g. without it), create a ScrollPositionRestoration instance and pass a session argument to it.

import { WebBrowserSession } from 'navigation-stack'
import { ScrollPositionRestoration } from 'navigation-stack/scroll-position'

// Create a `ScrollPositionRestoration`.
const scrollPositionRestoration = new ScrollPositionRestoration(new WebBrowserSession())

//----------------------------------------------------------------------------------------

// If you decide to use `NavigationStack` or Redux-way `createMiddlewares()` for navigation,
// it should be tied to the same session.
//
// const navigationStack = new NavigationStack(session)
// navigationStack.init()
//
// Or, navigation could be performed by any other means such as using `window.history.pushState()`.
// The only requirement is for the "current location" object to have a `key` property
// which the standard `window.location` object doesn't provide.
//
window.history.replaceState({ key: '123' }, '', '/initial-location')

// Render the initial location.
document.body.innerHTML = '<div> Initial Location </div>'

// Immediately after a page has been rendered, without any delay,
// call `.locationRendered()` method with the "current location" object as the argument.
scrollPositionRestoration.locationRendered({ key: '123', pathname: '/initial-location' })

//----------------------------------------------------------------------------------------

// Navigate to a new location.
//
// For example, it could use `NavigationStack` for navigation.
//
// navigationStack.push('/new-location')
//
// Or, navigation could be performed by any other means such as using `window.history.pushState()`.
//
window.history.pushState({ key: '456' }, '', '/new-location')

// Render the new location.
document.body.innerHTML = '<div> New Location </div>'

// The new location is now rendered.
// Call `.locationRendered()` immediately after it has been rendered, i.e. without any delay.
// There's no scroll position to restore because it's not a previously-visited location.
// The "current location" object must have a `key`.
scrollPositionRestoration.locationRendered({ key: '456', pathname: '/new-location' })

//----------------------------------------------------------------------------------------

// Navigate "back" to the initial location.
//
// For example, it could use `NavigationStack` for navigation.
//
// navigationStack.shift(-1)
//
// Or, navigation could be performed by any other means such as using `window.history.go()`.
//
window.history.go(-1)

// Render the initial location.
document.body.innerHTML = '<div> Initial Location </div>'

// The initial location is now rendered.
// Call `.locationRendered()` immediately after it has been rendered, i.e. without any delay.
// It will restore the scroll position at the initial location.
// The "current location" object must have a `key`.
scrollPositionRestoration.locationRendered({ key: '123', pathname: '/initial-location' })

//----------------------------------------------------------------------------------------

// (optional)
// When the user is about to close the application,
// stop the `ScrollPositionRestoration` and clean up any of its listeners.
// This is not required in a web browser because it cleans up all listeners
// automatically when closing a tab.
scrollPositionRestoration.stop()

// (optional)
// In case of using `NavigationStack` for navigation,
// stop the `NavigationStack` and clean up any of its listeners.
//
// navigationStack.stop()

ScrollPositionRestoration provides methods:

  • addScrollableContainer(key: string, element: Element) — Use it in cases when it should restore not only the page scroll position but also the scroll position(s) of any other scrollable container(s). Returns a "remove scrollable container" function.
  • locationRendered(location) — Call it every time a different location has been rendered, including the initial location, without any delay, i.e. immediately after a different location has been rendered. The location argument must have a key.
  • stop() — Stops scroll position restoration and clears any listeners or timers.

Base Path

If the web application is hosted under a certain URL prefix, it should be specified as a basePath parameter when creating a NavigationStack instance. This prefix will automatically be added to the URL in the web browser's address bar while the location object itself won't include it in the pathname.

new NavigationStack(new WebBrowserSession(), { basePath: '/base-path' })

Session

A "session" ties NavigationStack to the environment it operates in, such as a web browser.

Three different "session" implementations are shipped with this package:

  • Use WebBrowserSession in a web browser. Such session survives a page refresh and is automatically destroyed when the web browser tab gets closed.
  • Use ServerSideRenderSession in server-side rendering. Create a separate session for each incoming HTTP request. Initialize it with a relative URL of the HTTP request. If, during server-side render, the application code attempts to navigate to another location, it will throw a ServerSideNavigationError with a location property in it.
  • Use InMemorySession in tests to mimick a WebBrowserSession. Create a separate session for each separate navigation session. Initialize it with a relative URL or a location object.
See ServerSideRenderSession example
const navigationStack = new NavigationStack(new ServerSideRenderSession())

navigationStack.subscribe((location) => {
  console.log('Current location', location)
})

// Sets the initial location.
// Triggers the subscription listener.
navigationStack.init('/initial-location')

// Navigates to a new location.
// Throws `ServerSideNavigationError` with a `location` property.
navigationStack.push('/new-location')
See InMemorySession example
const navigationStack = new NavigationStack(new InMemorySession())

navigationStack.subscribe((location) => {
  console.log('Current location', location)
})

// Sets the initial location.
// Triggers the subscription listener.
navigationStack.init('/initial-location')

// Navigates to a new location.
// Triggers the subscription listener.
navigationStack.push('/new-location')

Every "session" has a unique key.

Once created, a "session" is simply passed to the NavigationStack constructor and then you don't have to deal with it anymore — NavigationStack will pull all the strings for you.

However, if someone prefers to completely bypass NavigationStack and interact with a "session" object directly, they could do so.

See "session" API
  • key: string — A unique ID of the session.
  • subscribe(listener: (location) => {}): () => {} — Subscribes to location changes, including setting the initial location. Returns an "unsubscribe" function. The location argument of the listener function is an "extended" location object having additional properties:
    • operation: string — The type of navigation that led to the location.
      • INIT in case of the initial location before any navigation has taken place.
      • SHIFT when the user performs a "Back" or "Forward" navigation, or after a .shift() navigation which is essentially a "back or forward navigation".
      • PUSH in case of a .push() navigation, i.e. "normal navigation via a hyperlink".
      • REPLACE in case of a .replace() navigation, i.e. "redirect".
    • delta: number — the difference between the index of the current location and the index of the previous location.
      • 0 for the initial location before any navigation has taken place.
      • 1 after a .push() navigation, i.e. "normal navigation via a hyperlink".
      • 0 after a .replace() navigation, i.e. "redirect".
      • delta: number after a .shift(delta) navigation, i.e. "back or forward navigation".
      • -1 after the user clicks a "Back" button in their web browser.
      • 1 after the user clicks a "Forward" button in their web browser.
  • start(initialLocation?: object) — Starts the session. The initialLocation argument is optional when the session can read it from somewhere. For example, WebBrowserSession can read initialLocation from window.location.
  • stop() — Stops the session. Cleans up any listeners, etc.
  • navigate(operation: string, location: object) — Navigates to a location using either "PUSH" or "REPLACE" operation. The location argument should be a result of calling parseInputLocation() function.
  • shift(delta: number) — Navigates "back" or "forward" by skipping a specified count of pages. Negative delta skips backwards, positive delta skips forward.

Utility

This package exports a few utility functions for transforming locations.

import {
  getLocationUrl,
  parseLocationUrl,
  parseInputLocation,
  addBasePath,
  removeBasePath
} from 'navigation-stack'

// The following two are "mutually inverse functions":
// one maps a `location` object to a URL string
// and the other maps a URL string to a `location` object.

// Converts a location object to a location URL.
getLocationUrl({ pathname: '/abc', search: '?d=e' }) === '/abc?d=e'

// Parses a location URL to a location object.
// If there're no query parameters, `query` property will be an empty object.
parseLocationUrl('/abc?d=e') === {
  pathname: '/abc',
  search: '?d=e',
  query: { d: 'e' },
  hash: ''
}

// The following function parses a non-strict location object to a strict one.
// It also parses a location URL to a location object.

parseInputLocation({ pathname: '/abc', search: '?d=e' }) === {
  pathname: '/abc',
  search: '?d=e',
  query: { d: 'e' },
  hash: ''
}

parseInputLocation('/abc?d=e') === {
  pathname: '/abc',
  search: '?d=e',
  query: { d: 'e' },
  hash: ''
}

// The following two functions can be used to add base path to a location
// or to remove it from it.

// Adds `basePath` to a location object or a location URL.
addBasePath('/abc', '/base-path') === '/base-path/abc'
addBasePath({ pathname: '/abc' }, '/base-path') === { pathname: '/base-path/abc' }

// Removes `basePath` from a location object or a location URL.
// If `basePath` is not present in location, it won't do anything.
removeBasePath('/base-path/abc', '/base-path') === '/abc';
removeBasePath({ pathname: '/base-path/abc' }, '/base-path') === { pathname: '/abc' }

Block Navigation

navigation-stack provides the ability to block navigation. Call addNavigationBlocker() function to set up a "navigation blocker".

import {
  NavigationStack,
  WebBrowserSession,
  addNavigationBlocker
} from 'navigation-stack'

// Create a session.
const session = new WebBrowserSession()

// Create a `NavigationStack` instance.
const navigationStack = new NavigationStack(session)

// Add a navigation blocker.
// It should be tied to the same "session".
const removeNavigationBlocker = addNavigationBlocker(
  session,
  (newLocation) => {
    // Returning `true` means "this navigation should be blocked".
    return true
  }
);

// Because the navigation is blocked, current location will not change here.
//
// The URL in the web browser's address bar will stay the same
// and no new entries will be added in the web browser's navigation history.
//
navigationStack.push('/new-location')

// Remove the navigation blocker.
removeNavigationBlocker()

// With the blocker removed, current location will be set to a new one.
//
// This also updates the URL in the web browser's address bar
// and adds a new entry in the web browser's navigation history.
//
navigationStack.push('/new-location')

Navigation blocker should be a function that receives a newLocation argument and could be "synchronous" or "asynchronous" (i.e. return a Promise, aka async/await).

The newLocation argument of a blocker function might not necessarily have a key or index property but other properties are present.

Navigation blockers fire both when navigating from one page to another and when closing the current browser tab. In the latter case, newLocation argument will be null, and also the blocker function can't return a Promise (because it won't wait), and returning true from it will cause the web browser will to show a confirmation modal with a non-customizable generic browser-specific text like "Leave site? Changes you made might not be saved".

Data Storage

One could use DataStorage to store any kind of application-specific data in a given "session". The data will exist as long as the "session" exists.

Different types of data could be stored under a different key.

If each different location should have it's own data stored under the same key, one could use LocationDataStorage instead of just DataStorage. For example, one could store scroll position for each different page to be able to restore it when the user decides to navigate "Back" to that page. By the way, that's precisely what ScrollPositionRestoration does.

DataStorage constructor receives a session argument and a namespace parameter. The namespace just gets prepended to every key. The idea is that your namespace must not clash with anyone else's namespace who might potentially use the same session to store their own data.

import { WebBrowserSession } from 'navigation-stack'
import { DataStorage, LocationDataStorage } from 'navigation-stack/data-storage'

const session = new WebBrowserSession()

// `DataStorage` example

const dataStorage = new DataStorage(session, { namespace: 'my-namespace' })

dataStorage.set('key', 123)
dataStorage.get('key') === 123

// `LocationDataStorage` example

const locationDataStorage = new LocationDataStorage(session, { namespace: 'my-namespace' })

const location = { pathname: '/abc' }

locationDataStorage.set(location, 'key', 123)
locationDataStorage.get(location, 'key') === 123

DataStorage or LocationDataStorage don't provide any guarantees about actually storing the data: if it encounters any errors in the process, it simply ignores them. This simplifies the API in a way that the application doesn't have to wrap .get()/.set() calls in a try/catch block. And judging by the nature of location-specific data, that type of data is inherently non-essential and rather "nice-to-have".

One might ask: Why use DataStorage or LocationDataStorage when one could simply store the data in a usual variable? The answer is that a usual variable doesn't survive if the user decides to refresh the page. But the entire navigation history does survive because that's how web browsers work. So if the user decides to go "Back" after refreshing the current page, the data associated to that previous location would already be lost and can't be recovered. In contrast, when using a DataStorage or LocationDataStorage with a WebBrowserSession, the stored data does survive a page refresh, which feels more consistent and coherent with the persistence behavior of the navigation history itself.

Redux

Under the hood, navigation-stack uses redux. Why? For no particular reason. The original farce package was published in September 2016, and by that time redux had still been a hot topic since July 2025. This package could most certainly be rewritten without using redux, it's just that there seems to be no need to do that.

So since navigation-stack already implements all that redux stuff internally, such as "middlewares" or "actions", why not export it for public usage? Maybe there're still some redux fans out there.

Using navigation-stack redux-way is equivalent to using it the conventional way via NavigationStack class. navigation-stack exports "middlewares", "actions" and a "reducer" that could be used in conjunction with redux or any other redux-compatible package (e.g. mini-redux).

See redux-style API

Start by creating a Redux "store" with navigation-stack middlewares.

import { createStore, applyMiddleware } from 'redux';

import {
  createMiddlewares,
  locationReducer,
  Actions,
  WebBrowserSession,
} from 'navigation-stack';

// Create a Redux store.
const store = createStore(
  // Reducer function. For example, `locationReducer()`.
  locationReducer,
  // It should be tied to a navigation "session".
  applyMiddleware(...createMiddlewares(new WebBrowserSession())),
);

Next, set the initial location. Normally, NavigationStack class API automatically performs this step for a developer. When using Redux API though, it doesn't do that and the developer has to do it themself.

// Sets the initial `location`.
//
// Accepts either a relative URL string or a location object.
//
// The initial location argument could be omitted for `WebBrowserSession`
// because it can read it by itself from `window.location`.
// Other types of session such as `InMemorySession` or `ServerSideRenderSession`
// don't have an initial location and require the initial location argument
// to be specified explicitly when creating an `Actions.init(initialLocation)` action.
//
store.dispatch(Actions.init());

Then subscribe to location changes. One could use Redux'es standard subscription mechanisms to immediately get notified of current location changes.

let currentLocation;

// Create a Redux store.
const store = createStore(
  locationReducer, // Reducer function. For example, `locationReducer()`.
  applyMiddleware(...createMiddlewares(new WebBrowserSession())),
);

// Subscribe to any potential Redux state changes.
const unsubscribe = store.subscribe(() => {
  const previousLocation = currentLocation;
  currentLocation = store.getState(); // In case of using `locationReducer()`.
  if (currentLocation !== previousLocation) {
    console.log('Current location', currentLocation);
  }
});

Now ready to perform navigation actions by dispatching any of the available Actions.

// Sets the `location` to be a new location.
//
// Also updates the URL in the web browser's address bar.
//
// Also adds a new entry in the web browser's navigation history.
//
store.dispatch(Actions.push('/new-location'));

// Sets the `location` to be a new location.
//
// Also updates the URL in the web browser's address bar.
//
// Does not add a new entry in the web browser's navigation history
// which is the only difference between this and `Actions.push()`.
//
store.dispatch(Actions.replace('/new-location'));

// Sets the `location` to be a previous one (if there is one).
// One could think of it as an equivalent of clicking a "Back" button in a web browser.
//
// Also updates the URL in the web browser's address bar.
//
// Also shifts the current position in the web browser's navigation history.
//
store.dispatch(Actions.shift(-1));

// Sets the `location` to be a next one (if there is one).
// One could think of it as an equivalent of clicking a "Forward" button in a web browser.
//
// Also updates the URL in the web browser's address bar.
//
// Also shifts the current position in the web browser's navigation history.
//
store.dispatch(Actions.shift(1));

To get the current location:

// When `locationReducer()` is used, `store.getState()` returns the current location.
const location = store.getState();
console.log(location);

(optional) After the user is done using the app, stop the session and clean up any listeners.

// (optional)
// When the user closes the application,
// stop the session and clean up any listeners.
// There's no need to do this in a web browser.
unsubscribe();
store.dispatch(Actions.stop());

Development

Clone the repository. Then:

yarn
yarn format
yarn test

It runs tests in two web browsers (for no particular reason) — Chrome and Firefox (configurable in karma.conf.cjs). When running yarn test, it opens Chome and Firefox browser windows. Don't unfocus those windows, otherwise the tests will finish with errors.

GitHub

On March 9th, 2020, GitHub, Inc. silently banned my account (erasing all my repos, issues and comments, even in my employer's private repos) without any notice or explanation. Because of that, all source codes had to be promptly moved to GitLab. The GitHub repo is now only used as a backup (you can star the repo there too), and the primary repo is now the GitLab one. Issues can be reported in any repo.

About

Handles navigation in a web browser

Resources

License

Code of conduct

Stars

Watchers

Forks

Packages

No packages published

Languages

  • JavaScript 100.0%