In one line: under the Navigation API, a navigable (a tab or an iframe) can have only one "ongoing navigation." React DOM 19's default transition indicator borrows that single slot to spin the browser's loading indicator. In an engine that follows the spec, the way it borrows the slot can cancel the user's link click. Starting from that bug, this piece explains the Navigation API's ongoing-navigation model and how to think about building libraries on top of it.

Why now

The problem

SPAs want the browser's loading UI

In an MPA, where every page change fetches fresh HTML, clicking a link spins the tab's loading indicator, enables the stop button, and tells assistive tech that something is loading. SPAs rewrite the URL with history.pushState and fetch data themselves, so they lose all of those native signals.

The Navigation API's NavigateEvent.intercept({ handler }) exists to close that gap. The promise returned by the handler tells the browser how long the navigation takes and whether it succeeds. While it is pending, navigation.transition is set, and Chrome shows its native loading indicator and enables the stop button (per Chrome for Developers' Navigation API guide).

React's idea: treat a transition as a navigation

React 19's startTransition / useTransition mark updates that take time. In PRs #33160 and #33162, React added a root option, onDefaultTransitionIndicator. By default, any transition for which the app didn't render its own loading UI borrows the browser's loading indicator. Here is how it works (packages/react-dom/src/client/ReactDOMDefaultTransitionIndicator.js):

// Condensed; see the React repo for the real code
navigation.addEventListener('navigate', (event) => {
  if (event.canIntercept && event.info === 'react-transition') {
    event.intercept({
      handler() { return new Promise(r => (pendingResolve = r)); },
      focusReset: 'manual',
      scroll: 'manual',
    });
  }
});
// On every navigatesuccess / navigateerror, restart after 20ms if still pending
function startFakeNavigation() {
  if (navigation.transition) return; // wait for any ongoing one
  navigation.navigate(navigation.currentEntry.url, {
    state: navigation.currentEntry.getState(),
    info: 'react-transition',
    history: 'replace',
  });
}
setTimeout(startFakeNavigation, 100); // skip fast transitions

In other words, React starts a replace navigation to the current URL, intercepts it itself, and holds a promise that doesn't settle until the transition ends: a fake navigation. If another navigation interrupts it (navigateerror), React starts a new fake one 20ms later. A comment in the code says the 20ms async gap works around Chromium issue 419746417.

The idea itself is clever. The trouble is that a navigable has only one ongoing-navigation slot.

How it works

1. Ongoing navigation: the single slot

In the HTML spec, each navigable has an ongoing navigation, which is a navigation ID (a UUID), "traversal", or null. Partway through, the navigate algorithm does this:

Set the ongoing navigation for navigable to navigationId. This will have the effect of aborting other ongoing navigations of navigable…

As a cross-document navigation moves through its parallel steps, it keeps checking whether the ongoing navigation is still its own ID, and aborts itself if not. The model is simple: the moment a later navigation overwrites the slot, the earlier one has lost. It matches the browser intuition that the last link you clicked wins.