Transition outlet
Every navigation with a plain router-outlet unmounts the current page and renders the next one in its loading state, so you flash a screen of spinners on each click. This outlet holds the current page on screen until the incoming one has its data, then swaps both in a single frame.
@mmstack/router-corenpm
The flash is baked into how the default outlet works. A route change destroys the old component immediately and mounts the new one, which then starts fetching, so there is always a beat where the page is empty or full of skeletons. TransitionRouterOutlet changes the timing. The current route stays mounted and visible while the incoming route mounts hidden and its data settles; only then do the two swap. It is the routing application of the same transition-scope machinery behind @mmstack/primitives, which covers the same idea for tabs, switches, and any non-router view.
#mm-transition-outlet
<mm-transition-outlet /> is a drop-in replacement for <router-outlet />. Swap the selector in your shell and you are done, no other config required. Under the hood it provides its own transition scope, and the incoming route's resources register into that scope so the outlet can tell when the route is actually 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 {} A resource joins the scope by registering. With @mmstack/resource that is the register option; for a hand-rolled ResourceRef it is registerResource(). Once registered, the outlet waits on it, which is how it knows the difference between a route that is done and one that is still fetching.
// 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',
});
}#How it behaves
- First navigation mounts immediately. There is no previous view to hold, so the first paint is instant. From then on the outgoing route holds until the incoming one settles, then swaps and is destroyed.
- Settle means went in flight, then drained. The outlet waits for the incoming route's registered resources to start loading and then finish. A route that registers nothing, or errors out, swaps via a microtask fallback, so a data-less or failing route can never hang the hold.
- Composes with guards and resolvers. A denied
canActivateleaves the current route untouched, nothing held, nothing leaked. A pendingresolveholds at the router level first, then the outlet holds through the data load. It works nested inside a parent route's outlet too. - Interruptions re-target the hold. Navigate again before the incoming route settles and the half-loaded view is destroyed; the stable view you were on stays visible until the new destination settles. You never get stranded on a torn intermediate state.
- Per-view isolation. The swap waits on the incoming view's resources only, so background work left running on the outgoing view, a
keepPreviouspoll for instance, cannot delay the swap.
If a particular route should show its own skeleton instead of being held, set data: { immediateTransition: true } on it. It swaps in immediately, even while loading, and opts out of the hold for that route alone.
#View transitions
The swap can be wrapped in the browser's View Transitions API, so the old view cross-fades into the new one according to your ::view-transition-* CSS. It is feature-detected, so a browser without document.startViewTransition simply gets the instant swap. If you only want it on one outlet, set the attribute.
<mm-transition-outlet viewTransition /> To use it alongside Angular's own router view transitions, wrap Angular's option with mmRouterViewTransitions() and no attribute is needed. The wrapper exists to fix a timing mismatch: Angular fires its transition at route activation, but under this outlet activation is visually inert, since the incoming view mounts hidden and the real visual change happens later at the swap. So the wrapper skips Angular's inert transition on held routes and fires the real one at the swap, while non-held routes transition normally. Set [viewTransition]="false" on an outlet to opt it out even when router view transitions are enabled app-wide.
import { provideRouter, withViewTransitions } from '@angular/router';
import { mmRouterViewTransitions } from '@mmstack/router-core';
provideRouter(routes, withViewTransitions(mmRouterViewTransitions()));#holdThroughNavigation
The outlet holds the previous view when you navigate between two different routes. But some resources persist across a navigation and have no view swap to hide behind: an app-shell or layout resource that lives above the outlet, or a route reused on a param change (/users/1 to /users/2, same component). Those just refetch in place and flash to loading. This is the signal-level answer to that case.
holdThroughNavigation wraps any resource, @mmstack/resource's queryResource, Angular's httpResource, or a plain resource(), and returns a stabilized Resource whose state cannot flash during a 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()}`),
);
}- During a navigation the whole snapshot (value, status, error, loading) is frozen at the pre-navigation state, so a refetch the navigation triggers shows no torn or loading state.
- On success or skip it reveals, and it is settle-aware: the last settled snapshot is held through the first load cycle the navigation triggers, then revealed when it lands. Later reloads pass through live until the next navigation, so a manual
reload()still shows its indicator. - On a true rollback (a
NavigationError, or aNavigationCancelthat is not a redirect or superseded) it holds the pre-navigation snapshot until the resource stops loading, so a cancelled refetch reveals the route you stayed on, never the one you did not reach.
What you get back is a read-only Resource plus reload(), a drop-in anywhere a resource is read.
#Flash-free route-data param navigation
On a reused route, /users/1 to /users/2, the component stays mounted and only the param changes. There is no view swap, so the transition outlet has nothing to hold. The route's data just refetches in place and flashes to loading, exactly the case the outlet alone cannot cover.
Because route data hands you the resource ref directly, you can wrap it. Feed injectRouteData through holdThroughNavigation and the same instance the route started now holds its state across the param navigation instead of flashing.
import { holdThroughNavigation, injectRouteData } from '@mmstack/router-core';
@Component({ selector: 'user-page', template: `{{ user.value()?.name }}` })
export class UserPage {
// same instance the route started, now held across /users/1 → /users/2
readonly user = holdThroughNavigation(injectRouteData(USER));
}#Three tools, three scopes
These stack, and each holds a different thing. The transition outlet holds the outgoing view across a cross-route navigation. holdThroughNavigation holds a persisted resource's state across the navigation lifecycle, rollback included, for the cases with no view swap. And a transition scope holds a value while its registered resources load. Reach for the one whose unit matches what would otherwise flash.