Non-blocking navigation
By default, a slow guard or resolver can leave the current screen unchanged with no feedback. CraftRouterOutlet() commits the URL immediately and shows a pending component only if the wait is actually noticeable.
Use it when guards or resolvers do real work — an HTTP call, a permission check. For synchronous routes, the outlet renders the target immediately.
CraftRouterOutlet() provides non-blocking navigation: the URL commits immediately, a pending component appears only if the guard/resolve chain is slow, and the target component is mounted only on success — never while an exception is being resolved.
Setup
Call the outlet inside a Craft component tree:
import { CraftRouterOutlet, craftComponent, main } from '@craft-ts/component';
export const App = craftComponent(
'App',
{},
() => ({}),
() => main(CraftRouterOutlet()),
);Routes with no craft guard or resolver render immediately.
Lifecycle
For a route with a craft chain, on navigation the outlet lets the URL commit immediately (no blocking guard), then runs three phases while the chain is in flight — so a fast navigation never flashes a blank screen or a loader:
- stay — for
stayMs(default300) the previous page is kept on screen. The chain runs in the background; if it settles within this window, the outlet transitions straight to the target (no blank, no loader); - blank — for the next
blankMs(default300), a blank surface, signalling the page is changing; - pending — the pending component (loader) is shown until the chain settles.
On success the outlet writes the resolved data and mounts the target; on exception it applies the route's handleExceptions outcome.
Lazy JavaScript load failures (loadComponent / loadChildren) happen before the outlet can mount the target route. Configure withRouteLoadError to retry those failures and render a recovery screen while keeping the browser URL on the intended route. A slow JavaScript download or retry does not currently activate this pending timeline; dedicated loading UI for that earlier phase is a planned evolution.
click → URL committed
├─ 0 → stayMs ........ PREVIOUS page kept ─(resolved)─▶ target
├─ stayMs → +blankMs . BLANK page ─(resolved)─▶ target
└─ beyond ............ LOADER (min pendingMinMs) ─(resolved / redirect)─▶ target / redirectpendingMinMs adds anti-flicker: once the loader is shown, it stays visible for at least that long, so a chain that settles right after it appears does not blink it in and out.
The previous page is kept alive (not re-created) during stay: the outlet renders through a single component slot it leaves untouched until the phase changes, so the old component instance keeps its state for the duration of the window.
Configuration
The loading and error features are plain feature objects. The recommended place for them is directly in provideCraftRouter(...):
provideCraftRouter(
appRoutes.toRoutes(),
withCraftViewTransitions(), // craft loading feature (see below)
withErrorComponent({
component: MyGlobalErrorScreen,
componentDeps: {} as import('./global-error').GenDeps_MyGlobalErrorScreen,
}),
withRouteLoadError({
component: MyRouteLoadErrorScreen,
componentDeps:
{} as import('./route-load-error').GenDeps_MyRouteLoadErrorScreen,
retry: { attempts: 1, delayMs: 250 },
}),
withTransitionTimings({ stayMs: 300, blankMs: 300, pendingMinMs: 500 }),
withLoadingText(() => computed(() => translate('common.loading'))),
withPendingComponent(MyBrandedSpinner),
),Most loading features still work standalone via provideCraftLoading(...) if you prefer to keep them in a separate provider. Keep withRouteLoadError(...) in provideCraftRouter(...): it also registers a navigation error handler and an internal recovery route.
provideCraftLoading(
withTransitionTimings({ stayMs: 300, blankMs: 300, pendingMinMs: 500 }),
withLoadingText(() => computed(() => translate('common.loading'))),
withPendingComponent(MyBrandedSpinner),
withErrorComponent({
component: MyGlobalErrorScreen,
componentDeps: {} as import('./global-error').GenDeps_MyGlobalErrorScreen,
}),
),| Feature | Service helper(s) | Default |
|---|---|---|
withPendingComponent | CraftPendingComponent | DefaultCraftPendingComponent |
withLoadingText | CraftLoadingText | locale-aware (en/fr, fallback en) |
withTransitionTimings | CraftStayMs / CraftBlankMs / CraftPendingMinMs | 300 / 300 / 0 |
withErrorComponent | CraftErrorComponent | null |
withRouteLoadError | CraftRouteLoadErrorConfig / CraftRouteLoadRetry | null / one retry after 250 ms |
withCraftViewTransitions | CraftViewTransitionsEnabled / CraftViewTransitionSkipBlank | false / false |
withA11yNavigationFocus | CraftA11yNavigationFocus | false |
The default pending component renders CraftLoadingText, which reads LOCALE_ID and picks a built-in translation (Loading… / Chargement…).
Per-route overrides
Any route may override the defaults via route fields that are stripped before the runtime route is emitted:
craftRoute('user/:userId', {
// …
stayMs: 150, // shorten the "keep previous page" window
blankMs: 0, // skip the blank phase → straight to loader
pendingComponent: () => import('./user-skeleton'),
// reactiveGuards: false, // opt out of live guards (on by default)
}),View Transitions
The default view-transition feature brackets only the synchronous URL commit in document.startViewTransition(). With the non-blocking outlet that is the wrong instant: the target component mounts after the guard/resolve chain settles, so a shared-element morph captures previous page → (stay/loader) and the real previous → target morph is lost — worse, a full-screen loader becomes the captured "old" frame.
withCraftViewTransitions() hands the morph to the outlet instead: it drives document.startViewTransition() around its own swaps (previous page → skeleton → target), so the morph survives even a slow chain. It guards prefers-reduced-motion, falls back to a plain swap when the API is missing, and is overridable in tests via the CRAFT_START_VIEW_TRANSITION seam.
provideCraftRouter(
appRoutes.toRoutes(),
withCraftViewTransitions(),
),Shared element across a slow chain
For the morph to bridge a slow navigation, something carrying the shared element's view-transition-name must stay on screen while the chain runs — the pending skeleton. A route opts in by declaring the shared-element payload shape with viewTransitionPayload<T>() — the view-transition analogue of how queryParams declares a route's query-params shape. This:
- makes a typed
viewTransition: T | nullpayload required on everycraftRouterLink/navigatetargeting it (nullis an explicit opt-out); - exposes a route-generated, fully-typed
injectXxxViewTransition(): Signal<T | null>helper; - tells the outlet to skip the blank phase (a blank would break the morph):
stay → pending → loaded.
export const { photosRoutes, injectPhotosPhotoIdViewTransition } = craftRoutes(
'photos',
[
craftRoute(
':photoId',
{
componentDeps:
{} as import('./photo-detail').GenDeps_PhotoDetailComponent,
loadComponent: ({ withRetry }) => withRetry(import('./photo-detail')),
withLoaderViewTransitionImage: viewTransitionPayload<{
name: string;
image: string | null;
}>(),
pendingComponent: () => import('./photo-skeleton'),
// The skeleton's DI is verified separately (see "Verifying the skeleton's DI").
canActivate: function* () {
/* slow guard */
},
},
{
/* … */
},
),
],
).withParent<ParentRoutes<'photos'>>();This collection is a lazy child mounted via loadChildren; its routed components carry their own per-route DI checks. Because its components depend on the :photoId param and the declared view-transition payload, it is only correct under the photos route — so it is pinned to that mount with .withParent<ParentRoutes<'photos'>>(), and the parent enforces it with assertChildRouteMounts(...). See Pinning a lazy child to its mount path.
The link passes a payload of the declared type (required, and shape-checked):
a({}, 'Photo').pipe(
CraftRouterLink({
to: 'photos/:photoId',
params: { photoId: photo.id },
viewTransition: { name: 'photo-' + photo.id, image: photo.preview },
}),
);The skeleton receives photoId as a route-bound input and reads the payload through the route-generated typed helper:
import { input } from '@angular/core';
export default class PhotoSkeleton {
protected readonly photoId = input.required<string>();
// Signal<{ name: string; image: string | null } | null> — typed by the route.
private readonly viewTransition = injectPhotosPhotoIdViewTransition();
protected readonly image = computed(
() => this.viewTransition()?.image ?? null,
);
// template: <span [style.view-transition-name]="'photo-' + photoId()"> … </span>
}The global, untyped
injectCraftViewTransition(): Signal<unknown>still exists for ad-hoc reads, but prefer the route-generated helper when you have a declared payload.
The payload travels in navigation state, so it is lost on reload or direct URL access — there is no previous page to morph from in that case anyway; the app stays functional (skeleton without the preview image, then the target). Pass withCraftViewTransitions({ skipBlank: true }) to skip the blank phase for every route, not just opted-in ones.
Verifying the skeleton's DI
The pending skeleton is a real component that injects dependencies (route params, the typed payload, monitoring, …). It is verified independently with the per-component, O(1) RouteCheckedDI check:
// The skeleton injects the `:photoId` param and the typed payload — both
// auto-provided by the route, so list those service names as available; the
// parent context (`AppValues` here) is the same one the route uses.
type _CheckPendingDI = RouteCheckedDI<
import('./photo-skeleton').GenDeps_PhotoSkeletonComponent,
'PhotosPhotoIdParams' | 'PhotosPhotoIdViewTransition',
AppValues,
'pending component: photos/:photoId'
>;
type _CanRunPending = CanRun<_CheckPendingDI>;A service the skeleton injects but nothing provides becomes a TypeScript error on _CanRunPending (The X service is not provided in pending component: photos/:photoId). The craft-ts/require-pending-component-di-check ESLint rule generates and refreshes this whole block from pendingComponent on --fix — resolving the skeleton's dependency metadata and deriving the auto-provided service names from the route's path params + payload.
Architecture tests (assertRouteDiProofs) fail if that pending proof is missing or not armed with CanRun.
See Also
- Route exception handling
- Route guards — what the outlet is waiting on
- Global error component
- Architecture rules —
assertRouteDiProofskeeps the pending-component proof armed