Skip to main content
@craft-ts
LearnLearn with EffectAI agentsGuideReference
@craft-ts/core@craft-ts/component@craft-ts/dev-tools@craft-ts/effect@craft-ts/style@craft-ts/style-testing@craft-ts/i18n@craft-ts/i18n-effect@craft-ts/mcp
ExamplesMigrationEffect compatibility & maturityAdopting CraftTS progressivelyPress kitRoadmapBacklog
Beta
  • Learn
  • Learn with Effect
  • AI agents
  • Guide
  • Reference
  • Packages
  • Resources
  • Guide overview
  • Create a project
i.Core concepts
  • The mental model
  • Which primitive should I use?
  • Anatomy of a primitive
  • Generators and yield*
  • Insertions
  • Typed insertion pipes
  • Exceptions as values
ii.Managing state
  • Local state
  • State machines
  • query
  • Mutations
  • queryParams
  • asyncProcess
  • Selecting
  • Reacting to mutations
  • Collections
  • Collection utilities
  • Persistence
  • GlobalPersisterHandler
  • Pagination placeholders
  • Schema validation
iii.Recommended approaches
  • Inject at the point of use
iv.Structuring the app
  • craftService
  • Service scopes
  • Shaping the public API
  • Abstract services
  • App start
  • Lazy services
  • Server functions
  • craftRegisterFor
  • Target wrapper
v.Routing & type-safe DI
  • Setup
  • CLI automation
  • ESLint rules
  • Route providers
  • Route guards
  • Route exception handling
  • Non-blocking navigation
  • Global error component
  • Route load errors
  • Scaling routes
vi.Components & templates
  • Components
  • Activating the style system
  • Typed styles
  • Defining a design system
  • Tokens and variables
  • Global foundation and fonts
  • Pseudo-elements and animations
  • Axes and the matrix
  • Context obligations
  • Testing visual states
  • Text contrast
  • Visual attestation
  • Template obligations
  • Typed CSS variables
  • Fine-grained reactivity
  • Progressive rendering
  • Directives and .pipe(...)
  • settledValue & pendingNode
  • Accessibility
  • Customization
  • Content projection
  • Styling a component
  • Template migrator
vii.Forms
  • Overview
  • Validators
  • Submitting
  • Nested forms
  • Exception handling
  • Complete examples
viii.Testing
  • Testing services
  • Testing components
  • Type-level tests
  • Browser boundaries
  • Page stability
  • Architecture rules
    • Overview
    • Declarative baseline
    • Unique identities
    • HTTP endpoint ownership
    • Computed purity
    • Dependency cycles
    • Mutation reactions
    • Route DI proofs
    • Route component files
    • Path boundaries
    • Exclusive links
    • Persisted identities
    • insertSelect keys
    • craftEffect and network
    • craftEffect and imperative sync
    • Interactive element names
    • Metric thresholds
    • Server-state loaders
    • Primitive loader requirements
  • Extensible architecture graph
  • Graph insights
  • Craft graph vs Nx
ix.Reactivity utilities
  • craftComputed
  • craftEffect
  • craftMethod
  • source$
  • on$
  • fromEventToSource$
  • sourceFromEvent
  • afterRecomputation
x.Internationalisation
  • Type-safe i18n
  • The catalogue
  • Tokens
  • The runtime
  • With Effect
xi.Deployment (experimental)
  • Overview
  • Manifest reference
  • Diagnostics
  • Providers
  • Alchemy provider
xii.Going further
  • SSR and hydration
  • Effect integration
  • Program operators
  • Pattern matching
  • Observability
  • AI agents
  • Temporal runtime
  1. Routing & type-safe DI
  2. Non-blocking navigation

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#

  • Route exception handling

  • Route guards — what the outlet is waiting on

  • Global error component

  • Architecture rules — assertRouteDiProofs keeps the pending-component proof armed

←Route exception handlingGlobal error component→

On this page

  • Setup
  • Lifecycle
  • Configuration
  • Per-route overrides
  • View Transitions
  • Shared element across a slow chain
  • Verifying the skeleton's DI
  • See Also

@craft-ts · MIT licensed

GitHubnpm

Search