orbital_router

Flutter routing integration for Orbital modules, scopes, deep links and navigation guards.

Imports

orbital_router exports the routing API plus the core contracts used by route declarations.

If you want the default scope runtime, import orbital_injector as well:

import 'package:orbital_injector/orbital_injector.dart';
import 'package:orbital_router/orbital_router.dart';

If you prefer a single import surface, use package:orbital/orbital.dart.

updateRoutingConfig swaps are serialized: a swap issued while a previous one is still applying waits for it to finish, so two rapid swaps can never release the same stack scopes concurrently. Swaps are rejected with OrbitalShellRoutingConfigChangeException while a shell route is mounted — navigate away from the shell before swapping the routing config.

What this package contains

orbital_router connects the Orbital scope model to Flutter navigation:

  • OrbitalRouter(...)
  • OrbitalRouter.routingConfig(...)
  • OrbitalModule, OrbitalPageRoute and OrbitalModuleRoute
  • OrbitalRouterDelegate
  • OrbitalRouteContext
  • OrbitalMiddleware and OrbitalAsyncMiddleware
  • OrbitalRetentionPolicy
  • OrbitalReactivationPolicy
  • OrbitalScopeFactory
  • OrbitalScopeProvider plus BuildContext extensions
  • router debug snapshots consumed by orbital_devtools

Quick start

final router = OrbitalRouter(
  scopeFactory: const DefaultOrbitalScopeFactory(),
  initialUri: Uri.parse('/'),
  modules: [
    OrbitalModule(
      path: '/',
      routes: [
        OrbitalPageRoute(
          path: '/home',
          builder: (context, routeContext) => const HomePage(),
        ),
      ],
    ),
  ],
);

MaterialApp.router(
  routerConfig: router,
);

Keep the OrbitalRouter instance stable for the lifetime of the owning app state, and call router.dispose() from State.dispose() when you create it manually.

Pass onNavigationError to observe navigation failures that happen mid-session (after the router has already committed to a route) without disrupting the currently visible page:

OrbitalRouter(
  // ...
  onNavigationError: (error) => scaffoldMessengerKey.currentState
      ?.showSnackBar(SnackBar(content: Text('Navigation failed: $error'))),
);

The DevTools bridge auto-enables in debug mode as soon as OrbitalRouter is created, so the orbital_devtools panel works with zero application code — add orbital_devtools to dev_dependencies and you're done. Pass enableDevtoolsBridge: false to opt out, or devtoolsAppId to customize the app id shown in the panel:

OrbitalRouter(
  // ...
  devtoolsAppId: 'my_app', // or: enableDevtoolsBridge: false
);

Route tree model

Orbital composes navigation from modules and page routes.

Modules contribute:

  • a base path
  • module-scoped bindings
  • module-scoped middlewares
  • nested pages or submodules
  • optional onInit/onExit lifecycle callbacks

onInit(resolver) runs once, right after the module's own bindings finish initializing; onExit(resolver) runs once, right before the module's scope is actually disposed. Neither reruns when a persistentScope module is retained and later reactivated — only on a genuine create/dispose:

OrbitalModule(
  path: '/chat',
  bindings: [
    OrbitalBinding.singleton<ChatSocketService>((resolver) => ChatSocketService()),
  ],
  onInit: (resolver) => resolver.get<ChatSocketService>().connect(),
  onExit: (resolver) => resolver.get<ChatSocketService>().disconnect(),
  routes: [...],
)

Pages contribute:

  • a page path
  • page-scoped bindings
  • page-scoped middlewares
  • a widget builder
  • optional retention semantics
  • optional route and async page presentation overrides

Matching and module identity

Route matching prefers more specific branches first. Static segments outrank parameterized segments; declaration order is only the final tiebreaker.

OrbitalModule.id is the semantic identity used for scope reuse across rebuilt declarations. If you want a module scope to survive equivalent rebuilds, supply an explicit id.

Without id, reuse falls back to the module declaration identity inside the current delegate instance, which prevents unrelated modules that merely share the same path from accidentally reusing each other's scope.

Async navigation

A branch is treated as async when it contains async bindings or async middlewares.

Use:

  • asyncNavigationBuilder
  • asyncNavigationPageFactory
  • pageFactory

Precedence is:

  • page
  • nearest module
  • app-level default

Custom pageFactory and asyncNavigationPageFactory implementations must return a Page that preserves the supplied key. Orbital relies on that key to keep Navigator.pages identity stable across pops, redirects and rebuilds.

During async navigation, Orbital keeps the currently rendered route mounted under the async page by default. The fallback async page is non-opaque, so the active page remains visible unless a custom async page factory chooses a different presentation.

Experimental shell routes

OrbitalShellRoute mounts independent branch navigators inside a single shell page (bottom navigation, tabs, etc.). Each branch owns its own stack while the shell keeps inactive branches alive in an IndexedStack.

OrbitalShellRoute(
  path: '/app',
  initialBranchIndex: 0,
  shellBuilder: (context, shell) => Scaffold(
    body: OrbitalShellBranchStack(controller: shell),
    bottomNavigationBar: NavigationBar(
      selectedIndex: shell.activeIndex,
      onDestinationSelected: shell.switchBranch,
      destinations: const [
        NavigationDestination(icon: Icon(Icons.home), label: 'Home'),
        NavigationDestination(icon: Icon(Icons.settings), label: 'Settings'),
      ],
    ),
  ),
  branches: [
    OrbitalModule(path: '/home', routes: [...]),
    OrbitalModule(path: '/settings', routes: [...]),
  ],
)

Cross-branch pushNamed calls switch branches automatically. Deep links that match a branch URI route into the shell instead of the root not-found page.

Authenticated branch scopes stay alive until shell.disposeBranches() runs or the shell unmounts. A logout flow that only calls pushNamedAndRemoveAll('/login') leaves those scopes (and any sessions or tokens they hold) alive in memory. Call disposeBranches() as part of logout before navigating away, and only recreate the branches on the next authenticated session:

await shell.disposeBranches();
await router.routerDelegate.pushNamedAndRemoveAll('/login');

Shell lifetime vs host-stack resets

A shell stays alive while host-level routes are pushed above it — the shell is only deactivated (its branches keep running) until it becomes the top again. It is disposed only when the shell route itself leaves the host stack.

A host-level pushNamedAndRemoveAll(...) clears every stack entry, including the shell, so the next visit re-creates the shell and its branch controllers from scratch (branch onInit re-runs and per-page state is lost). Marking the shell module and route persistentScope: true retains the scope tree, but branch controllers are still re-created because they are widget state. To keep the shell (and the branch below it) alive while navigating to a host route, prefer pushReplacementNamed(...) or a plain pop over pushNamedAndRemoveAll(...).

OrbitalAsyncNavigationState.background remains available for custom builders that want to compose an explicit background widget.

Middleware

OrbitalMiddleware can:

  • continue navigation
  • block navigation
  • redirect to another URI

Hook points:

  • onBeforeRun
  • onAfterRun

Middlewares that may await must implement OrbitalAsyncMiddleware.

Guards currently run after Orbital has resolved the destination module and page scopes. This keeps guard code able to read scoped dependencies, but it also means a blocked navigation may pay the initialization cost for the attempted destination. Keep heavy or side-effecting initialization out of guarded bindings when unauthenticated users commonly hit those routes.

Scope access and navigation helpers

Pages can read route and scope state from:

  • OrbitalRouteContext
  • context.orbitalRouteContext
  • context.orbitalScope
  • context.getOrbital<T>()
  • context.pushNamed('/path')
  • context.pushReplacementNamed('/path')
  • context.pushNamedAndRemoveAll('/path')

Orbital now renders a real Navigator.pages stack.

  • pushNamed / pushUri push a new page entry
  • pushReplacementNamed / pushReplacementUri replace only the top entry
  • pushNamedAndRemoveAll / pushUriAndRemoveAll reset the stack to a single destination
  • pop() / popRoute() / popUntil(...) remove stack entries and reactivate the previous one

By default, reactivation preserves page and module scopes exactly as they were. OrbitalReactivationPolicy can opt routes or modules into rerunning middlewares when a stacked entry becomes active again. Precedence is router, then outer-to-inner modules, then the page route.

When OrbitalRouter.routingConfig(...) changes but all live entries still match the new route tree, Orbital preserves the current stack instead of collapsing it to a single page. If the active URI disappears from the new config, Orbital shows the configured not-found page for that URI and clears invalid stack entries.

On the web, browser back/forward route information is currently treated as a replace-all platform route update. Orbital does not yet restore the logical page stack from RouteInformation.state, so browser history should be considered URL-level navigation rather than full in-memory stack restoration.

Retention and scope rules

  • page bindings require createScope: true
  • persistent module and page scopes can be retained across navigations
  • page retention keys default to path + query + fragment
  • persistentScopeKeyBuilder lets apps override page retention semantics
  • OrbitalRetentionPolicy bounds retained module/page entries and evicts the oldest retained scope when capacity is exceeded

More detail

See doc.md for lifecycle flow, stack reactivation, middleware orchestration, async overlay behavior and error semantics.

Libraries

orbital_router