Skip to content

feat(ui): add MagicSelector, which scopes a rebuild to one controller field - #149

Merged
anilcancakir merged 6 commits into
masterfrom
feat/magic-selector
Sep 8, 2026
Merged

feat(ui): add MagicSelector, which scopes a rebuild to one controller field#149
anilcancakir merged 6 commits into
masterfrom
feat/magic-selector

Conversation

@anilcancakir

Copy link
Copy Markdown
Contributor

The problem

refreshUI() notifies every listener, and MagicStatefulViewState._onControllerChanged answers by calling setState(() {}) on the whole view (lib/src/ui/magic_view.dart:177). That is the right default: a controller does not know which of its fields a screen reads, and a view that rebuilds is always correct.

It stops being cheap on a screen where one field changes often and most of the screen does not care. Measured in a consumer with Flutter's own build profiling, at a realistic data scale: one keystroke in a search field rebuilt 220 styled containers, and the seven-keystroke session was the most expensive interaction on the screen by a wide margin (p90 207 ms against 77 to 80 ms for every scroll session in the same run).

MagicBuilder could not help. It takes a ValueListenable<T> (lib/src/ui/magic_builder.dart:115), and a MagicController is a ChangeNotifier. The only workaround available today is a hand-maintained ValueNotifier per field, which means two notification mechanisms in one controller and a second thing to keep in sync with refreshUI().

The mechanism, which is the cache rather than the listener

This is the part worth reviewing carefully, because the obvious implementation does not work.

A BlocSelector-shaped widget gates its own setState on an equality check. That is enough when notifications arrive through an InheritedWidget, and useless here: the parent view rebuilds unconditionally from above, so the child is rebuilt whether or not it wanted to be, and its own gate is never consulted.

MagicSelector caches the widget its builder returned and, while the selected value compares equal, returns that same instance. Element.updateChild short circuits on hasSameSuperclass && child.widget == newWidget (packages/flutter/lib/src/widgets/framework.dart:4027 on 3.47.0), and an identical instance satisfies that, so the descent ends there and the subtree is never visited.

Two decisions that will look wrong at first

A changed selector or builder does not invalidate the cache. Both are written inline in a parent's build, so both are a fresh closure on every parent rebuild; comparing them by identity drops the cache on exactly the rebuild this widget exists to survive. I wrote it the other way first and two tests failed for precisely that reason. A changed selector still takes effect the moment it returns a different value, because build re-reads it. A changed builder that would render differently from the same value is the one case this cannot see, which is why the class doc makes purity a contract rather than a suggestion.

Equality is plain ==, not a deep comparison. provider's Selector defaults to DeepCollectionEquality; flutter_bloc's BlocSelector uses !=. This follows bloc. Walking a ten thousand element list on every keystroke costs more than the rebuild it prevents, and the consumer that motivated this has exactly that list. The consequence, that a selector returning a freshly built List never matches its own cache, is pinned in a test and documented rather than hidden.

Tests

11 tests in test/ui/magic_selector_test.dart, written before the implementation. The first group is the headline case: a probe inside the selector and a probe outside it, under a real MagicStatefulView, so the sibling's rising count proves the full-view rebuild happened while the scoped count stays at 1 across seven unrelated notifications.

The rest cover standing alone with no view above it, a notification that does not move the value, swapping the controller instance (with listener counts on both sides), a changed selector, detaching on unmount, a record selecting two fields, and the documented List behaviour.

ProfileController counts its own listeners rather than reading ChangeNotifier.hasListeners, which is @protected and produces an invalid_use_of_protected_member warning outside a subclass instance member.

Gates

dart analyze clean, dart format . no diff, 1438 tests green (no new skips), lib/src/ui/magic_selector.dart at LF:30 LH:30, dart pub publish --dry-run unchanged from master.

Post-change sync: CHANGELOG.md (Unreleased / Added), doc/basics/ui-helpers.md (a section beside MagicBuilder, plus its ToC entry), skills/magic-framework/SKILL.md (rule 6 now says which builder fits which source, version 0.1.12 to 0.1.13), skills/magic-framework/references/controllers-views.md (full section plus ToC).

example/ is deliberately untouched. It is regenerated per release by magic:install (CLAUDE.md), so a hand-written demo there is a liability, and none of the existing UI helpers (MagicBuilder, MagicTitle, MagicCan) appear in it either. The usage examples live in the doc.

Found while writing the tests, not fixed here

import 'package:magic/magic.dart' shadows dart:ui's TextDirection enum with package:intl's TextDirection class, because lib/magic.dart:7 blanket-exports intl. So Directionality(textDirection: TextDirection.ltr) does not compile in any consumer file that imports the barrel, and the error ("Member not found: 'ltr'") does not name the cause. The test file works around it with hide TextDirection and a comment. The fix has a precedent five lines above the offending export: file_picker is already exported by name for the same class of collision. Separate PR.

… field

refreshUI() notifies every listener and MagicStatefulViewState answers with
setState on the whole view. That is the right default, and it stops being cheap
on a screen where one field changes often and most of the screen does not care:
a consumer measured one keystroke in a search field rebuilding 220 styled
containers. MagicBuilder could not help, because it needs a ValueListenable and
a controller is a ChangeNotifier.

The mechanism is the cache, not the listener. MagicSelector keeps the widget its
builder returned and, while the selected value compares equal, returns that same
INSTANCE, so Element.updateChild short circuits on child.widget == newWidget and
never descends. A widget that merely skipped its own setState would still be
rebuilt from above, which is the situation inside every MagicStatefulView.

A changed selector or builder deliberately does not invalidate the cache. Both
are written inline in a parent's build, so both are a fresh closure every time
and comparing them by identity would drop the cache on exactly the rebuild this
exists to survive. A changed selector still takes effect the moment it returns a
different value; a changed builder that would render differently from the same
value is what the purity contract in the class doc rules out.

Equality is plain ==. A selector returning a freshly built List never matches
its own cache, which is pinned in a test rather than fixed: deep comparison of a
ten thousand element list on every notification costs more than the rebuild it
prevents.
Covers what it is for, why returning an identical instance is the mechanism,
the purity contract the caching forces, and why equality is plain == rather
than a deep comparison. Ends on when to reach for which: MagicBuilder when the
source already is a ValueListenable, MagicSelector when it is the controller.
Bumps the skill to 0.1.13. Rule 6 now names which of the two section builders
fits which source, because the distinction is the thing an agent gets wrong:
MagicBuilder needs a ValueListenable and a controller is not one.
@codecov

codecov Bot commented Sep 8, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@kodizm

kodizm Bot commented Sep 8, 2026

Copy link
Copy Markdown

Note

Kodizm (AI-generated). May contain mistakes; verify before acting.

The widget is correct and the caching mechanism it rests on holds up against the framework source; what I found is three small sync/DX items, none of them blocking.

I verified the central claim rather than taking it on trust. Element.updateChild does short circuit on hasSameSuperclass && child.widget == newWidget (framework.dart:4027 on the 3.47.1 in this checkout), and hasSameSuperclass is only narrowed inside an assert, so the short circuit holds in release too. The two decisions the description flags as looking wrong are both right: comparing selector/builder by identity would drop the cache on exactly the parent rebuild this exists to survive, and build re-reading the selector closes the staleness that leaves behind. dispose calling removeListener on a possibly-disposed controller is safe by design (change_notifier.dart:339-344 documents it explicitly).

Minor

lib/src/ui/magic_selector.dart:174 — the cache survives hot reload, so an edit to the widget the builder itself returns is invisible until the selected value happens to move. Element.reassemble (framework.dart:3767) marks descendants dirty recursively, so edits inside the cached subtree do appear, but the cached instance is what those descendants rebuild against: change builder: (n) => Text('$n items') to Text('$n rows'), hot reload, and the old string stays. A void reassemble() => _child = null; override fixes it in one line. The comment at build naming hot reload as a handled case is what made me check - it handles the value going stale on reload, not the builder. (DX)

skills/magic-framework/SKILL.md:8 — frontmatter moved to version: 0.1.13, but the stamp comment on line 8 still reads <!-- magic 0.0.9 | Skill v0.1.12 (2026-09-06). ... -->. The two version markers now disagree in the file that ships downstream to the registry.

CHANGELOG.md:7 — the new entry opens a second ### Added under ## [Unreleased]; the pre-existing one is still there below ### BREAKING. publish.yml builds the GitHub release body from this section, so it would ship with two Added headings. Folding the entry into the existing block, or moving it below ### BREAKING, matches how every released section here is laid out.

One doc line worth tightening

doc/basics/ui-helpers.md and the class doc both say reading an InheritedWidget "inside the cached subtree" needs no selection, which is precisely true. The adjacent sentence naming Theme.of, MediaQuery.of and WindTheme.of invites the reading that goes stale: builder: (v) => WDiv(className: WindTheme.of(context).x) written in a view's build captures the enclosing context, so a theme change rebuilds the parent, the cache is served, and the subtree keeps the old theme. Same class as the documented captured-total hole, but a dark-mode toggle is a likelier way to meet it. Worth naming the captured-context case explicitly.

Tests

11 new tests in test/ui/magic_selector_test.dart cover the headline scoped-vs-sibling case under a real MagicStatefulView, standing alone, an unmoved value, the controller swap with listener counts on both sides, a changed selector, unmount detach, a record, and the documented List behaviour. The two contract holes that follow from the caching decision - a changed builder that would render differently from the same value, and the hot-reload case above - are documented but not pinned by a test. The listener-count workaround in ProfileController is the right call; hasListeners genuinely is @protected.

Checks I ran

  • flutter test test/ui/magic_selector_test.dart - 11/11 passed.
  • flutter test test/ui - 77/77 passed, no skips.
  • dart format --set-exit-if-changed on both changed Dart files - "Formatted 2 files (0 changed)".
  • flutter analyze on the two files - the analysis server was OOM-killed (exit -9) in this container, so I did not verify the zero-issues gate; CI covers it.
  • Read the full patch for all 7 files; nothing was listed as changed_without_diff or dropped_for_size. I did not run the full 1438-test suite or dart pub publish --dry-run.

Hot reload marks descendants dirty, so an edit INSIDE the cached subtree showed
up on its own. An edit to the builder did not: the cached instance is what those
descendants rebuild against, so changing builder: (n) => Text('$n items') to
Text('$n rows') kept showing items until the selected value happened to move.
reassemble only runs in debug, so dropping the cache there is free.

Two tests, one per contract hole review named. The second pins the hole rather
than closing it: a changed builder is not seen while the value holds, which is
what the purity contract exists to rule out, written down so the next reader
meets it as a decision.
Both docs said reading an InheritedWidget inside the cached subtree needs no
selection, which is true and invites the reading that goes stale: a
WindTheme.of(context) written in the enclosing build captures the view's
context, so a theme change rebuilds the view, the cache is served, and the
subtree keeps the old theme. Same class as the captured-total hole, and a
dark-mode toggle is a likelier way to meet it.

The stamp comment in SKILL.md still read v0.1.12 while the frontmatter had moved
to 0.1.13, so the file shipping downstream disagreed with itself. And the
changelog entry opened a second ### Added under [Unreleased]; publish.yml builds
the release body from that section, so it would have shipped with two.
@kodizm

kodizm Bot commented Sep 8, 2026

Copy link
Copy Markdown

Note

Kodizm (AI-generated). May contain mistakes; verify before acting.

The three commits since my last review resolve every item I raised, and each fix is the right shape; nothing new to flag.

What changed since 6647bd5: reassemble() now nulls _child, the SKILL.md stamp comment moved to v0.1.13 (2026-09-08), the duplicate ### Added under ## [Unreleased] was folded into the existing block below ### BREAKING, and both the class doc and doc/basics/ui-helpers.md now name the captured-context case with a Builder as the fix.

The reassemble override is placed correctly and its comment draws the right line against the build comment above it, which was edited in the same commit to stop claiming hot reload as a value-staleness case. reassembleApplication marks this element dirty, so build runs with _child == null and the builder re-executes - the new test pins exactly that, and it is the one hole from my last review that could be pinned. The other, a changed builder under an unmoved value, is now a test that documents the cost rather than pretending to fix it, which is the honest way to write it.

Tests

13 tests now (up from 11). The two additions cover the hot-reload cache drop and the changed-builder contract, both under the new the caching contract, pinned rather than fixed group.

Checks I ran

  • flutter test test/ui/magic_selector_test.dart - 13/13 passed, no skips.
  • dart format --set-exit-if-changed on both changed Dart files - "Formatted 2 files (0 changed)".
  • grep on CHANGELOG.md headings - ## [Unreleased] now has one ### BREAKING (line 7) and one ### Added (line 17), so the release body renders with a single Added block.
  • Read the full patch for all 5 files; nothing was listed as changed_without_diff or dropped_for_size. I did not re-run flutter analyze (the analysis server was OOM-killed in this container last time) or the full suite.

@anilcancakir
anilcancakir merged commit 8a2b545 into master Sep 8, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant