Skip to content

buildwithdarsh/HistoryJS

Repository files navigation

HistoryJS

A modern, typed, framework-agnostic wrapper over the browser History API. The 2026 successor to browserstate/history.js.

npm bundle license types

The original history.js shipped in 2010 to paper over the HTML4/HTML5 split. Every browser worth supporting today implements pushState/popstate natively, so this rewrite drops the HTML4 fallback, the jQuery/MooTools/Prototype adapters, and the global History namespace — and gives you what apps actually want now: typed entries, async navigation guards, query helpers, route matching, link interception, and a virtual stack.

Install

npm install @buildwithdarsh/historyjs
# or
pnpm add @buildwithdarsh/historyjs
# or
yarn add @buildwithdarsh/historyjs

Or via CDN:

<script src="https://unpkg.com/@buildwithdarsh/historyjs"></script>

Quick start

import { getHistory } from '@buildwithdarsh/historyjs';

type AppState = { view: 'home' | 'profile'; userId?: string };

const history = getHistory<AppState>();

history.subscribe((event) => {
  console.log(event.type, '→', event.to.location.pathname, event.to.state);
});

await history.push('/profile/42', { state: { view: 'profile', userId: '42' } });
history.back();

Why a rewrite?

Original history.js (2010) @buildwithdarsh/historyjs (2026)
HTML4 hashchange fallback Native History API only
jQuery / MooTools / Prototype adapters Zero dependencies
Global History namespace, untyped state TypeScript-first, generic state
Event-name strings (statechange) Typed NavigationEvent callbacks
No navigation guards Async guards with cancellation
Manual query/hash building setQuery, setHash, getQuery
No routing primitives Built-in pattern matcher
Manual <a> interception interceptLinks(history)
Manual scroll handling Built-in scroll restoration

Core concepts

Entries

Every navigation produces a typed HistoryEntry<TState>:

{
  id: string;            // stable, survives reload
  state: TState | null;  // your typed state
  title: string;
  location: { pathname, search, hash, href, origin };
  index: number;         // monotonic
  timestamp: number;     // Date.now() at navigation
}

Navigation

await history.push('/path', { state: {...}, title: 'New title' });
await history.replace('/path');
history.back();       // or history.back(2)
history.forward();
history.go(-3);
history.reload();

push and replace are async because guards may be async. They resolve to true if the navigation completed, false if it was cancelled.

Listeners

const unsubscribe = history.subscribe((event) => {
  // event.type:      'push' | 'replace' | 'pop'
  // event.direction: 'forward' | 'backward' | 'none'
  // event.from / event.to: HistoryEntry
  // event.isPopState: true if triggered by browser back/forward
});

unsubscribe();

Guards

Guards run before every navigation. Return false to cancel.

history.addGuard(async (event) => {
  if (event.to.location.pathname.startsWith('/admin')) {
    return await checkAuth();
  }
});

// Or just prompt the user:
history.block('You have unsaved changes — leave anyway?');

For pop navigations, a cancelled guard attempts to push the user back via history.go(delta).

Query helpers

history.setQuery({ page: 2, q: 'hello' });   // patch params
history.setQuery({ page: null });            // remove a key
history.getQuery('page');                    // '2'
history.searchParams;                        // URLSearchParams (read-only)

Or use the standalone helpers:

import { parseQuery, stringifyQuery, mergeQuery } from '@buildwithdarsh/historyjs';

Route matching

import { matchPattern, buildPath } from '@buildwithdarsh/historyjs';

history.matches<{ id: string }>('/users/:id');
// → { pattern: '/users/:id', path: '/users/42', params: { id: '42' } }

buildPath('/users/:id', { id: 42 });
// → '/users/42'

Supported syntax:

  • :name — named param, matches a single segment
  • :name? — optional segment
  • :name* — splat, matches the rest including slashes

Link interception

One call hijacks every same-origin <a> click. Modifier-clicks, off-origin links, download links, and links with target pass through unchanged.

import { interceptLinks } from '@buildwithdarsh/historyjs';

const off = interceptLinks(history);
// Optional config:
interceptLinks(history, {
  selector: 'a[data-spa]',
  root: document.getElementById('app'),
  sameOriginOnly: true,
});

Virtual stack

history.entries is an in-memory snapshot of every entry the manager has visited — great for breadcrumbs, history menus, or analytics.

history.entries.forEach((entry) => {
  console.log(entry.index, entry.location.href, entry.timestamp);
});

Metrics

history.metrics; // { pushes, replaces, pops, cancelled }

Scroll restoration

Enabled by default on pop navigations. To opt out:

const history = getHistory({ restoreScrollOnPop: false });

The library sets history.scrollRestoration = 'manual' by default — pass scrollRestoration: 'auto' or false to opt out.

Singleton vs. instance

getHistory() is a lazy singleton — convenient for app code. For tests or iframes, instantiate directly:

import { HistoryManager } from '@buildwithdarsh/historyjs';
const history = new HistoryManager({ window: iframe.contentWindow! });

React example

import { useEffect, useState, useSyncExternalStore } from 'react';
import { getHistory } from '@buildwithdarsh/historyjs';

const history = getHistory<{ page: string }>();

export function useHistoryEntry() {
  return useSyncExternalStore(
    (cb) => history.subscribe(cb),
    () => history.entry,
  );
}

Demo

A full live playground (the page in example/index.html) is deployed at the project URL — push, replace, back/forward, query patching, route matching, guards, link interception, virtual stack, and a real-time event log are all wired up.

To run locally:

npm install
npm run build
npx serve example

Development

npm install
npm run typecheck   # tsc --noEmit
npm test            # vitest run
npm run build       # rollup → dist/
npm run dev         # rollup --watch

Publishing

npm publish --access public

The prepublishOnly script runs typecheck, tests, and the rollup build.

License

MIT

About

A modern, typed, framework-agnostic wrapper over the browser History API — the 2026 successor to history.js. Typed entries, async guards, query helpers, route matcher, link interception, virtual stack. Zero deps, ~3KB gz.

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors