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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ All notable changes to this project will be documented in this file.

### Added

- **`MagicSelector<C, T>` rebuilds one subtree when one part of a controller changes.** `refreshUI()` notifies every listener and `MagicStatefulViewState` answers with `setState` on the whole view, which is the right default and 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 selector caches 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. Returning an identical instance rather than skipping a `setState` is what makes it work under a parent that rebuilds anyway. Two rules follow: `builder` must be a pure function of the selected value (select a record to watch several fields), and equality is plain `==`, so a selector returning a freshly built `List` never matches its own cache. Deep comparison is deliberately not used, because walking a ten thousand element list per keystroke costs more than the rebuild it prevents. (`lib/src/ui/magic_selector.dart`)

- **`MagicPaginator.isRefreshing` and `.isLoadingMore`, because a list has three loading states and one flag cannot carry them.** A first load shows a skeleton, a refresh keeps the rows the reader is already looking at, and a next page puts a footer under the last row. Read off `isLoading` alone the second and third are indistinguishable, so a screen either blanks itself on every filter change or grows a footer promising a page nothing asked for. Both are false on a first load (nothing on screen to preserve, nothing being appended) and all three are false once the request lands. The distinction only exists DURING a request, which is why `_isReset` is set beside `_isLoading` and before the notification rather than derived afterwards: by the time a caller can await the future there is nothing left to tell apart. One window is documented rather than changed: a `refresh()` deferred behind an in-flight `loadMore()` keeps reporting `isLoadingMore` until that page lands, which is what is happening on the wire and the only path where the flags follow the request rather than the caller's most recent ask. (`lib/src/http/magic_paginator.dart`)

- **`MagicPaginator.total`, read from `meta.total`.** The size of the collection rather than of the pages in hand: `items.length` answers "how much have I fetched", and a header reading "11 of 240" needs the other number, which a consumer previously had to fetch a second time or parse out of a response this class had already parsed. Null on a cursor collection, because Laravel's `cursorPaginate()` deliberately does not count and a total invented from the loaded page would be wrong rather than approximate. Read with `containsKey` before the mode branches, so a page that says nothing about the count leaves the last known value alone: an endpoint sending the total on page one only would otherwise have it erased by page two. Cleared on a reset, since a reset is usually a different question and the previous count describes a collection that no longer exists. (`lib/src/http/magic_paginator.dart`)
Expand Down
84 changes: 84 additions & 0 deletions doc/basics/ui-helpers.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Magic provides context-free UI feedback utilities, reactive widget builders, dec
- [Toast Messages](#toast-messages)
- [Configuration](#configuration)
- [MagicBuilder](#magic-builder)
- [MagicSelector](#magic-selector)
- [MagicTitle](#magic-title)
- [MagicResponsiveView](#magic-responsive-view)
- [Extended Breakpoints](#extended-breakpoints)
Expand Down Expand Up @@ -350,6 +351,89 @@ class MonitorShowView extends MagicStatefulView<MonitorController> {
> [!TIP]
> For E2E drivability, prefer `MagicBuilder` over `setState` on the parent widget. Targeted subtree rebuilds keep interactive element identity stable so dusk agents do not lose their references mid-action.

<a name="magic-selector"></a>
## MagicSelector

`MagicSelector<C, T>` rebuilds one subtree when one part of a controller changes, and leaves it alone the rest of the time. Reach for it when `MagicBuilder` cannot help, which is whenever the thing you want to watch is a plain field on a `MagicController` rather than a `ValueListenable`.

```dart
MagicSelector<GuideController, String>(
controller: controller,
selector: (GuideController c) => c.countLabel,
builder: (String label) => Text(label),
)
```

### What it is for

`refreshUI()` notifies every listener, and `MagicStatefulViewState` answers by calling `setState` on the whole view. 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. A search field is the worked example. Every keystroke is a notification, and one keystroke on a real screen was measured rebuilding 220 styled containers, almost none of which could have looked different.

### How it avoids the rebuild

It caches the widget the builder returned and, while the selected value compares equal, returns that same **instance**. `Element.updateChild` short circuits when the new widget is `==` to the mounted one, so an identical instance ends the descent there and the subtree is never visited.

That is what makes it work under a parent that rebuilds anyway. A widget that merely skipped its own `setState` would still be rebuilt from above, which is the situation inside every `MagicStatefulView`.

### The contract

`builder` must be a pure function of the value it is handed. A cached child cannot see anything else the closure captured:

```dart
// WRONG: `total` is captured and nothing here watches it, so the line reads
// a stale total for as long as `count` happens not to move.
MagicSelector<C, int>(
controller: c,
selector: (C c) => c.count,
builder: (int count) => Text('$count of $total'),
)
```

Select both instead. A Dart record has value equality, so it compares by content and the cache still holds:

```dart
MagicSelector<C, (int, int)>(
controller: c,
selector: (C c) => (c.count, c.total),
builder: ((int, int) v) => Text('${v.$1} of ${v.$2}'),
)
```

Reading an `InheritedWidget` inside the cached subtree needs no selection. `Theme.of`, `MediaQuery.of` and `WindTheme.of` register their own dependency, and the framework rebuilds a dependent element directly rather than through its parent.

The word doing the work there is **inside**. A lookup written in the enclosing `build` and captured by the closure is the captured-`total` hole wearing different clothes, and a dark-mode toggle is a likelier way to meet it:

```dart
// WRONG: `context` belongs to the view's build, so a theme change rebuilds the
// view, the cache is served, and this subtree keeps the old theme.
MagicSelector<C, int>(
controller: c,
selector: (C c) => c.count,
builder: (int n) => WDiv(className: WindTheme.of(context).surface),
)

// Right: the lookup happens inside the built subtree, which registers its own
// dependency.
MagicSelector<C, int>(
controller: c,
selector: (C c) => c.count,
builder: (int n) => Builder(
builder: (BuildContext inner) =>
WDiv(className: WindTheme.of(inner).surface),
),
)
```

### Equality

Plain `==`, deliberately. A selector that returns a freshly built `List` or `Map` never matches its own cache, because Dart gives collections identity equality, and the subtree then rebuilds on every notification exactly as it would have without the widget.

Deep comparison was the alternative and is worse where it matters: walking a ten thousand element list on every keystroke costs more than the rebuild it prevents. Select a scalar, a record, or an object whose identity is stable across notifications.

> [!NOTE]
> `MagicSelector` does not replace `MagicBuilder`. Use `MagicBuilder` when the source already is a `ValueListenable`, such as `MagicFormData.processingListenable`; use `MagicSelector` when the source is the controller itself.

<a name="magic-title"></a>
## MagicTitle

Expand Down
1 change: 1 addition & 0 deletions lib/magic.dart
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export 'src/encryption/encryption_service_provider.dart';

// UI
export 'src/ui/magic_builder.dart';
export 'src/ui/magic_selector.dart';
export 'src/ui/magic_feedback.dart';
export 'src/ui/magic_view_registry.dart';
export 'src/ui/magic_view.dart';
Expand Down
207 changes: 207 additions & 0 deletions lib/src/ui/magic_selector.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import 'package:flutter/widgets.dart';

import '../http/magic_controller.dart';

/// Rebuilds one subtree when one part of a controller changes, and leaves it
/// alone the rest of the time.
///
/// [MagicController.refreshUI] notifies every listener, and
/// `MagicStatefulViewState` answers by calling `setState` on the whole view.
/// 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. A search field is the worked example: every keystroke is a
/// notification, and a consumer measured one keystroke rebuilding 220 styled
/// containers, almost none of which could have looked different.
///
/// ```dart
/// MagicSelector<GuideController, String>(
/// controller: controller,
/// selector: (GuideController c) => c.countLabel,
/// builder: (String label) => WText(label),
/// )
/// ```
///
/// ## How it avoids the rebuild
///
/// It caches the widget the builder returned and, while the selected value
/// compares equal, returns that same INSTANCE. `Element.updateChild` short
/// circuits when the new widget is `==` to the mounted one, so an identical
/// instance ends the descent right there and the subtree is never visited.
/// That is what makes this work under a parent that rebuilds anyway: a widget
/// that merely skipped its own `setState` would still be rebuilt from above.
///
/// ## The contract this buys
///
/// [builder] must be a pure function of the value it is handed. A cached child
/// cannot see anything else the closure captured, so this is stale for as long
/// as `count` happens not to move:
///
/// ```dart
/// // WRONG: `total` is captured, and nothing here watches it.
/// MagicSelector<C, int>(
/// controller: c,
/// selector: (C c) => c.count,
/// builder: (int count) => WText('$count of $total'),
/// )
/// ```
///
/// Select both instead. A Dart record has value equality, so it compares by
/// content and the cache still holds:
///
/// ```dart
/// MagicSelector<C, (int, int)>(
/// controller: c,
/// selector: (C c) => (c.count, c.total),
/// builder: ((int, int) v) => WText('${v.$1} of ${v.$2}'),
/// )
/// ```
///
/// Reading an [InheritedWidget] INSIDE the cached subtree is fine and needs no
/// selection: `Theme.of`, `MediaQuery.of` and `WindTheme.of` register their own
/// dependency, and the framework rebuilds a dependent element directly rather
/// than through its parent.
///
/// A lookup captured from the ENCLOSING build is the same hole as `total`
/// above, and a dark-mode toggle is a likelier way to meet it:
///
/// ```dart
/// // WRONG: `context` is the view's, so a theme change rebuilds the view, the
/// // cache is served, and this subtree keeps the old theme.
/// builder: (int n) => WDiv(className: WindTheme.of(context).surface),
///
/// // Right: the lookup runs inside the built subtree.
/// builder: (int n) => Builder(
/// builder: (BuildContext inner) =>
/// WDiv(className: WindTheme.of(inner).surface),
/// ),
/// ```
///
/// ## Equality
///
/// Plain `==`, deliberately. A selector that returns a freshly built `List` or
/// `Map` therefore never matches its own cache, because Dart gives collections
/// identity equality, and the subtree rebuilds every notification exactly as it
/// would have without this widget. Deep comparison was the alternative and is
/// worse where it matters: walking a ten thousand channel list on every
/// keystroke costs more than the rebuild it prevents. Select a scalar, a
/// record, or an object whose identity is stable across notifications.
///
/// See also:
///
/// * [MagicBuilder], for a plain [ValueListenable] with no selection step.
class MagicSelector<C extends MagicController, T> extends StatefulWidget {
/// The controller to watch.
final C controller;

/// Reads the one piece of [controller] this subtree depends on.
///
/// Called on every notification, so keep it cheap: a field read or a
/// memoized getter, never a scan that the controller has not already cached.
final T Function(C controller) selector;

/// Builds the subtree from the selected value, and from nothing else.
///
/// Takes no [BuildContext] for the same reason [MagicBuilder] does not: the
/// value is the whole input. Wrap the result in a [Builder] if a descendant
/// needs a context of its own.
final Widget Function(T value) builder;

/// Creates a [MagicSelector].
const MagicSelector({
super.key,
required this.controller,
required this.selector,
required this.builder,
});

@override
State<MagicSelector<C, T>> createState() => _MagicSelectorState<C, T>();
}

class _MagicSelectorState<C extends MagicController, T>
extends State<MagicSelector<C, T>> {
late T _value;

/// The widget [MagicSelector.builder] last returned.
///
/// Returning this instance again is the entire mechanism; see the class doc.
Widget? _child;

@override
void initState() {
super.initState();
_value = widget.selector(widget.controller);
widget.controller.addListener(_onNotified);
}

@override
void didUpdateWidget(covariant MagicSelector<C, T> oldWidget) {
super.didUpdateWidget(oldWidget);

if (!identical(oldWidget.controller, widget.controller)) {
oldWidget.controller.removeListener(_onNotified);
widget.controller.addListener(_onNotified);
_value = widget.selector(widget.controller);
_child = null;
}
}

// A changed `selector` or `builder` deliberately does NOT invalidate the
// cache, and that is the decision the whole widget rests on. Both are written
// inline in a parent's `build`, so both are a fresh closure on every parent
// rebuild and comparing them by identity would drop the cache every time,
// which is the case this exists to serve. 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 thing this cannot see, which is why the class doc makes purity a
// contract rather than a suggestion.

@override
void reassemble() {
super.reassemble();

// Hot reload marks descendants dirty, so an edit INSIDE the cached subtree
// shows up on its own. An edit to the builder does 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. Dropping the cache is
// free here, because reassemble only runs in debug.
_child = null;
}

@override
void dispose() {
// `removeListener` during a notification is safe: `ChangeNotifier`
// tombstones the slot and compacts the list once the outer call finishes.
widget.controller.removeListener(_onNotified);
super.dispose();
}

void _onNotified() {
if (!mounted) return;

final T next = widget.selector(widget.controller);
if (next == _value) return;

setState(() {
_value = next;
_child = null;
});
}

@override
Widget build(BuildContext context) {
// Re-read here as well as in the listener. A parent can rebuild this widget
// without any notification having fired (a `setState` higher up), and the
// cached child would then outlive the value it was built from. That covers
// a stale VALUE only; a hot-reloaded BUILDER is `reassemble`'s job.
final T next = widget.selector(widget.controller);
if (next != _value) {
_value = next;
_child = null;
}

return _child ??= widget.builder(_value);
}
}
Loading
Loading