Visual commit
NavigationEnd means the router is finished, not that anything changed on screen. The visual commit is the moment the screen actually shows the new route, and it is where scroll restoration, focus moves, announcements and analytics belong.
@mmstack/router-corenpm
Under a held transition the incoming view is still hidden when NavigationEnd fires, and with several or nested outlets there is no single moment NavigationEnd could stand in for. The visual commit is that moment: every outlet that armed for the navigation has finished its swap, so the user is finally looking at the new route. Anything that has to line up with what they see belongs there rather than on a router event.
#injectVisualCommit
A read-only signal of { status, navigationId }. navigationId is the router's own NavigationStart.id, so it correlates with router events if you need to line the two up.
import { Component, effect, inject } from '@angular/core';
import { injectVisualCommit } from '@mmstack/router-core';
@Component({ selector: 'app-shell' /* ... */ })
export class AppShell {
private readonly analytics = inject(Analytics);
private readonly commit = injectVisualCommit();
constructor() {
effect(() => {
// fires when the new route is actually on screen, not at NavigationEnd
if (this.commit().status === 'committed') this.analytics.pageView();
});
}
}pendingfromNavigationStartuntil every outlet that armed for the navigation has committed or swapped in immediately. A navigation no outlet armed for commits one render afterNavigationEnd.committedonce the swap is on screen.idleafter a cancelled or failed navigation that no successor follows and that left no swap outstanding.- An interrupting navigation re-enters
pendingunder its own id; outlets whose hold it superseded re-arm under it. - A navigation that dies with no successor while an earlier navigation's hold is still on its way to the screen falls back to
pendingunder that earlier navigation, and commits when it finally swaps. The status tracks outstanding visual work, not the router's bookkeeping: a hold that lands for real gets a commit even though the navigation that interrupted it never arrived. - On the server nothing paints, so
NavigationEndis the commit and outlet arms are ignored.
Two providers ride this signal. Both are opt-in, both fire once per committed navigation, and neither fires for a navigation superseded before it reached the screen.
#Scroll restoration
provideTransitionScrollRestoration() restores scroll on the visual commit. Angular's own restoration scrolls when the router activates the route, which under a hold is while the previous view is still on screen: the old page jumps, and the new one arrives already scrolled to the wrong place. This restores after the swap, when the content the position refers to actually exists.
import { provideRouter } from '@angular/router';
import { provideTransitionScrollRestoration } from '@mmstack/router-core';
bootstrapApplication(App, {
providers: [provideRouter(routes), provideTransitionScrollRestoration()],
});- Back and forward restore the position that page was left at.
- A forward navigation goes to the top, or to the element named by the URL fragment.
- It switches the browser's own restoration to
manual, since the browser would otherwise restore against the pre-swap DOM.
It replaces withInMemoryScrolling({ scrollPositionRestoration: 'enabled' }). Enable one or the other, not both, or the two fight over the same scroll.
#Route announcements
provideRouteA11y() makes route changes perceivable to assistive technology. A client-side navigation replaces the page without any of the signals a document load gives a screen reader: focus stays wherever it was, and nothing is announced. On the commit it moves focus to the root element of the view that swapped in (given a transient tabindex="-1" if it does not already have one, and focused with preventScroll so it cannot fight scroll restoration), and announces the new document title in a polite live region.
import { provideRouteA11y } from '@mmstack/router-core';
bootstrapApplication(App, {
providers: [provideRouter(routes), provideRouteA11y()],
});
// both halves default to on; announce only, the app moves focus itself:
provideRouteA11y({ focus: false });The title is read after the hold-aware title store has applied, so what is announced is what the page is actually called. With nested outlets the focus target is the outermost view that swapped in; two sibling outlets swapping in one navigation have no containment relation, so the first to settle is the one focused.