Runtime route config
Two primitives for apps whose route config is not fully known at build time: throw a lazy subtree away and load it again, or swap a route's definition wholesale, transactionally.
@mmstack/router-corenpm
The use cases are things like a lazy feature whose routes are generated from data that changes, a preview of a page the user is editing, or an A/B variant. Both primitives are keyed by a marker id rather than by Route identity, because Router.resetConfig shallow-copies every route it standardizes: object identity goes stale while the marker survives.
#Remounting a lazy subtree
remountable(id) marks a lazy route; injectRemountHandle(id) invalidates it, which throws the loaded subtree away and runs 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');
}
}- Invalidation orphans the route object. The cached children, injector, module factory and component are dropped, and the
Routethey 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', andinvalidate()resolves once that navigation is done, so awaiting it means the subtree is back. Passnavigation: 'none'to drop 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 is 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 (the preload strategy otherwise warms a path at most once).
generationis 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 current.- The outcome says what happened:
remounted,no-op(the route had nothing cached, sogenerationdoes not move and no navigation runs), orrejected(below).
invalidate() takes an inFlight option for what to do when any navigation is already in flight. It is deliberately conservative about relevance, since a navigation mid-recognition can still turn out to touch the subtree. 'wait' (the default) runs once the in-flight navigation settles, and invalidations that queue up meanwhile coalesce into a single run. 'cancel-and-retry' aborts the in-flight navigation, then runs. 'reject' does nothing and resolves { 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 is 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 } from '@mmstack/router-core';
export const appRoutes: Routes = [
mountSwitchRoute('preview', () => ({
path: 'preview',
children: buildRoutesFromDefinition(currentDefinition()),
})),
];import { injectMountController } from '@mmstack/router-core';
@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.
committedmeans the navigation onto the new mount reachedNavigationEnd.rolled-back, withreason: 'cancelled' | 'error', means the navigation hit aNavigationError, or a cancel that is not 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 does not re-run, and whatever the abandoned navigation staged, its title registration included, is dropped with it.supersededmeans 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()),
}));