From 7f08b90f3dd7de01f7d2801f2b2440ee19dfe3b0 Mon Sep 17 00:00:00 2001 From: Jacob Moura Date: Tue, 23 Jun 2026 11:09:17 -0300 Subject: [PATCH 1/2] feat(state): add context.select for granular page-scoped reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `context.select(selector)` to `ModularStateX` — the method-based twin of the existing `Selector` widget, mirroring `context.select` from `provider` to ease migration. The calling widget rebuilds only when the selected value changes (`==`), instead of on every notification. To support per-dependent selection over a page-scoped value, `_VMInherited` now uses a custom `InheritedElement` that reproduces `InheritedNotifier`'s trigger-driven rebuilds while honoring per-dependent selector aspects (a `watch` dependent — no aspect — is still notified on every trigger). Bumps flutter_modular to 7.0.2 and documents the API in the README and the v7 state-management page. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 7 + README.md | 3 + doc/docs/flutter_modular/state-management.md | 16 +++ lib/flutter_modular.dart | 2 +- lib/src/state/scoped.dart | 138 +++++++++++++++++++ pubspec.yaml | 2 +- test/select_test.dart | 120 ++++++++++++++++ 7 files changed, 286 insertions(+), 2 deletions(-) create mode 100644 test/select_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 92049450..44014988 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 7.0.2 + +- **`context.select(selector)`** — the method-based twin of the `Selector` + widget. Reads a value derived from a page-scoped `T` and rebuilds the calling + widget **only when the selected value changes** (`==`). Mirrors + `context.select` from `provider` to ease migration; call it from `build`. + ## 7.0.1 - **Page-scoped BLoC/Cubit support.** New `Scoped.addStreamable(ctor, diff --git a/README.md b/README.md index d93edd4a..f826dae1 100644 --- a/README.md +++ b/README.md @@ -251,6 +251,9 @@ c.route('/counter', // inside the page: final vm = context.watch(); // rebuilds when the VM notifies + +// granular: rebuild only when the selected value changes (provider-style) +final count = context.select((vm) => vm.count); ``` diff --git a/doc/docs/flutter_modular/state-management.md b/doc/docs/flutter_modular/state-management.md index e8af5d2c..7c30459e 100644 --- a/doc/docs/flutter_modular/state-management.md +++ b/doc/docs/flutter_modular/state-management.md @@ -231,6 +231,22 @@ Selector( ); ``` +`context.select` is the **method‑based twin** of `Selector` — call it inside +`build` to subscribe to a derived value; the widget rebuilds only when that value +changes (compared with `==`). It mirrors `context.select` from **provider**, so +migrating is a near drop‑in: + +```dart +// Inside build(): rebuilds only when `cart.items.length` changes. +final count = context.select((cart) => cart.items.length); +return Badge(label: Text('$count')); +``` + +:::note +Like in provider, only call `context.select` from `build` — never in `initState` or +`didChangeDependencies`. +::: + ## App‑scoped state {#app-scoped-state} Page‑scoped state lives *below* the `Navigator`, so it cannot rebuild the `MaterialApp` diff --git a/lib/flutter_modular.dart b/lib/flutter_modular.dart index d1adf752..cfaee1e7 100644 --- a/lib/flutter_modular.dart +++ b/lib/flutter_modular.dart @@ -2,7 +2,7 @@ /// /// Navigator 2.0 (route matching + page stack + guards + transitions), the /// module system (`createModule` / `ModularContext`), page-scoped state -/// (`provide` / `Scoped` + `context.watch`/`read`, `Consumer`/`Selector`; +/// (`provide` / `Scoped` + `context.watch`/`read`/`select`, `Consumer`/`Selector`; /// `addChangeNotifier`/`addStream` as the rule, `addStreamable`/`addListenable` /// for BLoC/Cubit-style objects, `add` for non-reactive resources), and nested /// routes (`children` + `RouterOutlet`). diff --git a/lib/src/state/scoped.dart b/lib/src/state/scoped.dart index 090b06d9..1a7b1c03 100644 --- a/lib/src/state/scoped.dart +++ b/lib/src/state/scoped.dart @@ -294,6 +294,120 @@ class _VMInherited extends InheritedNotifier { }); final T value; + + @override + InheritedElement createElement() => _VMInheritedElement(this); +} + +/// A selector aspect: given the current value, reports whether the value this +/// dependent derived from it has changed (and therefore needs a rebuild). +typedef _SelectorAspect = bool Function(T value); + +/// Per-dependent record of the selectors registered via `context.select`. +/// [shouldClear] is flipped on by a microtask after each build so the next +/// build's first `select` call discards the previous build's stale selectors. +class _SelectDependency { + final List<_SelectorAspect> selectors = []; + bool shouldClear = false; + bool clearScheduled = false; +} + +/// Marker dependency for a `watch` dependent (notified on every trigger). +const Object _watchAll = Object(); + +/// Element for [_VMInherited]. It reproduces [InheritedNotifier]'s "rebuild +/// dependents when the trigger fires" behaviour, but adds per-dependent aspect +/// filtering so `context.select` rebuilds a dependent ONLY when its +/// selected value changes — the method-based twin of the `Selector` widget. A +/// `watch` dependent (no aspect) is still notified on every trigger. +class _VMInheritedElement extends InheritedElement { + _VMInheritedElement(_VMInherited widget) : super(widget) { + widget.notifier?.addListener(_handleUpdate); + } + + bool _dirty = false; + + _VMInherited get _widget => widget as _VMInherited; + + @override + void update(_VMInherited newWidget) { + final oldNotifier = _widget.notifier; + final newNotifier = newWidget.notifier; + if (oldNotifier != newNotifier) { + oldNotifier?.removeListener(_handleUpdate); + newNotifier?.addListener(_handleUpdate); + } + super.update(newWidget); + } + + void _handleUpdate() { + _dirty = true; + markNeedsBuild(); + } + + @override + Widget build() { + if (_dirty) notifyClients(_widget); + return super.build(); + } + + @override + void notifyClients(InheritedNotifier oldWidget) { + super.notifyClients(oldWidget); + _dirty = false; + } + + @override + void unmount() { + _widget.notifier?.removeListener(_handleUpdate); + super.unmount(); + } + + @override + void updateDependencies(Element dependent, Object? aspect) { + final current = getDependencies(dependent); + // Already subscribed to the whole value (a prior `watch`) — selectors are + // irrelevant from here on; stay subscribed to everything. + if (current != null && current is! _SelectDependency) return; + + if (aspect is _SelectorAspect) { + final dep = (current as _SelectDependency?) ?? _SelectDependency(); + if (dep.shouldClear) { + dep.shouldClear = false; + dep.selectors.clear(); + } + if (!dep.clearScheduled) { + dep.clearScheduled = true; + scheduleMicrotask(() { + dep + ..clearScheduled = false + ..shouldClear = true; + }); + } + dep.selectors.add(aspect); + setDependencies(dependent, dep); + } else { + // `watch`: no aspect — depend on every notification. + setDependencies(dependent, _watchAll); + } + } + + @override + void notifyDependent(InheritedWidget oldWidget, Element dependent) { + final dependencies = getDependencies(dependent); + if (dependencies is _SelectDependency) { + final value = _widget.value; + for (final hasChanged in dependencies.selectors) { + if (hasChanged(value)) { + dependent.didChangeDependencies(); + return; + } + } + return; + } + // `watch` (or default) — always rebuild. + dependent.didChangeDependencies(); + } } /// Wraps a page subtree: builds the page-scoped instances in a page-local @@ -384,6 +498,30 @@ extension ModularStateX on BuildContext { return inherited.value; } + /// Reactively reads a value [R] DERIVED from a page-scoped [T], rebuilding + /// only when the selected value changes (compared with `==`). It is the + /// method-based twin of the `Selector` widget — call it inside `build` to + /// scope a rebuild to exactly what a widget uses: + /// + /// ```dart + /// final name = context.select((vm) => vm.name); + /// ``` + /// + /// Mirrors `context.select` from `provider`, easing migration. Like there, + /// only call it from `build` (never in `initState`/`didChangeDependencies`). + R select(R Function(T value) selector) { + final element = getElementForInheritedWidgetOfExactType<_VMInherited>(); + if (element == null) { + throw FlutterError('context.select<$T, $R>(): no scoped $T provided.'); + } + final selected = selector((element.widget as _VMInherited).value); + dependOnInheritedElement( + element, + aspect: (T value) => selector(value) != selected, + ); + return selected; + } + /// Reads a page-scoped value of type [T] WITHOUT subscribing to rebuilds. T read() { final element = getElementForInheritedWidgetOfExactType<_VMInherited>(); diff --git a/pubspec.yaml b/pubspec.yaml index d0613265..09a39d68 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: flutter_modular description: Smart project structure with dependency injection and route management for Flutter. -version: 7.0.1 +version: 7.0.2 homepage: https://github.com/Flutterando/modular repository: https://github.com/Flutterando/modular issue_tracker: https://github.com/Flutterando/modular/issues diff --git a/test/select_test.dart b/test/select_test.dart new file mode 100644 index 00000000..595719ea --- /dev/null +++ b/test/select_test.dart @@ -0,0 +1,120 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class MultiVM extends ChangeNotifier { + int a = 0; + int b = 0; + void incA() { + a++; + notifyListeners(); + } + + void incB() { + b++; + notifyListeners(); + } +} + +int selectBuilds = 0; + +final sModule = createModule( + register: (c) { + c.route( + '/', + provide: (s) => s.addChangeNotifier(MultiVM.new), + child: (ctx, state) => Scaffold( + body: Column( + children: [ + Builder( + builder: (c) { + final a = c.select((vm) => vm.a); + selectBuilds++; + return Text('a:$a'); + }, + ), + TextButton( + onPressed: () => ctx.read().incA(), + child: const Text('incA'), + ), + TextButton( + onPressed: () => ctx.read().incB(), + child: const Text('incB'), + ), + ], + ), + ), + ); + }, +); + +Widget _boot() { + final boot = bootstrapModule(sModule); + return MaterialApp.router( + routerConfig: modularRouterConfig(boot.routes, injector: boot.injector), + ); +} + +void main() { + testWidgets('context.select rebuilds only when the selected value changes', ( + tester, + ) async { + selectBuilds = 0; + await tester.pumpWidget(_boot()); + await tester.pumpAndSettle(); + expect(find.text('a:0'), findsOneWidget); + final builds = selectBuilds; + + // Changing `b` must NOT rebuild the selecting widget (it selects `a`). + await tester.tap(find.text('incB')); + await tester.pumpAndSettle(); + expect(selectBuilds, builds); + expect(find.text('a:0'), findsOneWidget); + + // Changing `a` rebuilds it, and the new selected value is delivered. + await tester.tap(find.text('incA')); + await tester.pumpAndSettle(); + expect(selectBuilds, greaterThan(builds)); + expect(find.text('a:1'), findsOneWidget); + }); + + testWidgets('context.select keeps tracking across rebuilds', (tester) async { + selectBuilds = 0; + await tester.pumpWidget(_boot()); + await tester.pumpAndSettle(); + + // Two consecutive selected changes both land (selectors are refreshed each + // build, never accumulated or dropped). + await tester.tap(find.text('incA')); + await tester.pumpAndSettle(); + expect(find.text('a:1'), findsOneWidget); + + final builds = selectBuilds; + await tester.tap(find.text('incA')); + await tester.pumpAndSettle(); + expect(find.text('a:2'), findsOneWidget); + expect(selectBuilds, greaterThan(builds)); + + // An unrelated change still does not rebuild after several rebuilds. + final settled = selectBuilds; + await tester.tap(find.text('incB')); + await tester.pumpAndSettle(); + expect(selectBuilds, settled); + }); + + testWidgets('context.select throws when no scoped T is provided', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (c) { + c.select((vm) => vm.a); + return const SizedBox(); + }, + ), + ), + ); + expect(tester.takeException(), isA()); + }); +} From 2fb98e7a6fc7e62ed8dda7a2bf57e5ddff144d35 Mon Sep 17 00:00:00 2001 From: Jacob Moura Date: Tue, 23 Jun 2026 11:09:29 -0300 Subject: [PATCH 2/2] chore(docs-mcp): release v0.2.2 Re-embeds the state-management page documenting `context.select` and bumps the server version to 0.2.2 (pubspec + serverVersion handshake). Co-Authored-By: Claude Opus 4.8 (1M context) --- tool/docs_mcp/CHANGELOG.md | 5 +++++ tool/docs_mcp/lib/src/generated/docs_data.g.dart | 4 ++-- tool/docs_mcp/lib/src/server.dart | 2 +- tool/docs_mcp/pubspec.yaml | 2 +- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/tool/docs_mcp/CHANGELOG.md b/tool/docs_mcp/CHANGELOG.md index 432fbda5..8717dac6 100644 --- a/tool/docs_mcp/CHANGELOG.md +++ b/tool/docs_mcp/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.2.2 + +- Re-embed the state-management page documenting `context.select` — the + method-based twin of the `Selector` widget (provider-style granular rebuilds). + ## 0.2.1 - Re-embed the refreshed state-management page: documents the page-scoped diff --git a/tool/docs_mcp/lib/src/generated/docs_data.g.dart b/tool/docs_mcp/lib/src/generated/docs_data.g.dart index 856ff1c2..2ebfe08f 100644 --- a/tool/docs_mcp/lib/src/generated/docs_data.g.dart +++ b/tool/docs_mcp/lib/src/generated/docs_data.g.dart @@ -43,7 +43,7 @@ const List docPages = [ DocPage( path: "flutter_modular/state-management.md", title: "State management", - markdown: "# State management\n\nThis is the architecture Modular pushes. State is **scoped** and has a **deterministic\nlifecycle**: a view model is built when its page mounts and disposed when the page\nleaves the stack. You don't own globals and you don't write `dispose` calls — the\nframework does. The durable truth stays in a repository/service in\n[DI](./dependency-injection.md); view models are disposable projections over it.\n\n## Page‑scoped state with `provide`\n\nDeclare a route's state in its `provide` callback. Each registration becomes a factory\nbuilt in a **page‑local injector** at mount (its own dependencies resolved from the\nmodule injector), provided to the subtree, and disposed at unmount.\n\n```dart\nfinal productsModule = createModule(\n path: '/products',\n register: (c) {\n c\n ..route(\n '/',\n provide: (s) => s.addChangeNotifier(ProductListViewModel.new),\n child: (ctx, state) => const ProductListPage(),\n )\n ..route(\n '/:id',\n provide: (s) {\n s\n ..add(RealtimeConnection.new)\n ..addChangeNotifier(ProductDetailViewModel.new)\n ..addStream(_viewersStream);\n },\n child: (ctx, state) => ProductDetailPage(id: state['id']!),\n );\n },\n);\n```\n\nThe rule is **`addChangeNotifier`** (a reactive view model) and **`addStream`**\n(stream‑backed state); **`add`** registers a plain non‑reactive object. The `Scoped`\nregistrar (`s`) offers:\n\n| Method | For | Reactive? | Disposed on unmount? |\n|---|---|---|---|\n| `addChangeNotifier(ctor)` | a `ChangeNotifier` view model | ✅ via `watch`/`read` | ✅ `dispose()` |\n| `addStream(create)` | stream‑backed state | ✅ as `StreamValue` | ✅ cancels the subscription |\n| `add(ctor)` | a non‑reactive object (socket, use‑case, config) | ❌ | ✅ if it implements `Disposable` |\n\nReactivity and lifecycle are **independent**: a thing can have either, both, or neither.\nFor reactive objects that don't fit the two rules above — a **BLoC**, a **Cubit**, a\ncontroller exposing a `Listenable` — there are two escape hatches,\n[`addStreamable` and `addListenable`](#exceptions-addstreamable-and-addlistenable).\n\n### addChangeNotifier — a reactive view model\n\nThe bound type is a `ChangeNotifier` (not a bare `Listenable`) precisely so disposal is\nguaranteed. A page‑scoped VM reads the source of truth instead of holding it:\n\n```dart\n/// Page-scoped: 1:1 with the list view. Reads the repository (SSoT), doesn't own truth.\nclass ProductListViewModel extends ChangeNotifier {\n ProductListViewModel(this._repo); // repo injected from the module graph\n final ProductRepository _repo;\n\n bool loading = true;\n List products = const [];\n\n Future load() async {\n products = await _repo.getProducts();\n loading = false;\n notifyListeners();\n }\n}\n```\n\n### add — a non‑reactive resource\n\nFor something that needs lifecycle but no reactivity — a connection, a subscription\nmanager, a use‑case holding a handle. It is built as a per‑page singleton, so a view\nmodel can **inject the same instance**. If it implements `Disposable`, it is\n`dispose()`d on exit — `add` **always** checks for `Disposable`:\n\n```dart\nclass RealtimeConnection implements Disposable {\n bool isOpen = true;\n\n @override\n void dispose() {\n isOpen = false; // closed when the detail page leaves the stack\n }\n}\n```\n\n```dart\nclass ProductDetailViewModel extends ChangeNotifier {\n // The page's RealtimeConnection (same instance) injected alongside the repo.\n ProductDetailViewModel(this._repo, this._connection);\n final ProductRepository _repo;\n final RealtimeConnection _connection;\n bool get connected => _connection.isOpen;\n}\n```\n\n### addStream — stream‑backed state\n\n`addStream` exposes the latest value of a stream as a `StreamValue`:\n\n```dart\nStream _viewersStream() =>\n Stream.periodic(const Duration(seconds: 2), (i) => 40 + i);\n\n// ...provide: (s) => s.addStream(_viewersStream)...\n\n// In the page:\nfinal viewers = context.watch>().value; // latest int, or null\n```\n\n## Exceptions: addStreamable and addListenable\n\n`addChangeNotifier` and `addStream` cover the common cases. When an object's reactivity\nlives on a **property** — its `stream`, or a `Listenable` it exposes — and you want to\nexpose the **object itself** (to read its synchronous state and call its methods), reach\nfor these two escape hatches. Each takes a factory, a selector for the reactive source,\nand a (required) dispose callback:\n\n- `addStreamable(ctor, (t) => t.stream, (t) => t.close())` — reactivity is a `Stream`.\n `context.watch()` returns the object; rebuilds fire on each emission.\n- `addListenable(ctor, (t) => t.someListenable, (t) => t.dispose())` — reactivity is a\n `Listenable` property.\n\n```dart\n// A controller that is NOT a ChangeNotifier but exposes one:\nclass SearchController {\n final ValueNotifier query = ValueNotifier('');\n void dispose() => query.dispose();\n}\n\nprovide: (s) => s.addListenable(\n SearchController.new,\n (c) => c.query, // the rebuild trigger\n (c) => c.dispose(), // cleanup on unmount\n);\n```\n\n:::note\nPrefer `addChangeNotifier`/`addStream`. Use `addStreamable`/`addListenable` only when the\nreactive source is a property of the object you want to expose.\n:::\n\n## BLoC and Cubit\n\nA **BLoC** or **Cubit** is exactly the streamable case: it exposes a synchronous `state`,\na `stream` of changes, and an async `close()`. Register it with `addStreamable` —\n`context.watch()` returns the **BLoC/Cubit** itself, so you read `state` directly and\nrebuilds are driven by its stream:\n\n```dart\n// CounterCubit is a Cubit from the `bloc` package.\nroute(\n '/counter',\n provide: (s) => s.addStreamable(\n CounterCubit.new,\n (c) => c.stream,\n (c) => c.close(),\n ),\n child: (ctx, state) {\n final counter = ctx.watch(); // the Cubit itself\n return Text('\${counter.state}'); // read its synchronous state\n },\n);\n```\n\nflutter_modular has **no dependency on the `bloc` package** — `addStreamable` takes the\n`stream` and `close` as callbacks. To make this a one‑liner, add a small extension on\n`Scoped` in your app. Because both **BLoC** and **Cubit** extend `BlocBase` (which has\n`.stream` and `.close()`), a single `addBloc` covers both:\n\n```dart\nimport 'package:bloc/bloc.dart';\nimport 'package:flutter_modular/flutter_modular.dart';\n\n/// Registers a page-scoped BLoC or Cubit: reactive via its stream, closed on unmount.\nextension BlocScoped on Scoped {\n void addBloc>(B Function() create) =>\n addStreamable(create, (b) => b.stream, (b) => b.close());\n}\n```\n\n```dart\n// Now registering any BLoC or Cubit is one line:\nprovide: (s) => s.addBloc(CounterCubit.new),\n```\n\n:::tip\nWith the extension above, `addBloc(MyBloc.new)` works for both **BLoC** and\n**Cubit** — one line to get a page‑scoped, auto‑closed, reactive instance.\n:::\n\n## Reading state: `watch` and `read`\n\nFrom any descendant of the page, reach a provided `Listenable`:\n\n```dart\nfinal vm = context.watch(); // rebuilds this widget on notify\ncontext.read().load(); // reads without subscribing (callbacks)\n```\n\n- `context.watch()` subscribes — the widget rebuilds when `T` notifies.\n- `context.read()` does **not** subscribe — use it in callbacks (`onPressed`) and\n one‑shot calls.\n\n## Granular rebuilds: `Consumer` and `Selector`\n\n`watch` rebuilds the whole widget that called it. To scope a rebuild to a sub‑tree, use\n`Consumer`; to rebuild only when a *derived value* changes, use `Selector`:\n\n```dart\n// Rebuilds only this builder when the VM notifies:\nConsumer(\n builder: (context, cart, child) => Text('\${cart.items.length} items'),\n);\n\n// Rebuilds only when the selected value changes:\nSelector(\n selector: (context, cart) => cart.items.length,\n builder: (context, count, child) => Badge(label: Text('\$count')),\n);\n```\n\n## App‑scoped state {#app-scoped-state}\n\nPage‑scoped state lives *below* the `Navigator`, so it cannot rebuild the `MaterialApp`\nitself. For app‑global state that drives **theme, locale, or session**, declare it on\n`ModularApp.provide` — the very same `Scoped` mechanism, only anchored **above** the\n`MaterialApp`:\n\n```dart\nvoid main() {\n runApp(\n ModularApp(\n module: appModule,\n provide: (s) => s.addChangeNotifier(ThemeController.new),\n child: const AppRoot(),\n ),\n );\n}\n\nclass AppRoot extends StatelessWidget {\n const AppRoot({super.key});\n\n @override\n Widget build(BuildContext context) {\n final theme = context.watch(); // above the MaterialApp\n return MaterialApp.router(\n themeMode: theme.mode,\n theme: ThemeData.light(useMaterial3: true),\n darkTheme: ThemeData.dark(useMaterial3: true),\n routerConfig: ModularApp.routerConfigOf(context),\n );\n }\n}\n```\n\n```dart\nclass ThemeController extends ChangeNotifier {\n ThemeMode mode = ThemeMode.light;\n void toggle() {\n mode = mode == ThemeMode.light ? ThemeMode.dark : ThemeMode.light;\n notifyListeners();\n }\n}\n```\n\nBecause it is anchored above the `Navigator`, a page deep in the tree can still reach it\nwith `context.read().toggle()` — and the whole app re‑themes.\n\n## Disposable {#disposable}\n\n`Disposable` is the interface that opts a non‑reactive class into page‑scoped lifecycle:\n\n```dart\nabstract interface class Disposable {\n void dispose();\n}\n```\n\nImplement it and register with `add` (page‑scoped) — Modular builds it in the\npage‑local injector and calls `dispose()` on unmount. Feature‑module binds that\nimplement `Disposable` (or `ChangeNotifier`) are likewise disposed when the feature\nleaves the stack; see [DI lifecycle](./dependency-injection.md#bind-lifecycle).\n\n## Why this is the architecture\n\n- **One home for the truth.** Repositories/services are root‑owned singletons; a view\n model reads them and never becomes a competing source of truth.\n- **No floating state.** A VM exists exactly as long as its page; leaving disposes it.\n- **State management gets lighter.** Lifecycle and provision are the framework's job, so\n whatever reactivity you choose has less to carry.", + markdown: "# State management\n\nThis is the architecture Modular pushes. State is **scoped** and has a **deterministic\nlifecycle**: a view model is built when its page mounts and disposed when the page\nleaves the stack. You don't own globals and you don't write `dispose` calls — the\nframework does. The durable truth stays in a repository/service in\n[DI](./dependency-injection.md); view models are disposable projections over it.\n\n## Page‑scoped state with `provide`\n\nDeclare a route's state in its `provide` callback. Each registration becomes a factory\nbuilt in a **page‑local injector** at mount (its own dependencies resolved from the\nmodule injector), provided to the subtree, and disposed at unmount.\n\n```dart\nfinal productsModule = createModule(\n path: '/products',\n register: (c) {\n c\n ..route(\n '/',\n provide: (s) => s.addChangeNotifier(ProductListViewModel.new),\n child: (ctx, state) => const ProductListPage(),\n )\n ..route(\n '/:id',\n provide: (s) {\n s\n ..add(RealtimeConnection.new)\n ..addChangeNotifier(ProductDetailViewModel.new)\n ..addStream(_viewersStream);\n },\n child: (ctx, state) => ProductDetailPage(id: state['id']!),\n );\n },\n);\n```\n\nThe rule is **`addChangeNotifier`** (a reactive view model) and **`addStream`**\n(stream‑backed state); **`add`** registers a plain non‑reactive object. The `Scoped`\nregistrar (`s`) offers:\n\n| Method | For | Reactive? | Disposed on unmount? |\n|---|---|---|---|\n| `addChangeNotifier(ctor)` | a `ChangeNotifier` view model | ✅ via `watch`/`read` | ✅ `dispose()` |\n| `addStream(create)` | stream‑backed state | ✅ as `StreamValue` | ✅ cancels the subscription |\n| `add(ctor)` | a non‑reactive object (socket, use‑case, config) | ❌ | ✅ if it implements `Disposable` |\n\nReactivity and lifecycle are **independent**: a thing can have either, both, or neither.\nFor reactive objects that don't fit the two rules above — a **BLoC**, a **Cubit**, a\ncontroller exposing a `Listenable` — there are two escape hatches,\n[`addStreamable` and `addListenable`](#exceptions-addstreamable-and-addlistenable).\n\n### addChangeNotifier — a reactive view model\n\nThe bound type is a `ChangeNotifier` (not a bare `Listenable`) precisely so disposal is\nguaranteed. A page‑scoped VM reads the source of truth instead of holding it:\n\n```dart\n/// Page-scoped: 1:1 with the list view. Reads the repository (SSoT), doesn't own truth.\nclass ProductListViewModel extends ChangeNotifier {\n ProductListViewModel(this._repo); // repo injected from the module graph\n final ProductRepository _repo;\n\n bool loading = true;\n List products = const [];\n\n Future load() async {\n products = await _repo.getProducts();\n loading = false;\n notifyListeners();\n }\n}\n```\n\n### add — a non‑reactive resource\n\nFor something that needs lifecycle but no reactivity — a connection, a subscription\nmanager, a use‑case holding a handle. It is built as a per‑page singleton, so a view\nmodel can **inject the same instance**. If it implements `Disposable`, it is\n`dispose()`d on exit — `add` **always** checks for `Disposable`:\n\n```dart\nclass RealtimeConnection implements Disposable {\n bool isOpen = true;\n\n @override\n void dispose() {\n isOpen = false; // closed when the detail page leaves the stack\n }\n}\n```\n\n```dart\nclass ProductDetailViewModel extends ChangeNotifier {\n // The page's RealtimeConnection (same instance) injected alongside the repo.\n ProductDetailViewModel(this._repo, this._connection);\n final ProductRepository _repo;\n final RealtimeConnection _connection;\n bool get connected => _connection.isOpen;\n}\n```\n\n### addStream — stream‑backed state\n\n`addStream` exposes the latest value of a stream as a `StreamValue`:\n\n```dart\nStream _viewersStream() =>\n Stream.periodic(const Duration(seconds: 2), (i) => 40 + i);\n\n// ...provide: (s) => s.addStream(_viewersStream)...\n\n// In the page:\nfinal viewers = context.watch>().value; // latest int, or null\n```\n\n## Exceptions: addStreamable and addListenable\n\n`addChangeNotifier` and `addStream` cover the common cases. When an object's reactivity\nlives on a **property** — its `stream`, or a `Listenable` it exposes — and you want to\nexpose the **object itself** (to read its synchronous state and call its methods), reach\nfor these two escape hatches. Each takes a factory, a selector for the reactive source,\nand a (required) dispose callback:\n\n- `addStreamable(ctor, (t) => t.stream, (t) => t.close())` — reactivity is a `Stream`.\n `context.watch()` returns the object; rebuilds fire on each emission.\n- `addListenable(ctor, (t) => t.someListenable, (t) => t.dispose())` — reactivity is a\n `Listenable` property.\n\n```dart\n// A controller that is NOT a ChangeNotifier but exposes one:\nclass SearchController {\n final ValueNotifier query = ValueNotifier('');\n void dispose() => query.dispose();\n}\n\nprovide: (s) => s.addListenable(\n SearchController.new,\n (c) => c.query, // the rebuild trigger\n (c) => c.dispose(), // cleanup on unmount\n);\n```\n\n:::note\nPrefer `addChangeNotifier`/`addStream`. Use `addStreamable`/`addListenable` only when the\nreactive source is a property of the object you want to expose.\n:::\n\n## BLoC and Cubit\n\nA **BLoC** or **Cubit** is exactly the streamable case: it exposes a synchronous `state`,\na `stream` of changes, and an async `close()`. Register it with `addStreamable` —\n`context.watch()` returns the **BLoC/Cubit** itself, so you read `state` directly and\nrebuilds are driven by its stream:\n\n```dart\n// CounterCubit is a Cubit from the `bloc` package.\nroute(\n '/counter',\n provide: (s) => s.addStreamable(\n CounterCubit.new,\n (c) => c.stream,\n (c) => c.close(),\n ),\n child: (ctx, state) {\n final counter = ctx.watch(); // the Cubit itself\n return Text('\${counter.state}'); // read its synchronous state\n },\n);\n```\n\nflutter_modular has **no dependency on the `bloc` package** — `addStreamable` takes the\n`stream` and `close` as callbacks. To make this a one‑liner, add a small extension on\n`Scoped` in your app. Because both **BLoC** and **Cubit** extend `BlocBase` (which has\n`.stream` and `.close()`), a single `addBloc` covers both:\n\n```dart\nimport 'package:bloc/bloc.dart';\nimport 'package:flutter_modular/flutter_modular.dart';\n\n/// Registers a page-scoped BLoC or Cubit: reactive via its stream, closed on unmount.\nextension BlocScoped on Scoped {\n void addBloc>(B Function() create) =>\n addStreamable(create, (b) => b.stream, (b) => b.close());\n}\n```\n\n```dart\n// Now registering any BLoC or Cubit is one line:\nprovide: (s) => s.addBloc(CounterCubit.new),\n```\n\n:::tip\nWith the extension above, `addBloc(MyBloc.new)` works for both **BLoC** and\n**Cubit** — one line to get a page‑scoped, auto‑closed, reactive instance.\n:::\n\n## Reading state: `watch` and `read`\n\nFrom any descendant of the page, reach a provided `Listenable`:\n\n```dart\nfinal vm = context.watch(); // rebuilds this widget on notify\ncontext.read().load(); // reads without subscribing (callbacks)\n```\n\n- `context.watch()` subscribes — the widget rebuilds when `T` notifies.\n- `context.read()` does **not** subscribe — use it in callbacks (`onPressed`) and\n one‑shot calls.\n\n## Granular rebuilds: `Consumer` and `Selector`\n\n`watch` rebuilds the whole widget that called it. To scope a rebuild to a sub‑tree, use\n`Consumer`; to rebuild only when a *derived value* changes, use `Selector`:\n\n```dart\n// Rebuilds only this builder when the VM notifies:\nConsumer(\n builder: (context, cart, child) => Text('\${cart.items.length} items'),\n);\n\n// Rebuilds only when the selected value changes:\nSelector(\n selector: (context, cart) => cart.items.length,\n builder: (context, count, child) => Badge(label: Text('\$count')),\n);\n```\n\n`context.select` is the **method‑based twin** of `Selector` — call it inside\n`build` to subscribe to a derived value; the widget rebuilds only when that value\nchanges (compared with `==`). It mirrors `context.select` from **provider**, so\nmigrating is a near drop‑in:\n\n```dart\n// Inside build(): rebuilds only when `cart.items.length` changes.\nfinal count = context.select((cart) => cart.items.length);\nreturn Badge(label: Text('\$count'));\n```\n\n:::note\nLike in provider, only call `context.select` from `build` — never in `initState` or\n`didChangeDependencies`.\n:::\n\n## App‑scoped state {#app-scoped-state}\n\nPage‑scoped state lives *below* the `Navigator`, so it cannot rebuild the `MaterialApp`\nitself. For app‑global state that drives **theme, locale, or session**, declare it on\n`ModularApp.provide` — the very same `Scoped` mechanism, only anchored **above** the\n`MaterialApp`:\n\n```dart\nvoid main() {\n runApp(\n ModularApp(\n module: appModule,\n provide: (s) => s.addChangeNotifier(ThemeController.new),\n child: const AppRoot(),\n ),\n );\n}\n\nclass AppRoot extends StatelessWidget {\n const AppRoot({super.key});\n\n @override\n Widget build(BuildContext context) {\n final theme = context.watch(); // above the MaterialApp\n return MaterialApp.router(\n themeMode: theme.mode,\n theme: ThemeData.light(useMaterial3: true),\n darkTheme: ThemeData.dark(useMaterial3: true),\n routerConfig: ModularApp.routerConfigOf(context),\n );\n }\n}\n```\n\n```dart\nclass ThemeController extends ChangeNotifier {\n ThemeMode mode = ThemeMode.light;\n void toggle() {\n mode = mode == ThemeMode.light ? ThemeMode.dark : ThemeMode.light;\n notifyListeners();\n }\n}\n```\n\nBecause it is anchored above the `Navigator`, a page deep in the tree can still reach it\nwith `context.read().toggle()` — and the whole app re‑themes.\n\n## Disposable {#disposable}\n\n`Disposable` is the interface that opts a non‑reactive class into page‑scoped lifecycle:\n\n```dart\nabstract interface class Disposable {\n void dispose();\n}\n```\n\nImplement it and register with `add` (page‑scoped) — Modular builds it in the\npage‑local injector and calls `dispose()` on unmount. Feature‑module binds that\nimplement `Disposable` (or `ChangeNotifier`) are likewise disposed when the feature\nleaves the stack; see [DI lifecycle](./dependency-injection.md#bind-lifecycle).\n\n## Why this is the architecture\n\n- **One home for the truth.** Repositories/services are root‑owned singletons; a view\n model reads them and never becomes a competing source of truth.\n- **No floating state.** A VM exists exactly as long as its page; leaving disposes it.\n- **State management gets lighter.** Lifecycle and provision are the framework's job, so\n whatever reactivity you choose has less to carry.", ), DocPage( path: "flutter_modular/testing.md", @@ -462,7 +462,7 @@ const List docChunks = [ pageTitle: "State management", heading: "Granular rebuilds: `Consumer` and `Selector`", anchor: "granular-rebuilds-consumer-and-selector", - text: "## Granular rebuilds: `Consumer` and `Selector`\n\n`watch` rebuilds the whole widget that called it. To scope a rebuild to a sub‑tree, use\n`Consumer`; to rebuild only when a *derived value* changes, use `Selector`:\n\n```dart\n// Rebuilds only this builder when the VM notifies:\nConsumer(\n builder: (context, cart, child) => Text('\${cart.items.length} items'),\n);\n\n// Rebuilds only when the selected value changes:\nSelector(\n selector: (context, cart) => cart.items.length,\n builder: (context, count, child) => Badge(label: Text('\$count')),\n);\n```", + text: "## Granular rebuilds: `Consumer` and `Selector`\n\n`watch` rebuilds the whole widget that called it. To scope a rebuild to a sub‑tree, use\n`Consumer`; to rebuild only when a *derived value* changes, use `Selector`:\n\n```dart\n// Rebuilds only this builder when the VM notifies:\nConsumer(\n builder: (context, cart, child) => Text('\${cart.items.length} items'),\n);\n\n// Rebuilds only when the selected value changes:\nSelector(\n selector: (context, cart) => cart.items.length,\n builder: (context, count, child) => Badge(label: Text('\$count')),\n);\n```\n\n`context.select` is the **method‑based twin** of `Selector` — call it inside\n`build` to subscribe to a derived value; the widget rebuilds only when that value\nchanges (compared with `==`). It mirrors `context.select` from **provider**, so\nmigrating is a near drop‑in:\n\n```dart\n// Inside build(): rebuilds only when `cart.items.length` changes.\nfinal count = context.select((cart) => cart.items.length);\nreturn Badge(label: Text('\$count'));\n```\n\n:::note\nLike in provider, only call `context.select` from `build` — never in `initState` or\n`didChangeDependencies`.\n:::", ), DocChunk( pagePath: "flutter_modular/state-management.md", diff --git a/tool/docs_mcp/lib/src/server.dart b/tool/docs_mcp/lib/src/server.dart index 349ea95a..deac6584 100644 --- a/tool/docs_mcp/lib/src/server.dart +++ b/tool/docs_mcp/lib/src/server.dart @@ -14,7 +14,7 @@ import 'generated/docs_data.g.dart'; import 'search_index.dart'; /// Version reported to clients in the MCP `initialize` handshake. -const String serverVersion = '0.2.1'; +const String serverVersion = '0.2.2'; /// URI scheme/prefix under which each doc page is exposed as a resource. const String _uriPrefix = 'modular-docs:///'; diff --git a/tool/docs_mcp/pubspec.yaml b/tool/docs_mcp/pubspec.yaml index abbff712..52d48e40 100644 --- a/tool/docs_mcp/pubspec.yaml +++ b/tool/docs_mcp/pubspec.yaml @@ -2,7 +2,7 @@ name: flutter_modular_docs_mcp description: >- MCP server that serves the flutter_modular documentation to AI coding clients as searchable resources plus a keyword search tool. -version: 0.2.1 +version: 0.2.2 repository: https://github.com/Flutterando/modular homepage: https://modular.flutterando.com.br topics: