cobalt_go_router 0.1.2
cobalt_go_router: ^0.1.2 copied to clipboard
go_router bindings for Cobalt — dependency scopes whose lifetime is a navigation flow, created on entry and disposed on exit.
cobalt_go_router #
go_router bindings for Cobalt. A scope whose lifetime is a navigation flow: created when the flow opens, disposed when it closes.
CobaltShellRoute(
name: 'checkout',
identity: (state) => state.pathParameters['orderId'],
scope: (state) => CheckoutScope(state.pathParameters['orderId']!),
routes: [
GoRoute(path: '/orders/:orderId/summary', builder: (_, _) => const SummaryScreen()),
GoRoute(path: '/orders/:orderId/payment', builder: (_, _) => const PaymentScreen()),
],
)
It is an ordinary ShellRoute subclass, so it drops into a route table wherever a RouteBase
goes. Being a class, a flow can also be given a name of its own and reused:
class CheckoutFlowRoute extends CobaltShellRoute {
CheckoutFlowRoute()
: super(name: 'checkout', scope: ..., routes: [...]);
}
GoRouter(routes: [HomeRoute(), CheckoutFlowRoute()]);
There is also cobaltShellRoute(...), a function that builds the same object, for route tables
written that way. The class is the documented spelling — it reads alongside GoRoute and
ShellRoute, and it is the one that can be subclassed.
Inside the flow nothing new applies: context.cobalt<T>() resolves from the nearest scope, so the
flow shadows the root for exactly as long as it is open. No screen has to remember to clear
anything, and no repository grows a reset().
Naming #
Each type is named after the go_router type it extends, so a route table reads in one register:
| This package | extends |
|---|---|
CobaltShellRoute |
ShellRoute |
CobaltStatefulShellRoute |
StatefulShellRoute (including .indexedStack) |
CobaltStatefulShellBranch |
StatefulShellBranch |
CobaltRouteScope is the odd one out because it extends nothing — it is the widget that actually
owns the scope, and the route types above use it internally.
"Flow" stays a word for the concept, not for a type: the types say what they are, the docs say
what they are for. Naming your own CheckoutFlowRoute extends CobaltShellRoute is exactly right.
Why there is no router listener #
CobaltShellRoute is an ordinary ShellRoute, and the scope is owned by a widget inside it. That is
enough because of how go_router keys a shell's page:
pageKey: ValueKey<String>(route.hashCode.toString()) // go_router, match.dart
The key is the identity of the route object, so the Navigator reuses the same Route — and
the whole subtree — for every navigation within the flow, and destroys it the frame the flow
leaves the match list. The widget tree already knows the flow's lifetime; nothing has to watch the
router and mirror it. That mirroring is exactly where hand-rolled solutions break on the back
button, on deep links and on tab switches.
One consequence worth stating: construct the flow route once, with the rest of the route table.
A fresh CobaltShellRoute instance is a different flow as far as go_router is concerned, so rebuilding
the routes list on every frame would tear the scope down every frame.
Tabs — StatefulShellRoute #
Two levels are available, and they compose:
CobaltStatefulShellRoute.indexedStack(
name: 'workspace', // one scope for the whole shell
scope: (_) => const WorkspaceScope(),
shell: (_, _, navigationShell) => Scaffold(
body: navigationShell,
bottomNavigationBar: NavigationBar(
selectedIndex: navigationShell.currentIndex,
onDestinationSelected: navigationShell.goBranch,
destinations: [...],
),
),
branches: [
CobaltStatefulShellBranch( // and one scope per tab
name: 'feed',
scope: (_) => const FeedScope(),
routes: [GoRoute(path: '/feed', builder: (_, _) => const FeedScreen())],
),
CobaltStatefulShellBranch(
name: 'profile',
scope: (_) => const ProfileScope(),
routes: [GoRoute(path: '/profile', builder: (_, _) => const ProfileScreen())],
),
],
)
That gives three levels: the root, the shell's workspace, and feed / profile under it. Either
piece works alone — a plain StatefulShellRoute with CobaltStatefulShellBranch branches, or an
CobaltStatefulShellRoute with plain branches.
A branch is kept alive, not kept visible. This is the one thing to internalise. go_router
preserves branch navigators off-screen (IndexedStack plus AutomaticKeepAliveClientMixin), so a
branch scope is built the first time that tab is visited — or immediately, with preload: true —
and lives until the shell itself closes. Switching away disposes nothing. That is the whole point
of a stateful shell: the tab keeps its state, and its dependencies are part of that state.
If something must die when a tab is deselected, it does not belong to the tab. Give it a flow of
its own inside the tab, where leaving the flow is a real navigation. This package deliberately
offers no "dispose on deselect" switch: implementing it would mean watching currentIndex and
disposing imperatively, which is a second owner for a lifetime the widget tree already governs —
the thing this package exists to avoid.
A branch's default route cannot be parameterized #
go_router asserts it:
The default location of a StatefulShellBranch cannot be a parameterized route
'defaultGoRoute!.pathParameters.isEmpty'
So CobaltStatefulShellRoute with an identity read from a path parameter — /workspace/:id with tabs
under it — does not work unless every branch declares an explicit, literal initialLocation. This
is go_router's constraint, not ours, and it is worth knowing before designing routes around it.
Identity #
The router cannot decide one thing: whether /orders/1 and /orders/2 are the same flow. They
match the same ShellRoute, so by default the scope is reused. identity says otherwise — when
its value changes the scope is torn down and rebuilt:
identity: (state) => state.pathParameters['orderId'],
Leave it out when the flow has a single instance. The scope is named after the flow, with the
identity appended (checkout:42), so a scope tree stays readable.
What to expect #
- A frame of
loadingon every rebuild.CobaltScopeWidgetpublishes its scope only afterinit()finishes, even for a fully synchronous graph. Changingidentitytherefore flashes whatever you pass asloading. - Teardown of the outgoing scope is not awaited. On an identity change the old and new scopes are both children of the parent for a moment. Harmless for plain objects; visible if the flow holds a socket or a subscription.
- Nested flows cost a frame each. A flow inside a flow publishes its scope one frame after its parent does.
- Registrations are built during the widget build phase.
CobaltScopeBuilder.buildruns insidedidChangeDependencies, so a dependency whose constructor has a side effect that notifies listeners will triggersetState() called during build. Keep construction inert, or defer the notification (ascheduleMicrotaskis enough). - A scope cannot be reparented. If the root scope is replaced, the subtree has to be rebuilt
rather than left pointing at the old root.
CobaltAppScopehandles this — it keys the provider it publishes by the scope — so this only needs doing by hand if you own the root without it.
Known limitation #
A flow whose routes are not one subtree — /cart, /checkout and /payment declared at the top
level — cannot be expressed as a ShellRoute, so this package cannot scope it. Covering that would
mean owning the scope outside the widget tree, driven by routerDelegate.currentConfiguration, and
reconciling two sources of truth about one lifetime. That is the bug class get_it ships
dropScope for. Restructure the routes into a shell, or own the scope by hand the way
examples/notes_app owns its session scope.
onExit is not used #
go_router's onExit is a veto hook (FutureOr<bool>), and it is skipped entirely when the
navigator has no context. It answers "may we leave?", not "we left" — the wrong shape for teardown.