Skip to content

ui-router-navigation-location-plugin

NPM VersionGitHub Release

ui-router-navigation-location-plugin is a UI-Router location plugin that manages URLs with the modern browser Navigation API instead of the History API — and exposes the router inside navigation events, something the classic plugins cannot do.

It works with any @uirouter/core-based router, not just lit-ui-router.

Experimental

This plugin is a new implementation without the production mileage of the battle-tested pushState/hash location services. The Navigation API itself is also only recently cross-engine (Firefox 147+, Safari 26.2+), so non-Chromium behavior is lightly exercised.

How location plugins fit in

UI-Router delegates all URL reading and writing to a pluggable location service. @uirouter/core ships two: hashLocationPlugin (/#/path) and pushStateLocationPlugin (/path, History API). This package adds a third strategy with the same clean URLs as pushState:

FeatureNavigation APIpushStatehash
Modern standardYesNoNo
Event interceptionYesNoNo
Browser supportModern enginesWideUniversal
Production mileageNewWideWide
SEO friendlyYesYesNo
Clean URLsYesYesNo

Installation

bash
npm install ui-router-navigation-location-plugin
# or
pnpm add ui-router-navigation-location-plugin

Quick start

ts
import { navigationLocationPlugin } from 'ui-router-navigation-location-plugin';
import { UIRouterLit } from 'lit-ui-router';

const router = new UIRouterLit();
router.plugin(navigationLocationPlugin);

Feature-detect with a fallback

The Navigation API is supported in Chrome/Edge 102+, Firefox 147+, and Safari 26.2+ (caniuse). For older browsers, fall back to the History API — the URLs are identical:

ts
import { pushStateLocationPlugin } from '@uirouter/core';
import { navigationLocationPlugin } from 'ui-router-navigation-location-plugin';

router.plugin(
  'navigation' in window ? navigationLocationPlugin : pushStateLocationPlugin,
);

This is the strategy the sample app uses by default — its Preferences → Feature Flags panel lets you switch between all three plugins and shows browser compatibility for each.

The plugin's headline feature: it passes the UIRouter instance along in each navigation's info metadata, so a global navigate listener can distinguish router-driven navigations from everything else and access the router while handling them:

ts
import { isUIRouterNavigateEvent } from 'ui-router-navigation-location-plugin';

window.navigation.addEventListener('navigate', (event) => {
  if (isUIRouterNavigateEvent(event)) {
    // A UIRouter transition drove this navigation
    const { uiRouter } = event.info;
    event.intercept({
      async handler() {
        // e.g. integrate view transitions, analytics, progress UI…
      },
    });
  }
});

What else observes navigation

navigation.navigate() does not call history.pushState, does not call history.replaceState, and does not fire popstate. Anything that watches for navigation by patching those methods — Google Analytics' enhanced measurement is the common case, and most session-replay and RUM shims do the same — is therefore blind to router-driven navigation under this plugin. Nothing throws; the events simply stop arriving, which is the kind of thing you discover in production a week later.

The gap is narrower than it first looks. Taking gtag as the worked example:

NavigationWho sees it
Cold loadgtag, via its own config call
Router transitionnobody — this is the gap
Back/forward (traverse)gtag, via popstate

So the fix is not "switch the integration off and send everything by hand" — that double-counts traversals. Send exactly what the observer cannot see, by reading the navigation kind off a second navigate listener:

ts
let lastNavigationType = '';

// records only — a `navigate` listener is an interception boundary
// solely when it calls `event.intercept()`
window.navigation.addEventListener('navigate', (event) => {
  lastNavigationType = event.navigationType;
});

router.transitionService.onSuccess({}, () => {
  // read once per transition: a transition with no `navigate` event of its
  // own must not reuse the previous one's kind
  const navigationType = lastNavigationType;
  lastNavigationType = '';

  if (navigationType === 'push' || navigationType === 'replace') {
    gtag('event', 'page_view', { page_location: location.href });
  }
});

The sample app boots this plugin by default, so it is running the rule live: see ga.js, whose Cypress suite asserts the resulting page_view counts separately under each location plugin.

API summary

  • navigationLocationPlugin — the plugin factory; pass it to router.plugin(...)
  • NavigationLocationService — the location service class (extends BaseLocationServices from @uirouter/core); handles URL reads/writes via the Navigation API, including <base href> handling for non-root deployments and navigation state/title metadata
  • isUIRouterNavigateEvent(event) — type guard: was this NavigateEvent triggered by UIRouter?
  • UIRouterNavigateEvent / UIRouterNavigateInfo — the extended event and info types carrying the uiRouter instance

See the full API reference.

Further reading