Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## 7.0.2

- **`context.select<T, R>(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<T>(ctor,
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,9 @@ c.route('/counter',

// inside the page:
final vm = context.watch<CounterViewModel>(); // rebuilds when the VM notifies

// granular: rebuild only when the selected value changes (provider-style)
final count = context.select<CounterViewModel, int>((vm) => vm.count);
```

<!-- CONTRIBUTING -->
Expand Down
16 changes: 16 additions & 0 deletions doc/docs/flutter_modular/state-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,22 @@ Selector<CartViewModel, int>(
);
```

`context.select<T, R>` 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<CartViewModel, int>((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`
Expand Down
2 changes: 1 addition & 1 deletion lib/flutter_modular.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
138 changes: 138 additions & 0 deletions lib/src/state/scoped.dart
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,120 @@ class _VMInherited<T extends Object> extends InheritedNotifier<Listenable> {
});

final T value;

@override
InheritedElement createElement() => _VMInheritedElement<T>(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<T> = bool Function(T value);

/// Per-dependent record of the selectors registered via `context.select<T>`.
/// [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<T> {
final List<_SelectorAspect<T>> 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<T, R>` 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<T extends Object> extends InheritedElement {
_VMInheritedElement(_VMInherited<T> widget) : super(widget) {
widget.notifier?.addListener(_handleUpdate);
}

bool _dirty = false;

_VMInherited<T> get _widget => widget as _VMInherited<T>;

@override
void update(_VMInherited<T> 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<Listenable> 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<T>) return;

if (aspect is _SelectorAspect<T>) {
final dep = (current as _SelectDependency<T>?) ?? _SelectDependency<T>();
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<T>) {
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
Expand Down Expand Up @@ -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<UserVM, String>((vm) => vm.name);
/// ```
///
/// Mirrors `context.select` from `provider`, easing migration. Like there,
/// only call it from `build` (never in `initState`/`didChangeDependencies`).
R select<T extends Object, R>(R Function(T value) selector) {
final element = getElementForInheritedWidgetOfExactType<_VMInherited<T>>();
if (element == null) {
throw FlutterError('context.select<$T, $R>(): no scoped $T provided.');
}
final selected = selector((element.widget as _VMInherited<T>).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<T extends Object>() {
final element = getElementForInheritedWidgetOfExactType<_VMInherited<T>>();
Expand Down
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
120 changes: 120 additions & 0 deletions test/select_test.dart
Original file line number Diff line number Diff line change
@@ -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>(MultiVM.new),
child: (ctx, state) => Scaffold(
body: Column(
children: [
Builder(
builder: (c) {
final a = c.select<MultiVM, int>((vm) => vm.a);
selectBuilds++;
return Text('a:$a');
},
),
TextButton(
onPressed: () => ctx.read<MultiVM>().incA(),
child: const Text('incA'),
),
TextButton(
onPressed: () => ctx.read<MultiVM>().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<MultiVM, int>((vm) => vm.a);
return const SizedBox();
},
),
),
);
expect(tester.takeException(), isA<FlutterError>());
});
}
5 changes: 5 additions & 0 deletions tool/docs_mcp/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## 0.2.2

- Re-embed the state-management page documenting `context.select<T, R>` — 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
Expand Down
Loading
Loading