Skip to content

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:

ts
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:

  1. stay — for stayMs (default 300) 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);
  2. blank — for the next blankMs (default 300), a blank surface, signalling the page is changing;
  3. 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 / redirect

pendingMinMs 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(...):

ts
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.

ts
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,
  }),
),
FeatureService helper(s)Default
withPendingComponentCraftPendingComponentDefaultCraftPendingComponent
withLoadingTextCraftLoadingTextlocale-aware (en/fr, fallback en)
withTransitionTimingsCraftStayMs / CraftBlankMs / CraftPendingMinMs300 / 300 / 0
withErrorComponentCraftErrorComponentnull
withRouteLoadErrorCraftRouteLoadErrorConfig / CraftRouteLoadRetrynull / one retry after 250 ms
withCraftViewTransitionsCraftViewTransitionsEnabled / CraftViewTransitionSkipBlankfalse / false
withA11yNavigationFocusCraftA11yNavigationFocusfalse

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:

ts
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.

ts
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 | null payload required on every craftRouterLink / navigate targeting it (null is 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.
ts
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):

ts
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:

ts
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:

ts
// 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 ​