navigation-ponyfill
v0.2.0
Published
A ponyfill (polyfill) for the browser Navigation API
Maintainers
Readme
navigation-ponyfill
A ponyfill (polyfill) for the browser Navigation API that enables tracking of browser history navigation, including (reasonably) reliable detection of when the user can navigate backwards in a single-page application.
navigation-ponyfill has zero runtime dependencies and will defer to the native Navigation on window.navigation when available.
What's a ponyfill?
A ponyfill is like a polyfill, but instead of patching the global environment, it exports the functionality as a module.
Unfortunately, navigation-ponyfill is not entirely side-effect free due to how it works.
Why?
My most immediate concern when implementing this ponyfill was to support "back" buttons in single-page applications. It is desirable to use the history.back() method in such cases so that the behavior is in-line with the browser's back button (and swiping on mobile). However, you don't want to bounce the user off your application if they came from elsewhere. In this case, it is preferable to navigate to a fallback URL instead.
It's a UI element seen in many applications (Instagram, Twitter/X, Bluesky, etc.) and while it's a slam-dunk to implement with Navigation, it's a minefield of edge-cases and tricky to get right using the History API. It is easiest to implement if you can just pass the previous URL with history.pushState({ previousUrl }, '', newUrl), but that's not something we can readily hook into in all frameworks (looking at you, Next.js).
Installation
npm install navigation-ponyfillTypeScript projects require installation of @types/dom-navigation as well.
npm install -D @types/dom-navigationQuick Start
import { navigation } from 'navigation-ponyfill'
// Check if back navigation is available
if (navigation.canGoBack) {
history.back()
}
// Access the current entry and history entries
console.log('Current URL:', navigation.currentEntry?.url)
console.log('History length:', navigation.entries().length)
// Listen for navigation changes
navigation.addEventListener('currententrychange', (event) => {
console.log('Navigated:', event.navigationType) // 'push' | 'replace' | 'traverse'
console.log('From:', event.from.url)
console.log('To:', navigation.currentEntry?.url)
})Entry Points
The package provides two entry points:
Default (with side effects)
import { navigation } from 'navigation-ponyfill'Returns the native window.navigation if available, otherwise patches history.pushState and history.replaceState and returns the ponyfill. Use this for most applications.
Core (side-effect-free on import)
import { createNavigation, Navigation } from 'navigation-ponyfill/core'
// No side effects until you call createNavigation()
const navigation = createNavigation()No automatic patching—you control when and how the Navigation instance is created. Useful for testing or advanced use cases.
API Reference
navigation
import { navigation } from 'navigation-ponyfill'A singleton that provides the Navigation API. This can be either:
- The native
window.navigationif the browser supports the Navigation API - The ponyfill
Navigationinstance as a fallback
Both share a common interface for the properties and methods below.
Properties
currentEntry: NavigationHistoryEntry | null— The current history entry.canGoBack: boolean— Whether the user can navigate backwards in this session.canGoForward: boolean— Whether the user can navigate forwards in this session.
Methods
entries(): NavigationHistoryEntry[]— Returns an array of all history entries in the current session.
Events
currententrychange— Fired when navigation occurs viapushState,replaceState, hash changes, or browser back/forward.
Navigation
The ponyfill implementation, available via createNavigation({ force: true }) or when the native API is unavailable.
Additional Methods
destroy(): void— Restores original history methods and removes event listeners. Use this for cleanup in tests or when the ponyfill is no longer needed.
NavigationCurrentEntryChangeEvent
Event object passed to currententrychange listeners.
interface NavigationCurrentEntryChangeEvent extends Event {
readonly from: NavigationHistoryEntry // Previous history entry
readonly navigationType: NavigationType | null // How the navigation occurred - reload not supported
}NavigationHistoryEntry
Represents a history entry.
interface NavigationHistoryEntry extends EventTarget {
readonly id: string // Unique identifier for this entry
readonly key: string // Stable key that persists across replace operations
readonly index: number // Position in the entries list (-1 if disposed)
readonly url: string | null // Full URL of the entry
readonly sameDocument: boolean // Whether this was a same-document navigation
getState(): unknown // Returns a clone of the state for this entry
}Events
dispose— Fired when the entry is removed from the history stack (e.g., on replace, or when navigating to a new page after going back).
NavigationType
type NavigationType = 'push' | 'replace' | 'traverse' | 'reload'push—history.pushState()was calledreplace—history.replaceState()was calledtraverse— Browser back/forward navigation (popstate)reload— Page reload (not currently emitted, included for alignment with native types)
createNavigation(options?)
Factory function to create a Navigation instance.
function createNavigation(
options?: CreateNavigationOptions,
): Navigation | NativeNavigation
function createNavigation(options: { force: true }): NavigationBy default, returns the native window.navigation if available, otherwise returns the ponyfill. Use force: true to always get the ponyfill instance.
CreateNavigationOptions
type CreateNavigationOptions = {
force?: boolean
history?: History | HistoryShim
}force— Whentrue, always returns the ponyfillNavigationinstance, even if the native Navigation API is available. Default:false(prefers native when available).history— CustomHistoryobject to use. Defaults towindow.historyin browser environments, or a no-opHistoryShimduring SSR.
Examples
import { createNavigation } from 'navigation-ponyfill/core'
// Default: uses native Navigation API if available, otherwise ponyfill
const navigation = createNavigation()
// Force ponyfill even when native is available (useful for testing)
const navigation = createNavigation({ force: true })
// Type narrowing for ponyfill-specific methods
if ('destroy' in navigation) {
navigation.destroy()
}
// Custom history object (useful for testing)
const navigation = createNavigation({ force: true, history: customHistory })Framework Integration
Next.js
See the Next.js example for a complete integration with React context and hooks.
SSR Support
The ponyfill includes a HistoryShim that provides a no-op implementation for server-side rendering. When window is not available, createNavigation() automatically uses the shim.
How It Works
The ponyfill monkey-patches history.pushState and history.replaceState, augmenting the state object with navigation metadata:
history.state = {
...yourState,
__NAVIGATION_PONYFILL: {
entryId: 'abc123',
entryKey: 'def456',
},
}It maintains a stack of NavigationHistoryEntry objects persisted to sessionStorage, allowing entries() and currentEntry to survive page reloads. It also listens for popstate events to track browser back/forward navigation and hash changes.
Because of the use of history.state and sessionStorage, the ponyfill even works in multi-page applications (MPAs).
Caveats
Unsupported APIs
NavigationCurrentEntryChangeEventwithnavigationType: 'reload'- this is impossible for us to detect.NavigationHistoryEntry.sameDocumentdoes not work -- we always set it totrueto maintain the same type signature with native API. It is impossible for us to determine in the ponyfill if an entry is from the same document (page) or not.
Multi-Page Applications (MPAs)
- While the ponyfill works for MPAs, it must be loaded on every page. If it's not, its state might become corrupted. This is untested.
State in History API calls must be an object or nullish
Normally you can call history.pushState(state, '', url) with any serializable value for state (including boolean, string, array, etc.). Because the ponyfill merges your state with its own metadata, the state must be an object or nullish (null/undefined).
