npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@mmstack/router-core

v22.6.1

Published

Signal-based primitives for `@angular/router` — reactive router state, resolver-driven UI (titles, breadcrumbs, headless nav menus), and on-demand module preloading.

Readme

@mmstack/router-core

Signal-based primitives for @angular/router — reactive router state, resolver-driven UI (titles, breadcrumbs, headless nav menus), and on-demand module preloading.

Part of the @mmstack ecosystem, designed to complement @mmstack/primitives.

npm version

Installation

npm install @mmstack/router-core

Features

  • Reactive router state — read the current URL, path params, and query params as Angular Signals.
  • Resolver-driven UI — declare your document title, breadcrumbs, and one or more nav menus from your Routes config; consume them reactively from any component.
  • Smart preloading — a RouterLink replacement and PreloadingStrategy that preload lazy-loaded route modules on hover, visibility, or imperatively.
  • Transition navigation — a drop-in RouterOutlet that keeps the current route on screen until the incoming route's data settles, then swaps in one frame.
  • Visual commit — one signal for "the new route is on screen", plus scroll restoration and screen-reader announcements that fire on it instead of on NavigationEnd.
  • Route-level data — declare a route's data once; it fires at the resolve phase (before the component, in parallel across the matched chain), stays reactive to param/query changes, coordinates with the transition outlet, and can be warmed on mmLink hover.
  • Navigation hold — stabilize a persisted/reused resource across navigation so it never flashes to loading mid-transition, and rolls back cleanly on a cancelled navigation.
  • Route config at runtime — throw a lazy subtree away and load it again, or swap a route's whole definition transactionally, with a rollback when the navigation onto it aborts.

Table of contents


Reactive router state

Helpers that expose router state as Angular Signals — read them anywhere you'd read a signal (templates, computeds, effects).

url

A read-only Signal tracking the current router URL.

  • Updates after every successful navigation.
  • Reflects the URL after redirects (urlAfterRedirects).
  • Initializes synchronously with the router's current URL.
import { Component } from '@angular/core';
import { url } from '@mmstack/router-core';

@Component({
  selector: 'app-header',
  template: `<nav>Current path: {{ currentUrl() }}</nav>`,
})
export class HeaderComponent {
  protected readonly currentUrl = url();
}

navigationEndTick

A monotonically increasing counter signal that ticks on every successful navigation — including navigations whose resulting URL string equals the previous one (initial landing on /, onSameUrlNavigation: 'reload', redirects back to the same URL). Use it instead of the URL string to key recomputation of anything derived from router state snapshots.

const tick = navigationEndTick(inject(Router));
const leaf = computed(() => {
  tick(); // recompute per navigation, even same-URL reloads
  let r = router.routerState.snapshot.root;
  while (r.firstChild) r = r.firstChild;
  return r;
});

queryParam

A WritableSignal that two-way binds with a URL query parameter.

  • Reading returns the current value (or null if absent).
  • Setting updates the URL; setting null removes the parameter.
  • Reacts to external navigation changes.
  • Uses queryParamsHandling: 'merge', so unrelated params survive updates.
  • Each set() navigates immediately. Opt into batch: true to coalesce several same-tick writes into one navigation (otherwise each write rebuilds from the pre-navigation URL and only the last survives).
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { queryParam } from '@mmstack/router-core';

@Component({
  selector: 'app-search-page',
  imports: [FormsModule],
  template: `
    <input [(ngModel)]="searchTerm" placeholder="Enter search term..." />
    <button (click)="searchTerm.set(null)" [disabled]="!searchTerm()">
      Clear
    </button>
    <p>Current search: {{ searchTerm() ?? 'None' }}</p>
  `,
})
export class SearchPageComponent {
  protected readonly searchTerm = queryParam('q');
}

Typed & tuned params

The second argument accepts options for typed params and write behavior:

  • parse / serialize — convert between the URL string and a typed value. Provide both and the signal becomes WritableSignal<T | null> (parse runs on present params; an absent param reads as null directly; serialize returning null removes the param).
  • replaceUrl — write without creating a history entry (right call for type-ahead search boxes). Under batch, a history entry is kept unless every batched writer opted out.
  • debounce — milliseconds to debounce writes (reads stay instant).
  • batch — coalesce same-tick writes into a single navigation (default false; see above). Useful when resetting several params together.
  • route — bind to a specific ActivatedRoute instead of the injected one.
// number-typed page param
readonly page = queryParam<number>('page', {
  parse: (v) => {
    const n = parseInt(v, 10);
    return Number.isFinite(n) ? n : null;
  },
  serialize: (n) => (n <= 1 ? null : String(n)), // page 1 keeps the URL clean
});

// debounced type-ahead search, no history spam while typing
readonly q = queryParam('q', { replaceUrl: true, debounce: 300 });

Resolver-driven UI

Three helpers (createTitle, createBreadcrumb, createNavItems) hook into Angular's route resolve map (or title map for titles) to populate the document title, a breadcrumb trail, and one or more nav menus from your Routes config. They share the same pattern: declare on the route, consume reactively from a component.

Title

createTitle is a route resolver that sets the document title. Use it directly in the route's title property — it accepts static strings or signal-driven dynamic titles.

import { Routes } from '@angular/router';
import { inject } from '@angular/core';
import { createTitle } from '@mmstack/router-core';
import { ProductStore } from './product.store';

export const appRoutes: Routes = [
  {
    path: 'about',
    title: createTitle('About Us'),
    loadComponent: () =>
      import('./about.component').then((m) => m.AboutComponent),
  },
  {
    path: 'users/:id',
    // factories receive the route's ActivatedRouteSnapshot
    title: createTitle((route) => `User ${route.params['id']}`),
    loadComponent: () =>
      import('./user.component').then((m) => m.UserComponent),
  },
  {
    path: 'products/:id',
    // Signal-driven title — the inner function becomes a computed under the hood.
    title: createTitle(() => {
      const products = inject(ProductStore);
      return () => `Product: ${products.product().name ?? 'Loading...'}`;
    }),
    loadComponent: () =>
      import('./product-detail.component').then((m) => m.ProductComponent),
  },
];

Title registrations made during a navigation are staged — they're buffered and applied when the swap reaches the screen (the visual commit), and dropped if the navigation is cancelled or errors. So a guard-rejected navigation can never flip the document title, and a held transition doesn't retitle the page while the previous view is still the one on screen. Without a transition outlet the commit is one render after NavigationEnd, and on the server it is NavigationEnd, so nothing changes for SSR.

Breadcrumb registrations stage the same way but flush at NavigationEnd, since breadcrumbs render outside the outlet and lead the swap.

Configuration (optional)

provideTitleConfig customizes title formatting and fallbacks:

  • prefixstring | (title: string) => string. Static prefix, or a full formatter for complete control over the result.
  • keepLastKnownTitle (default true) — when navigating to a route that doesn't provide a title, hold the last route-driven title instead of clearing it.
  • initialTitle — explicit fallback when no route title is active. If omitted, TitleStore captures Title.getTitle() once at construction (typically the <title> from index.html). Set this explicitly if you set the document title imperatively before the router has bootstrapped.
import { provideRouter } from '@angular/router';
import { provideTitleConfig } from '@mmstack/router-core';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(appRoutes),
    provideTitleConfig({
      prefix: (title) => (title ? `${title} — MyApp` : 'MyApp'),
      initialTitle: 'MyApp',
    }),
  ],
};

Breadcrumbs

A signal-based, headless breadcrumb toolkit. Breadcrumbs are auto-generated from route segments by default, with per-route overrides via createBreadcrumb. Consume the reactive list with injectBreadcrumbs.

import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
import { injectBreadcrumbs } from '@mmstack/router-core';

@Component({
  selector: 'app-breadcrumbs',
  imports: [RouterLink],
  template: `
    <nav aria-label="breadcrumb">
      <ol>
        @for (crumb of breadcrumbs(); track crumb.id) {
          <li>
            <a
              [routerLink]="crumb.link()"
              [attr.aria-label]="crumb.ariaLabel()"
            >
              {{ crumb.label() }}
            </a>
          </li>
        }
      </ol>
    </nav>
  `,
})
export class BreadcrumbsComponent {
  protected readonly breadcrumbs = injectBreadcrumbs();
}

Heads up: crumb.link() is a serialized URL string. Bind it to [routerLink] (or [mmLink] if you want preloading) — [href] would trigger a full page reload.

Overriding a breadcrumb

When auto-generation isn't enough, register a custom breadcrumb in the route's resolve map. The factory runs in an injection context, so you can pull labels from stores, i18n services, etc.

import { Routes } from '@angular/router';
import { inject } from '@angular/core';
import { createBreadcrumb } from '@mmstack/router-core';
import { UserStore } from './user.store';

export const appRoutes: Routes = [
  {
    path: 'home',
    component: HomeComponent,
    resolve: {
      // Shorthand for { label: 'Home' } — also accepts an options object or a factory returning either.
      breadcrumb: createBreadcrumb('Home'),
    },
  },
  {
    path: 'admin',
    component: AdminComponent,
    data: { skipBreadcrumb: true }, // opt out of auto-generation for this route
  },
  {
    path: 'users/:userId',
    component: UserProfileComponent,
    resolve: {
      // the factory receives the route's ActivatedRouteSnapshot
      breadcrumb: createBreadcrumb((route) => {
        const userStore = inject(UserStore);
        return {
          label: () =>
            userStore.user(route.params['userId'])()?.name ?? 'Loading...',
        };
      }),
    },
  },
];

Configuration (optional)

provideBreadcrumbConfig controls auto-generation behavior:

  • generation: 'manual' — disable auto-generation entirely; only routes with createBreadcrumb produce breadcrumbs.
  • generation: () => (leaf: ResolvedLeafRoute) => string — supply a custom label generator. The outer function runs in a root injection context, so you can inject stores or i18n services there.
import {
  provideBreadcrumbConfig,
  type BreadcrumbConfig,
  type ResolvedLeafRoute,
} from '@mmstack/router-core';

const customStrategy: BreadcrumbConfig['generation'] = () => {
  return (leaf: ResolvedLeafRoute): string =>
    leaf.route.data?.['navTitle'] ?? leaf.route.title ?? 'Default';
};

export const appConfig: ApplicationConfig = {
  providers: [provideBreadcrumbConfig({ generation: customStrategy })],
};

Nav menus

A headless, scope-aware navigation menu primitive. Routes declare nav items via createNavItems; components consume them via injectNavItems() as Signal<NavItem[]>. When multiple routes in the active chain register items for the same scope, the deepest active registration wins — navigating away restores the shallower one.

import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
import { injectNavItems } from '@mmstack/router-core';

@Component({
  selector: 'app-top-bar',
  imports: [RouterLink],
  template: `
    <nav>
      @for (item of items(); track item.id()) {
        <a
          [routerLink]="item.link()"
          [class.active]="item.active()"
          [attr.aria-disabled]="item.disabled()"
        >
          {{ item.label() }}
        </a>
      }
    </nav>
  `,
})
export class TopBar {
  protected readonly items = injectNavItems();
}

Heads up: item.link() is a serialized URL string. Bind it to [routerLink] (or [mmLink] for preloading) — [href] would cause a full page reload.

Registering items

Items are declared in a route's resolve map. Links resolve relative to the route the resolver is attached to, matching Angular's routerLink convention — a leading slash makes a link absolute.

import { Routes } from '@angular/router';
import { createNavItems } from '@mmstack/router-core';

export const appRoutes: Routes = [
  {
    path: '',
    resolve: {
      // Root menu — visible on every page unless a deeper route overrides.
      // Absolute paths work fine for top-level app menus:
      nav: createNavItems([
        { label: 'Home', link: '/' },
        { label: 'Products', link: '/products' },
        { label: 'About', link: '/about' },
      ]),
    },
    children: [
      {
        path: 'products',
        loadComponent: () =>
          import('./products.component').then((m) => m.ProductsComponent),
        resolve: {
          // Inside /products, the menu changes — root menu is shadowed until we navigate away.
          // Relative links work too — these resolve against /products:
          nav: createNavItems([
            { label: 'All', link: '/products' },
            { label: 'Featured', link: 'featured' }, // → /products/featured
            { label: 'Categories', link: 'categories' }, // → /products/categories
          ]),
        },
      },
    ],
  },
];

NavItem.active is computed against the current URL with subsetMatchOptions defaults (prefix-match paths, subset query params, ignore matrix/fragment). Override per-item with activeMatch: Partial<IsActiveMatchOptions> or globally with provideNavConfig({ activeMatch }).

Link resolution rules

| Input | Resolved to (when the resolver route is mounted at /myLib) | | -------------------- | ------------------------------------------------------------ | | 'a' or 'a/b' | /myLib/a, /myLib/a/b | | ['a', 'b'] | /myLib/a/b | | '/elsewhere' | /elsewhere (absolute escape) | | ['/fooBar', 'baz'] | /fooBar/baz (absolute escape) | | UrlTree | passed through unchanged |

Relative-by-default makes nav items portable across mount points — particularly useful for nx feature libraries that export Routes without knowing where the consuming app will mount them:

// libs/my-feature/src/lib/routes.ts
export const myFeatureRoutes: Routes = [
  {
    path: '',
    component: MyFeatureShellComponent,
    resolve: {
      nav: createNavItems([
        { label: 'Overview', link: 'overview' }, // → ${mount}/overview
        { label: 'Settings', link: 'settings' }, // → ${mount}/settings
      ]),
    },
    children: [
      { path: 'overview', component: OverviewComponent },
      { path: 'settings', component: SettingsComponent },
    ],
  },
];

// apps/host/src/app/app.routes.ts — the consumer picks the mount path.
export const appRoutes: Routes = [
  {
    path: 'my-feature',
    loadChildren: () =>
      import('@org/my-feature').then((m) => m.myFeatureRoutes),
  },
  // Same lib, same nav items, different mount — links resolve correctly:
  {
    path: 'admin/tools',
    loadChildren: () =>
      import('@org/my-feature').then((m) => m.myFeatureRoutes),
  },
];

Named scopes

Pass { name } when a route declares more than one menu (e.g. top bar + side bar). The resolve key is just a unique handle Angular requires; the store keys on name.

resolve: {
  mainNav: createNavItems([...primary], { name: 'main' }),
  sideNav: createNavItems([...secondary], { name: 'side' }),
}

// consumers
@Component({ ... }) class TopBar  { items = injectNavItems('main'); }
@Component({ ... }) class SideBar { items = injectNavItems('side'); }

Children, hidden, disabled

Items can declare children for nested menus. By default a parent is active when its own link matches OR any descendant is active — useful for grouping headers with no own link. Setting activeMatch explicitly disables the OR; pass matchesWhenChildActive: true to re-enable it.

hidden filters the item (and its subtree) out of the consumer-facing array. disabled is preserved on the item and cascades to descendants — useful for permission-gated subtrees:

createNavItems(() => [
  {
    label: 'Admin',
    link: 'admin',
    hidden: () => !permissions.isAdmin(), // signal-driven
    children: [
      { label: 'Users', link: 'admin/users' },
      { label: 'Settings', link: 'admin/settings' },
    ],
  },
]);

Default (fallback) items

Routes register items on-demand, so any URL with no registration in its active chain renders an empty menu. provideNavConfig({ defaults }) declares fallback items rendered on those URLs — handy for landing pages, error routes, or app shells where a few items should always be visible:

provideNavConfig({
  defaults: [
    { label: 'Home', link: '/' },
    { label: 'Docs', link: '/docs' },
  ],
}),

Relative links on defaults resolve from / (the router root), so link: 'home' becomes /home. Absolute links and UrlTrees pass through unchanged.

Named scopes are supported via the record form:

provideNavConfig({
  defaults: {
    '': [{ label: 'Home', link: '/' }],          // default scope
    main: [{ label: 'Home', link: '/' }],         // injectNavItems('main')
    side: () => [{ label: 'Settings', link: '/settings' }], // factory
  },
}),

Shadowing follows the usual deepest-wins rule — any active route that calls createNavItems (including createNavItems([]) to render an explicitly empty menu) replaces the defaults for that scope.

Typed metadata

CreateNavItem and NavItem carry a TMeta generic so consumers can attach app-specific fields (icons, badges, etc.) without the library imposing a shape:

type NavMeta = { icon: string };

createNavItems<NavMeta>([{ label: 'Home', link: '/', meta: { icon: 'home' } }]);

// in the component
items = injectNavItems<NavMeta>();
// items()[0].meta().icon → 'home'

Preloading

Two complementary primitives speed up lazy-loaded routes: a PreloadingStrategy that listens for preload requests, and a RouterLink replacement that issues them on hover or visibility. An imperative escape hatch (injectTriggerPreload) covers the cases where the directive isn't a fit.

PreloadStrategy

A custom PreloadingStrategy that defers preloading until something asks for a specific route. It pairs with the Link (mmLink) directive or injectTriggerPreload — neither preloads anything on its own.

  • Listens for preload requests triggered by Link / injectTriggerPreload.
  • Path-matches the requested URL against the route config (supports route params, matrix params, and wildcards).
  • Skips preloading on slow connections (effectiveType: '2g') or when the browser reports saveData — evaluated at request time, so conditions improving later aren't locked out.
  • Respects data: { preload: false } on a route config to opt that route out.
  • data: { preloadDelay: 150 } debounces hover-intent — the load starts that many ms after the first request, so accidental pointer flybys don't fetch chunks.
  • Deduplicates: each path is preloaded at most once (failed loads may retry on the next request).

Provide it alongside provideRouter:

import { PreloadStrategy } from '@mmstack/router-core';
import { provideRouter, withPreloading } from '@angular/router';

export const appConfig: ApplicationConfig = {
  providers: [provideRouter(routes, withPreloading(PreloadStrategy))],
};

Link (mmLink)

The Link directive (used as mmLink) wraps Angular's RouterLink and adds preloading. All standard routerLink inputs (queryParams, fragment, state, relativeTo, etc.) are proxied through unchanged.

  • preloadOninput<'hover' | 'visible' | null>() (default: 'hover'). null disables preloading. This is the WHEN.
  • preloadinput<'all' | 'code'>() (default: 'all'). The WHAT: 'all' warms the lazy chunk AND the route's data (withRouteData); 'code' warms only the chunk — for links to routes whose data is expensive or shouldn't fire speculatively. (Per-KEY opt-out belongs in the factory via ctx.isPrefetch.)
  • preloadingoutput<void>() fires when the route is queued for preload (before the JS actually loads).
  • useMouseDown — navigate on mousedown instead of click (shaves ~50–100ms off perceived latency). The press's own click event is swallowed, so the navigation runs exactly once; keyboard activation still works.
  • beforeNavigateinput<() => void>() hook invoked just before an SPA navigation triggered by this link. Modified/middle clicks and target="_blank" links are left to the browser and skip the hook.

App-wide defaults for preloadOn / preload / useMouseDown can be set once via provideMMLinkDefaultConfig({ ... }).

Replace existing routerLinks with mmLink to opt them in:

import { Component } from '@angular/core';
import { Link } from '@mmstack/router-core';

@Component({
  selector: 'app-navigation',
  imports: [Link],
  template: `
    <nav>
      <!-- preload on hover (default) -->
      <a [mmLink]="['/features']">Features</a>
      <!-- preload when scrolled into view -->
      <a [mmLink]="['/pricing']" preloadOn="visible">Pricing</a>
      <!-- no preload -->
      <a [mmLink]="['/contact']" [preloadOn]="null">Contact</a>
    </nav>
  `,
})
export class NavigationComponent {}

injectTriggerPreload

When the directive isn't a fit — preloading from a signal effect, on a keyboard shortcut, when a command palette opens — injectTriggerPreload() returns a function that runs the same preload pipeline imperatively. Same PreloadStrategy requirement.

import { Component, effect, signal } from '@angular/core';
import { injectTriggerPreload } from '@mmstack/router-core';

@Component({
  /* ... */
})
export class CommandPaletteComponent {
  private readonly triggerPreload = injectTriggerPreload();
  protected readonly highlighted = signal<string | null>(null);

  constructor() {
    effect(() => {
      const target = this.highlighted();
      if (target) this.triggerPreload(target);
    });
  }
}

Transition outlet

By default a route change unmounts the current view immediately and the incoming view renders in its loading state — a flash of spinners on every navigation. TransitionRouterOutlet turns navigation into a transition: the current route stays mounted and visible while the incoming route mounts hidden and its data settles, then both swap in one frame. It's the routing application of @mmstack/primitives' holdUntilReady / transition-scope machinery — the Angular take on a Suspense-driven route transition.

TransitionRouterOutlet (mm-transition-outlet)

A drop-in replacement for <router-outlet>. It provides its own transition scope, so the incoming route's resources register into it (use @mmstack/resource's register option, or registerResource() for a hand-rolled ResourceRef) and the outlet can tell when the route is ready.

import { Component } from '@angular/core';
import { TransitionRouterOutlet } from '@mmstack/router-core';

@Component({
  selector: 'app-shell',
  imports: [TransitionRouterOutlet],
  template: `<mm-transition-outlet />`,
})
export class AppShell {}
// the incoming route registers its data so the outlet knows when to swap
@Component({ selector: 'user-page', template: `…{{ user.value()?.name }}` })
export class UserPage {
  readonly user = queryResource<User>(() => `/api/users/${this.id()}`, {
    register: 'indicator',
  });
}

Behaviour:

  • First navigation mounts immediately (nothing to hold). After that, the outgoing route holds until the incoming one settles, then swaps and is destroyed — navigation still respects "tree = f(URL)".
  • Settle = the incoming route's registered resources went in flight and then drained. A route that registers nothing (or errors) swaps via a microtask fallback, so a data-less or failing route never hangs the hold.
  • Composes with guards and resolvers — a denied canActivate leaves the current route untouched (nothing held or leaked); a pending resolve holds at the router level, then the outlet holds through the data load. Works when nested inside a parent route's outlet too.
  • Interruptions re-target the hold — navigating again before the incoming route settles destroys the half-loaded view; the stable view stays visible until the new destination settles.
  • Per-view isolation — the swap waits on the incoming view's resources only, so long-running background work (e.g. a keepPrevious poll) on the outgoing view can't delay it. Routes that opt into route-level data get their own scope automatically (full isolation); others share the outlet's scope, with the swap attributed to the incoming view.
  • data: { immediateTransition: true } on a route opts it out of the hold — it swaps in immediately, even while loading (handy for routes that should show their own skeleton).

What a staged view may do

While it is staged, the incoming view is constructed, rendered and running — it is only hidden (display: none plus inert). Anything it does inside its own subtree is invisible until the swap, which is the whole point. Anything it does to the page outside that subtree is not, and lands while the user is still looking at the previous route:

  • overlays, dialogs, portals, toasts — anything appended to the body
  • body or :root classes, CSS variables, scroll position
  • writing document.title directly

Titles are the one case handled for you: register them with createTitle and they're buffered until the swap. For the rest, defer the work until the view is visible — read injectVisualCommit, or move it into the swapped-in view's own subtree.

swapCommit

An output that fires once per swap this outlet performs, saying how it ended.

<mm-transition-outlet (swapCommit)="onSwap($event)" />

| outcome | Meaning | | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | | committed | a held view was swapped out and the incoming view is on screen | | immediate | the outlet activated without holding (nothing to hold, immediateTransition, or a RouteReuseStrategy re-attach) and has rendered | | superseded | an armed hold was dropped or re-targeted before it could commit; the outlet re-arms under the interrupting navigation | | outlet-destroyed | the outlet was destroyed while still holding a view |

committed and immediate fire after the view is actually on screen, which under a hold is well after NavigationEnd. For the navigation-wide answer across several (or nested) outlets, read injectVisualCommit instead.

View Transitions

The swap can be wrapped in the browser's View Transitions API — the old view cross-fades (or whatever your ::view-transition-* CSS says) into the new one. Feature-detected: browsers without document.startViewTransition fall back to the instant swap.

Standalone — just set the attribute:

<mm-transition-outlet viewTransition />

Alongside Angular's router view transitions — wrap Angular's option with mmRouterViewTransitions() and it just works, no attribute needed:

import { provideRouter, withViewTransitions } from '@angular/router';
import { mmRouterViewTransitions } from '@mmstack/router-core';

provideRouter(routes, withViewTransitions(mmRouterViewTransitions()));
<mm-transition-outlet />

Why the wrapper is needed: Angular fires its transition at route activation, but under this outlet activation is visually inert — the incoming view mounts hidden and the real visual change happens later, at the swap (once the route's data settles). So for routes the outlet holds, Angular's activation-time transition would be an invisible no-op that just freezes the page for its duration. mmRouterViewTransitions() coordinates the two:

  • Non-held routes (first navigation, data.immediateTransition, routes that load nothing) — Angular transitions them normally; the swap is synchronous with activation.
  • Held routes — Angular's inert transition is skipped, and the outlet fires the real one at the swap. The same ::view-transition-* CSS applies to both.

Your own onViewTransitionCreated / skipInitialTransition options are preserved (pass them to mmRouterViewTransitions({ ... })). To opt a specific outlet out even when router view transitions are enabled app-wide, set [viewTransition]="false".


Visual commit

NavigationEnd means the router is finished, not that anything changed on screen. Under a held transition the incoming view is still hidden at that point, and with several or nested outlets there's 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 screen finally shows the new route. Anything that has to line up with what the user sees — scroll restoration, focus moves, announcements, analytics — belongs there rather than on NavigationEnd.

injectVisualCommit

A read-only Signal of { status, navigationId }. navigationId is the router's own NavigationStart.id, so it correlates with router events.

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(() => {
      if (this.commit().status === 'committed') this.analytics.pageView();
    });
  }
}
  • pending from NavigationStart until every outlet that armed for the navigation has committed or swapped in immediately. A navigation no outlet armed for commits one render after NavigationEnd.
  • committed once the swap is on screen.
  • idle after a cancelled or failed navigation that no successor follows and that left no swap outstanding.
  • An interrupting navigation re-enters pending under 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 pending under 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 and afterNextRender never runs, so NavigationEnd is 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.

Heads up: this 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 doesn't already have one (dropped again on the next blur), and focused with preventScroll so it can't fight scroll restoration, and
  • announces the new document title in a polite live region.

The title is read after the hold-aware title store has applied, so what's announced is what the page is actually called.

The initial navigation fires neither half: it rides the document load, which gives a screen reader those signals itself — focus at the top, title announced. Firing there would instead yank focus into the page on every load.

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 });

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.


Route-level data

Define a route's data once, on the route, and have it fire at the resolve phase — before the component constructs, in the route's injector, with the matched params in hand — instead of waiting for the component to mount and kick off a fetch. It's non-blocking (the request runs while the transition outlet holds the previous view), stays reactive to param/query changes, and the component just reads it.

It's built entirely on the transition-scope primitive, so @mmstack/router-core has no dependency on a resource library. Your factory is the only place a resource is named — use @mmstack/resource's queryResource (or Angular's httpResource + registerResource()); anything that produces a ResourceRef works.

Defining route data

Three pieces: a typed routeDataKey, provideRouteData(key) in the route's providers (provides the per-route transition scope + a memoization slot), and createRouteData(key, factory) in its resolve map (fires the factory). The component reads it with injectRouteData(key).

import {
  routeDataKey,
  provideRouteData,
  createRouteData,
  injectRouteData,
} from '@mmstack/router-core';
import { queryResource, type QueryResourceRef } from '@mmstack/resource';

const USER = routeDataKey<QueryResourceRef<User | undefined>>('user');

export const routes: Routes = [
  {
    path: 'users/:id',
    loadComponent: () => import('./user.page').then((m) => m.UserPage),
    providers: [provideRouteData(USER)],
    resolve: {
      user: createRouteData(USER, (ctx) =>
        queryResource(() => `/api/users/${ctx.params()['id']}`, {
          defaultValue: undefined,
          register: 'suspend', // the outlet holds the previous view until this settles
          cache: { staleTime: 30_000 }, // optional — enables prefetch-on-hover (below)
        }),
      ),
    },
  },
];
@Component({ selector: 'user-page', template: `{{ user.value()?.name }}` })
export class UserPage {
  // the resource the route already started — reads the same instance, already in flight
  readonly user = injectRouteData(USER);
}

Behaviour:

  • Fires before the component — the factory runs at the resolve phase, so the request is already in flight when the component mounts (which it does hidden, under the transition outlet). Sibling/nested route data fires in the same activation pass, so a matched chain loads in parallel.
  • Reactive params, define-oncectx.params() / ctx.queryParams() are live signals derived from router state on every navigation. They update on param/query changes without relying on the route's runGuardsAndResolvers — so you define the factory once and it keeps producing correct data (a query-param change refetches even though the resolver itself doesn't re-run). ctx.param('id') is the single-param sugar: a memoized signal with a dev-mode error when the name doesn't exist on the matched route (typo guard) — prefer it over ctx.params()['id'].
  • Memoized — the factory runs once per route activation; a re-running resolver reuses the same instance. The data lives as long as the route, and is destroyed with it.
  • Coordinates with the outletregister: 'suspend' makes the TransitionRouterOutlet hold the previous view until the data settles; register: 'indicator' drives the busy indicator without blocking the swap. Opting in also gives the route its own transition scope (per-view isolation).
  • No outlet required — without a transition outlet the data still fires and is readable; you just don't get the held-transition behavior.
  • Cross-data access — a child route's factory can read an ancestor's slot directly: injectRouteData(PARENT_KEY) works inside the factory (resolve order is parent-first and the factory's injector sees ancestor route providers; integration-tested). Params inherit the same way via ctx.params().
  • Refresh from a component — the slot IS the resource ref: injectRouteData(USER).reload() is the "pull to refresh this page" story; no extra API.

When the data errors — two composable levers:

createRouteData(USER, factory, {
  // the "what does the app do next" hook: redirect, toast-and-stay, log.
  // Fires per TRANSITION into error (first load, reloads, param re-fetches);
  // never fires for speculative prefetch errors.
  onError: (err, ctx) => ctx.injector.get(Router).navigateByUrl('/not-found'),
});

Without onError the default stands: the outlet swaps on settle-by-error and the component renders the slot's error() — the in-view error-boundary pattern (@if (user.error(); as e) { ... }). Use the boundary for recoverable in-place errors, onError for route-level policy.

Guards reading warm data — share the cache key, not the slot: a functional guard runs before resolvers (so the slot for the navigation being guarded isn't populated yet — which is why there's deliberately no guardFromRouteData helper), but guards run in an injection context, so a guard that fetches through the same cached query the factory uses leaves the cache warm and the factory's resource resolves from it without a second request:

const canSeeUser: CanActivateFn = async (snapshot) => {
  const user = queryResource<User>(
    () => `/api/users/${snapshot.params['id']}`,
    { cache: { staleTime: 30_000 } }, // SAME url ⇒ same cache key as the factory's query
  );
  const value = await until(user.value, (v) => v !== undefined);
  return value.active || createUrlTreeFromSnapshot(snapshot, ['/inactive']);
};

Prefetch on hover

Opt in with withRouteData() and the same mmLink preload signal that loads a lazy chunk also warms the route's data: on hover/visibility, the factory runs with params parsed from the link URL, populating your resource cache so the eventual navigation reads it warm (deduped). It's the preload="intent"ensureQueryData pattern, wired to your existing links.

import { provideRouter, withPreloading } from '@angular/router';
import { PreloadStrategy, withRouteData } from '@mmstack/router-core';

bootstrapApplication(App, {
  providers: [
    provideRouter(routes, withPreloading(PreloadStrategy)),
    withRouteData(), // hovering an mmLink now warms route data, not just code
  ],
});

Notes:

  • Needs a cache to be useful. Prefetch warms whatever shared cache your factory's resource writes to (e.g. @mmstack/resource's provideQueryCache() at the app root). Without one, the hover fetch isn't reused by the navigation.
  • Two-phase for lazily code-split routes. The route's data factory isn't visible until its chunk has loaded, so the first hover warms the code and a subsequent hover warms the data; eager (non-lazy) routes warm data on the first hover.
  • On the prefetch path ctx.isPrefetch is true and params come from the hovered URL (there's no ActivatedRoute yet) — a factory can branch on it if needed.
  • Hovers are deduped per link — but failures re-arm. A warm that resolves stays deduped; one that errors, times out (timeout, default 30s), or throws is forgotten so the next hover retries. A factory may also return an object of resources ({ user, posts }) — every member is watched and the warm scope stays alive until all of them settle.

Flash-free param navigation. A route-data resource on a reused route (e.g. /users/1 → /users/2) refetches in place — the outlet can't hold it (same component, no view swap). Wrap it with holdThroughNavigation for a flash-free, rollback-safe transition.


Any resolver can join the pipeline. createRouteData resolvers are tagged automatically; withPrefetch(resolver, { description, prefetch }) tags arbitrary ones — navigation runs the wrapped resolver unchanged, hover runs your prefetch(ctx) speculatively (params extracted from the link URL, ctx.isPrefetch: true, deduped per link + description). The canonical consumer is @mmstack/translate: warming a route's locale chunk on hover (see its README's ecosystem recipes).

Navigation hold

The transition outlet holds the previous view when navigating between different routes. But a resource that persists across a navigation — an app-shell/layout resource, or a route reused on a param change — has no view swap to hold; it just refetches in place and flashes to loading. holdThroughNavigation is the signal-level answer to that.

holdThroughNavigation

Wraps any resource (@mmstack/resource's queryResource, Angular's httpResource or resource()) and returns a stabilized Resource whose state can't flash through mid-navigation:

import { holdThroughNavigation } from '@mmstack/router-core';

@Component({ selector: 'user-page', template: `{{ user.value()?.name }}` })
export class UserPage {
  private readonly id = injectParam('id'); // your param signal
  // a reused route on param change refetches in place — stabilize it
  readonly user = holdThroughNavigation(
    queryResource<User>(() => `/api/users/${this.id()}`),
  );
}

Behaviour:

  • During a navigation the whole snapshot (value / status / error / loading) is frozen at the pre-navigation state — a refetch the navigation triggers shows no torn or loading state.
  • On success or skip (NavigationEnd / NavigationSkipped) it reveals — settle-aware: a navigation's refetch typically starts just after NavigationEnd (live params tick on it), so the last settled snapshot is held through that first load cycle and revealed when it lands. Once the cycle completes, loads pass through live again (a later reload()'s indicator shows normally) until the next navigation.
  • On a true rollback (NavigationError, or a NavigationCancel that isn't a redirect / superseded-by-a-new-navigation) it holds the pre-navigation snapshot until the resource stops loading — so a cancelled refetch settling back to the route you stayed on reveals cleanly, never the would-be state of the route you didn't reach.
  • Redirect / superseded cancels stay frozen — a new navigation is already taking over and drives the next state, with no flicker in between.

The return value is a read-only Resource (value(), status(), error(), isLoading(), hasValue(), snapshot()) plus reload(), so it's a drop-in for templates and anywhere a Resource is read. It composes with route-level data — holdThroughNavigation(injectRouteData(USER)) gives a route's data flash-free param navigation.

Three tools, three layers — reach for the one that matches:

| Tool | Holds | Trigger | | --- | --- | --- | | TransitionRouterOutlet | the outgoing view | cross-route navigation | | holdThroughNavigation | a persisted resource's state | navigation lifecycle (with rollback) | | <mm-suspense> / transition scope commit | a value while registered resources load | scope pending |


Route config at runtime

Two primitives for apps whose route config isn't fully known at build time: a lazy feature whose routes are generated from data that changes, a preview of a page the user is editing, an A/B variant. Both are keyed by a marker id rather than by Route identity — Router.resetConfig shallow-copies every route it standardizes, so identity goes stale while the marker survives.

Remounting a lazy subtree

remountable(id) marks a lazy route; injectRemountHandle(id) invalidates it — throw the loaded subtree away and run loadChildren again.

import { Routes } from '@angular/router';
import { remountable } from '@mmstack/router-core';

export const appRoutes: Routes = [
  {
    path: 'reports',
    loadChildren: () => import('./reports/routes').then((m) => m.reportRoutes),
    data: { ...remountable('reports') },
  },
];
import { Component } from '@angular/core';
import { injectRemountHandle } from '@mmstack/router-core';

@Component({
  /* ... */
})
export class ReportDesigner {
  private readonly reports = injectRemountHandle('reports');

  async onDefinitionChanged() {
    const { outcome } = await this.reports.invalidate();
    if (outcome === 'remounted') this.toast('Reports reloaded');
  }
}

Behaviour:

  • Invalidation orphans the route object — the cached children, injector, module factory and component are dropped, and the Route they were cached on is replaced in the config. A load or preload already in flight lands on the discarded object, so it can never repopulate the live config.
  • It re-enters the current URL with onSameUrlNavigation: 'reload', and invalidate() resolves once that navigation is done — await invalidate() means "the subtree is back". navigation: 'none' drops the cache without navigating; the next navigation into the subtree picks up the fresh load.
  • The old subtree's injectors are destroyed once the replacement has loaded and its navigation is visually committed, the point at which the old view is gone by construction. Under navigation: 'none' that's the eventual next load of the marker — the still-mounted view keeps its injector until then, stale by design.
  • Preload memory is cleared for the invalidated path and everything under it, so the subtree can be hover-warmed again (PreloadStrategy otherwise warms a path at most once).
  • generation is a counter signal that bumps on every invalidation that dropped something — key derived state off it, or use it to tell whether work started under an older config is still the current one.
  • The outcome says what happened: remounted, no-op (the route had nothing cached, so generation doesn't move and no navigation runs), or rejected (below).

invalidate() takes an inFlight option for what to do when any navigation is already in flight. It's deliberately conservative about relevance: a navigation mid-recognition can still turn out to touch the subtree.

  • 'wait' (default) — run once the in-flight navigation settles. Invalidations that queue up meanwhile coalesce into a single run, which navigates if any of the queued callers asked it to.
  • 'cancel-and-retry' — abort the in-flight navigation, then run.
  • 'reject' — do nothing, and resolve { outcome: 'rejected' }.

The handle is shared per id, so every injection sees the same one. invalidate() throws if no route in the config carries the marker — that's a wiring bug, not a runtime outcome.

Swapping a mount

mountSwitchRoute(id, factory) declares a route whose definition can be replaced at runtime; injectMountController(id) performs the swap. The factory produces the route: once for the initial mount, again for every switch.

import { mountSwitchRoute, injectMountController } from '@mmstack/router-core';

export const appRoutes: Routes = [
  mountSwitchRoute('preview', () => ({
    path: 'preview',
    children: buildRoutesFromDefinition(currentDefinition()),
  })),
];
@Component({
  /* ... */
})
export class PreviewToolbar {
  private readonly preview = injectMountController('preview');

  // later, when the definition changes:
  async rebuild() {
    const { outcome } = await this.preview.switch();
    if (outcome === 'rolled-back') this.toast('Preview could not be rebuilt');
  }
}

Swapping is transactional: the new definition goes into the config, navigation re-enters (the current URL, or switch({ target })), and the transaction settles on the router's own events.

  • committed — the navigation onto the new mount reached NavigationEnd.
  • rolled-back, with reason: 'cancelled' | 'error' — the navigation hit a NavigationError, or a cancel that isn't a redirect (a guard rejecting the new definition, say). The previous definition goes back into the config with its lazy cache intact, so the loader doesn't re-run, and whatever the abandoned navigation staged, its title registration included, is dropped with it.
  • superseded — a newer switch took over the config first. The queue is one deep and the newest wins; the newer transaction inherits the older one's rollback point, so a rollback lands on the mount that was last live rather than on one that only ever existed mid-transaction.

switch() is for anywhere outside the router's own recognition pass — an effect, a click handler. Inside recognition, use beginSwitch(): it swaps synchronously and returns the UrlTree the navigation should re-enter with, which is exactly what a canMatch guard returns to redirect. The router's redirect hop then lands on the new mount, and the transaction rides it rather than reading it as an abort.

mountSwitchRoute('preview', () => ({
  path: 'preview',
  canMatch: [
    () => {
      const controller = injectMountController('preview');
      // the redirect hop runs this guard again — the second pass must not swap again
      if (!definitionChanged()) return true;

      void controller.outcome().then((result) => {
        if (result.outcome === 'rolled-back') selected.set(lastCommitted());
      });
      return controller.beginSwitch();
    },
  ],
  children: buildRoutesFromDefinition(currentDefinition()),
}));

outcome() resolves with the result of the switch currently in flight or, when there is none, of the next one to begin. Same taxonomy either way, so a beginSwitch() caller can react to a rollback without subscribing to router events itself; switch() returns the same promise for the transaction it starts, so the two views of one transaction can't disagree.

The controller is shared per id, which is what makes the queue-of-one global to the mount rather than per caller.