cobalt_flutter 0.1.2 copy "cobalt_flutter: ^0.1.2" to clipboard
cobalt_flutter: ^0.1.2 copied to clipboard

Flutter bindings for Cobalt — scope providers, widget-owned child scopes and BuildContext resolution helpers.

cobalt_flutter #

Flutter bindings for Cobalt.

CobaltScopeProvider(
  scope: await $startCobalt(),
  child: const MyApp(),
);

Resolve from any descendant:

final repo = context.cobalt<NoteStore>();

Widget-owned scopes #

A scope can belong to a piece of UI: created when it mounts, disposed when it unmounts, so a screen's dependencies live exactly as long as the screen.

The short way is to extend CobaltScopedWidget, which collapses the scope declaration, the wrapper and the content into one class:

class NoteDetailScreen extends CobaltScopedWidget {
  const NoteDetailScreen({super.key});

  @override
  void registerScope(CobaltScope scope) =>
      scope.registerLazySingleton<NoteDraft>(const NoteDraftFactory());

  @override
  Widget buildScoped(BuildContext context) =>
      Text(context.cobalt<NoteDraft>().text);
}

buildScoped runs below the scope, so context.cobalt<T>() resolves from it. Override scopeName, loading or errorBuilder when the defaults do not fit; the scope is otherwise named after the widget, which is what shows up in the scope tree.

CobaltScopedStatefulWidget is the stateful counterpart — the widget declares the scope, its CobaltScopedState overrides buildScoped, and setState rebuilds only the content. The scope is created once on mount, not on every rebuild.

Use CobaltScopeWidget directly when the scope has to wrap part of a subtree rather than a whole widget:

CobaltScopeWidget(
  builder: const NoteDetailScope(),
  loading: const CircularProgressIndicator(),
  errorBuilder: (context, error) => ErrorView(error),
  child: const NoteDetailPage(),
)

name is optional everywhere and defaults to the builder's type. If the scope registers async singletons, loading is shown while init() runs and errorBuilder receives anything it throws.

Who owns the root scope #

CobaltAppScope does. It takes the graph the same way CobaltApplication.start does, builds it, publishes it, and disposes it on unmount. Its usual home is MaterialApp.builder:

void main() => runApp(
  MaterialApp(
    theme: ThemeData(colorSchemeSeed: Colors.indigo),
    builder: CobaltAppScope.builder(
      root: const AppScope(),
      loading: const Scaffold(body: Center(child: CircularProgressIndicator())),
      errorBuilder: (context, error, retry) => StartupFailed(error, retry),
    ),
    home: const HomeScreen(),
  ),
);

In Code-Gen Mode the three generated names go straight in — no wrapper function in between:

builder: CobaltAppScope.builder(
  root: $CobaltRootScope(environment: environment),
  bootstrap: () => $cobaltBootstrap(environment),
  rootName: $cobaltRootScopeName,
),

Why builder and not above the app. Everything MaterialApp.builder returns sits below Theme, Directionality, MediaQuery and Localizations, and the child it hands you is the navigator. So loading and errorBuilder are ordinary screens with the app's theme — put the scope above MaterialApp instead and they have no theme at all, which is why they would each need a throwaway MaterialApp of their own.

If the app already uses builder, compose the two yourself; merging two builders is the app's decision, not the framework's:

builder: (context, child) => CobaltAppScope(
  root: const AppScope(),
  child: MyOwnWrapper(child: child!),
),

bootstrap is a function, not a list. Bootstrap steps are instances that hold resources, so a stored list would hand a restart the same objects it just released — the defect that made the generated $cobaltBootstrap a getter in the first place. root is a plain value, because an CobaltScopeBuilder only registers and carries no state.

For a graph the declarative form cannot express, CobaltAppScope.start(start: () async { ... }) takes a function returning a started scope.

Building the graph inside runApp rather than before it is the point. runApp(App(scope: await start())) has no way to show a startup failure — the app dies before its first frame. Here the failure is a screen with a retry. As a bonus, WidgetsFlutterBinding is already initialized when @CobaltBootstrap steps run.

CobaltAppScope.of(context).restart() tears the graph down and builds a new one; it is the same call that retries a failed start. The published provider is keyed by the scope, so a restart rebuilds the subtree — a child scope cannot be reparented, and would otherwise be left pointing at a root that is gone.

Changing the graph needs a key, or restart() #

CobaltAppScope reads root and bootstrap once, when it mounts. It has no didUpdateWidget, so putting a different graph in the same slot leaves the widget owning the graph it already built — and the next context.cobalt<T>() looks in the wrong one, failing with "not registered" for something that is plainly registered in the graph you thought you passed.

Two ways out, depending on what you meant. To replace one graph with another, give the widget a key that changes with the graph, so Flutter builds a new element instead of updating the old one. To rebuild the same graph, call CobaltAppScope.of(context).restart(), which disposes the old root first.

This mostly does not come up, because a route push builds a new element anyway. It bites in tests that pump one graph after another into the same position.

Hot reload keeps the graph; hot restart rebuilds it #

Measured on the iOS simulator, because the behaviour is easy to assume and easy to get wrong.

Hot reload leaves the graph alone. CobaltAppScope keeps its state, so the root scope is not rebuilt: no bootstrap step re-runs, no initializer re-runs, and every instance stays the one it was. Editing a widget takes effect immediately, which is the point.

That includes edits to registered classes, and this is the part that surprises people. Change what a factory or a bootstrap step produces, hot reload, and the screen still shows the old value — the instance already exists and nothing asked for a new one. Cobalt is doing what a singleton is for. When you are iterating on a service's construction, use hot restart, or call CobaltAppScope.of(context).restart() to rebuild only the graph while the app keeps running.

Hot restart rebuilds everything: a new isolate, so statics reset, phase 0 runs again, and the new code takes effect.

One caveat about hot restart, inherent to Flutter rather than to Cobalt: it replaces the isolate outright, so dispose() never runs. Whatever the old graph held — a socket, a file handle, a native binding — is dropped rather than released. restart() does not have this problem; it disposes the old root before building the new one.

disposeOnExitRequest is off by default #

Turning it on disposes the graph when the OS asks the app to quit. It is off because the hook behind it only fires where an exit is cancelable — Flutter's own docs say "Currently this is only supported on macOS and Linux" — and is blunt about the rest:

Do not rely on this function as a place to save critical data, because you will be disappointed.

On iOS and Android the process can be killed with no notification at all. So this is a desktop nicety, not a guarantee, and it delays quitting by however long teardown takes.

One sharp edge if you do enable it: Flutter asks every observer before quitting and does not stop at the first refusal. If another observer cancels the exit after this one has already disposed, the app keeps running with no graph and shows loading until something calls restart().

Objects that cannot say how to close themselves #

A scope releases what it built, in reverse creation order. It recognises exactly two things — Disposable and AsyncDisposable — plus whatever a registration named a dispose: function for. Dart has no structural typing, so a matching method signature is not enough, and almost every object a Flutter app registers has one without the declaration:

Type What it has What it needs
ChangeNotifier, ValueNotifier void dispose() implements Disposable — nothing else, the signature already matches
Bloc, Cubit Future<void> close() with CobaltBloc from cobalt_bloc, or the same two lines by hand
StreamController Future close() dispose: at the registration — it is not yours to change
class NotesController extends ChangeNotifier implements Disposable {}

class SessionCubit extends Cubit<Session> implements AsyncDisposable {
  @override
  Future<void> dispose() => close();
}

scope.registerSingleton(StreamController<Event>(), dispose: (it) => it.close());

In Code-Gen Mode the third route is @CobaltInject(dispose: closeIt), pointing at a top-level or static function that takes the registered type.

Forgetting the declaration is quiet: the object is built, used, and never closed. This is the single most common way to leak with Cobalt, and packages/cobalt_flutter/test/flutter_teardown_test.dart pins all four cases so the behaviour cannot drift into something the documentation does not say.

The two errors you will actually meet #

CobaltNoScopeError — nothing publishes a scope above the widget that asked. Usually a missing provider, but the other cause looks nothing like one and has cost this repository four separate debugging sessions: a route pushed with Navigator.push is built by the navigator, which sits above any provider mounted inside a screen. Code that resolved fine in place throws the moment the same widget is opened as a pushed route. Read the scope where the push happens and pass it into the pushed widget, rather than reading it there.

CobaltNoAppScopeError — nothing owns a root scope above the widget that asked to restart it. CobaltScopeProvider publishes a scope somebody else owns; only CobaltAppScope owns one, and only an owner can take it down and build it again.

Both are CobaltError subclasses, so a test can name the one it expects instead of matching on message text.

0
likes
160
points
183
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Flutter bindings for Cobalt — scope providers, widget-owned child scopes and BuildContext resolution helpers.

Repository (GitHub)
View/report issues

Topics

#dependency-injection #di #flutter #scopes

License

MIT (license)

Dependencies

cobalt, flutter

More

Packages that depend on cobalt_flutter