# Select and structural sharing

> Five readers of one cache entry, each selecting something different, with build counters that show which changes reach which reader.

One list of todos in the cache, read by five widgets at once: four of them
use `select` to take one piece of it, a count, the first text, a done/open
record, the list of texts, and the fifth reads the whole list. Each reader
counts its builds, and `data builds` counts only the builds where its
selected value changed. A refetch that brings back equal data, or a write
that changes one field, then shows which readers it reaches. This is the
pattern for a screen where several widgets derive from one response: a
device list whose header shows a count of online devices, a badge with the
number of unread items, a summary row over an order's lines. Each widget
selects what it shows and is left alone when something else changes.

Live demo: [Select and structural sharing](https://dualmeta-gmbh.github.io/query_kit/demo/showcase/#/select-and-sharing), running in the browser against an in-memory backend ([source](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/select_and_sharing)). What a reader rebuilds on, and what it does not.

## What to try

- Press *Refetch*. The `todos` debug strip shows `fetches=2`, but no
  reader's `data builds` moves: structural sharing kept the old list, so
  every selection is equal to the last one. The reader with `buildWhen`
  (*QuerySelectBuilder + buildWhen*) does not move its `builds` either; the
  others count the fetching and idle notifications in `builds`.
- Press *Toggle todo 1*. Only the done/open record and the reader without
  `select` get a new `data builds`; the count, the first text and the list
  of texts are unchanged.
- Press *Rename todo 2*. Now only the list of texts and the reader without
  `select` move.
- Switch on *Structural sharing off* and press *Refetch*. The list in the
  cache is a new instance each time, so the reader without `select` moves,
  and so does the list of texts, a new list on every run of its selector.
  The count, the first text and the record are compared by value and stay
  put.

## The code

The selectors are top-level functions, so the same function object is
passed on every build.

[`examples/showcase/lib/features/select_and_sharing/select_and_sharing_screen.dart`, lines 85–95](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/select_and_sharing/select_and_sharing_screen.dart#L85-L95):

```dart
int countTodos(List<Todo> todos) => todos.length;

String firstText(List<Todo> todos) => todos.isEmpty ? '' : todos.first.text;

({int done, int open}) doneAndOpen(List<Todo> todos) => (
      done: todos.where((todo) => todo.done).length,
      open: todos.where((todo) => !todo.done).length,
    );

List<String> todoTexts(List<Todo> todos) =>
    <String>[for (final todo in todos) todo.text];
```

Every reader shares one query; a `select` makes it a
`QuerySelectOptions<List<Todo>, T>`. Leaving `structuralSharing` null keeps
the default sharing, and `keepNext`, the screen's name for
`noStructuralSharing()`, switches it off for the cache write and for what
`select` produces.

[`examples/showcase/lib/features/select_and_sharing/select_and_sharing_screen.dart`, lines 108–118](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/select_and_sharing/select_and_sharing_screen.dart#L108-L118):

```dart
QuerySelectOptions<List<Todo>, T> todosQuery<T>(
  ShowcaseApi api, {
  required T Function(List<Todo> todos) select,
  bool sharing = true,
}) =>
    QuerySelectOptions<List<Todo>, T>(
      queryKey: ShowcaseKeys.todos,
      queryFn: (context) => api.todos(signal: context.signal),
      select: select,
      structuralSharing: sharing ? null : keepNext,
    );
```

The first reader is `context.selectQuery`, which returns a result whose
data is the selected `int`.

[`examples/showcase/lib/features/select_and_sharing/select_and_sharing_screen.dart`, lines 380–387](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/select_and_sharing/select_and_sharing_screen.dart#L380-L387):

```dart
final result = context.selectQuery<List<Todo>, int>(
  todosQuery(
    ShowcaseScope.apiOf(context),
    select: countTodos,
    sharing: widget.sharing,
  ),
);
_counter.record(result.dataOrNull);
```

The second is a `QuerySelectBuilder` whose `buildWhen` lets a rebuild
through only when the selected data changed.

[`examples/showcase/lib/features/select_and_sharing/select_and_sharing_screen.dart`, lines 414–432](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/select_and_sharing/select_and_sharing_screen.dart#L414-L432):

```dart
Widget build(BuildContext context) => QuerySelectBuilder<List<Todo>, String>(
      options: todosQuery(
        ShowcaseScope.apiOf(context),
        select: firstText,
        sharing: widget.sharing,
      ),
      buildWhen: (previous, next) => previous.dataOrNull != next.dataOrNull,
      builder: (context, result) {
        _counter.record(result.dataOrNull);
        return _ReaderRow(
          id: 'builder',
          style: 'QuerySelectBuilder + buildWhen',
          selection: 'String: the first text',
          counter: _counter,
          result: result,
          facts: () => <String>['first=${result.dataOrNull}'],
        );
      },
    );
```

<details>
<summary>The whole screen</summary>

[`examples/showcase/lib/features/select_and_sharing/select_and_sharing_screen.dart`](https://github.com/dualmeta-gmbh/query_kit/blob/d69b05dc391bd0585e2e53600572b6440025a7e9/examples/showcase/lib/features/select_and_sharing/select_and_sharing_screen.dart):

```dart
/// Port-specific: what a reader rebuilds on, and what it does not.
///
/// One cache entry — the todos — read by five readers at once: one per call
/// style, each with a different `select`, plus a control without one. Every
/// reader counts its own builds. `builds` is every call of its build;
/// `data builds` only the calls whose selected value differed from the one
/// built before (`!=`, so identity for a list — which is exactly what
/// structural sharing is about). Upstream's guide is
/// `docs/framework/react/guides/render-optimizations.md`; the binding's rules
/// are the README's "What rebuilds, and when".
///
/// What the counters show, read from the binding's source
/// (`query_context.dart`, `query_mixin.dart`, `query_builder.dart`):
///
/// - `context.selectQuery` and `watchSelectQuery` rebuild whenever the result
///   differs from the one last built, and a `QueryResult` carries
///   `fetchStatus` and `dataUpdatedAt`: a refetch that brings back equal data
///   is still two rebuilds — fetching, then idle with a newer
///   `dataUpdatedAt`. `select` decides what the *data* comparison sees, so
///   `data builds` is the honest measure of "did my selection change", and it
///   is what the proofs assert on for those readers.
/// - `QuerySelectBuilder` with `buildWhen: previous.dataOrNull !=
///   next.dataOrNull` skips both of those rebuilds, so its `builds` counter
///   is the one that stands still. It is the only reader *on this screen*
///   that passes a predicate, not the only one that could: all four builders
///   and all eight keyless reads take the same `buildWhen`, and the
///   `build-when` screen is where each of the eight is shown doing it.
/// - `ListenableBuilder` over a `QueryController` rebuilds on every
///   notification, with no equality guard at all: the same two rebuilds per
///   refetch as the context and mixin readers, and a first load of two
///   builds like everyone's — the fetch its subscription starts is already
///   in the first result it builds from.
/// - `structuralSharing` governs the cache write *and* what `select`
///   produces, as upstream's `replaceData` does (see `StructuralSharing` in
///   the core). With the switch off, a
///   selection is reported exactly as the selector built it, so a reader
///   moves when its own selected value is not `==` to the last one: the
///   controller's list of texts is a new instance every time and moves, while
///   the `int`, the `String` and the record do not. The fifth reader, the one
///   without `select`, is the control: with sharing off
///   the list in the cache is a new instance on every refetch, and upstream
///   hands an observer without `select` the cache's data as it is — and so
///   does this library: an observer without a `select` passes the cached
///   data through untouched, so with sharing off the fifth reader moves on
///   every refetch.
///
/// Proofs (widget tests in `test/features/select_and_sharing_test.dart`,
/// end-to-end in `e2e/tests/select_and_sharing.spec.ts`): every reader shows
/// its selection after one `GET /api/todos`; a refetch with equal data moves
/// no reader's `data builds` and not the `buildWhen` reader's `builds`, while
/// the strip's `fetches` becomes 2; toggling todo 1 moves only the done/open
/// record (and the control); renaming todo 2 moves only the list of texts
/// (and the control); with sharing off, a refetch with equal data provably
/// reaches the cache write (an equal list, but a new instance, where sharing
/// on kept the old one) and moves the one `select` reader whose selection is
/// a new instance — the controller's list of texts — while the three whose
/// selections are `==` to the last stand still and the guard-less readers'
/// `builds` climb; and the control's `data builds` climbs with sharing off
/// and stands still once it is back on.
library;

import 'package:flutter/material.dart';
import 'package:query_kit_flutter/query_kit_flutter.dart';

import '../../shared/api.dart';
import '../../shared/chrome.dart';
import '../../shared/debug_strip.dart';
import '../../shared/fact_group.dart';
import '../../shared/feature.dart';
import '../../shared/feature_scaffold.dart';
import '../../shared/models.dart';
import '../../shared/scope.dart';

const Feature selectAndSharingFeature = Feature(
  id: 'select-and-sharing',
  title: 'Select and structural sharing',
  summary: 'What a reader rebuilds on, and what it does not.',
);

// The selectors are top-level functions, not closures built in `build`: a
// tear-off of one is the same object every build, which is what lets the
// observer skip re-running it while the data has not changed — upstream's
// "extract it to a stable function reference".

int countTodos(List<Todo> todos) => todos.length;

String firstText(List<Todo> todos) => todos.isEmpty ? '' : todos.first.text;

({int done, int open}) doneAndOpen(List<Todo> todos) => (
      done: todos.where((todo) => todo.done).length,
      open: todos.where((todo) => !todo.done).length,
    );

List<String> todoTexts(List<Todo> todos) =>
    <String>[for (final todo in todos) todo.text];

/// The opt-out: what arrives is written as it is, never reconciled with what
/// the cache held — upstream's `structuralSharing: false`. The core's
/// `noStructuralSharing()` rather than a `(_, next) => next` of the screen's
/// own: a hook of one's own governs the cache write only, and only the
/// recognised opt-out turns sharing off for what `select` produces too.
final StructuralSharing<List<Todo>> keepNext = noStructuralSharing();

/// The one query every reader shares. They differ only in what they select
/// and in whether the cache write shares structure. A select is its own
/// options shape (`QuerySelectOptions`), so the raw reader has
/// [rawTodosQuery].
QuerySelectOptions<List<Todo>, T> todosQuery<T>(
  ShowcaseApi api, {
  required T Function(List<Todo> todos) select,
  bool sharing = true,
}) =>
    QuerySelectOptions<List<Todo>, T>(
      queryKey: ShowcaseKeys.todos,
      queryFn: (context) => api.todos(signal: context.signal),
      select: select,
      structuralSharing: sharing ? null : keepNext,
    );

/// [todosQuery] without a select: the same key, the same fetch, the list as
/// the cache holds it.
QueryObserverOptions<List<Todo>> rawTodosQuery(
  ShowcaseApi api, {
  bool sharing = true,
}) =>
    QueryObserverOptions<List<Todo>>(
      queryKey: ShowcaseKeys.todos,
      queryFn: (context) => api.todos(signal: context.signal),
      structuralSharing: sharing ? null : keepNext,
    );

typedef _TodoPatch = ({int id, String? text, bool? done});

class SelectAndSharingScreen extends StatefulWidget {
  const SelectAndSharingScreen({super.key});

  @override
  State<SelectAndSharingScreen> createState() => _SelectAndSharingScreenState();
}

class _SelectAndSharingScreenState extends State<SelectAndSharingScreen> {
  bool _sharingOff = false;

  @override
  Widget build(BuildContext context) {
    final sharing = !_sharingOff;
    return FeatureScaffold(
      feature: selectAndSharingFeature,
      children: <Widget>[
        SectionCard(
          title: 'One cache entry: the todos',
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: <Widget>[
              const _Actions(),
              SwitchListTile(
                contentPadding: EdgeInsets.zero,
                title: const Text('Structural sharing off'),
                value: _sharingOff,
                onChanged: (value) => setState(() => _sharingOff = value),
              ),
            ],
          ),
        ),
        // Above the readers, not below: the end-to-end suite reads the
        // semantics tree, and a lazily built list only has the rows in view.
        QueryDebugStrip(queryKey: ShowcaseKeys.todos, label: 'todos'),
        SectionCard(
          title: 'Five readers, one entry',
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: <Widget>[
              _ContextReader(sharing: sharing),
              _BuilderReader(sharing: sharing),
              _MixinReader(sharing: sharing),
              _ControllerReader(sharing: sharing),
              _RawReader(sharing: sharing),
            ],
          ),
        ),
      ],
    );
  }
}

/// The buttons, and the mutation behind two of them. A widget of its own
/// because a mutation rebuilds the widget that reads it on every state
/// change, and that widget must not be the readers' parent.
class _Actions extends StatefulWidget {
  const _Actions();

  @override
  State<_Actions> createState() => _ActionsState();
}

class _ActionsState extends State<_Actions> {
  int _renames = 0;

  @override
  Widget build(BuildContext context) {
    final api = ShowcaseScope.apiOf(context);
    final client = QueryClientProvider.of(context);
    final update = context.mutation(
      MutationOptions.simple<Todo, _TodoPatch>(
        mutationFn: (patch) =>
            api.updateTodo(patch.id, text: patch.text, done: patch.done),
        onSuccess: (_, __, ___) => client.invalidateQueries(
          filters: QueryFilters(queryKey: ShowcaseKeys.todos),
        ),
      ),
    );
    final busy = update.value.isPending;

    return Wrap(
      spacing: 8,
      runSpacing: 8,
      children: <Widget>[
        _ActionButton(
          'Refetch',
          enabled: !busy,
          onPressed: () => client
              .refetchQueries(
                filters: QueryFilters(queryKey: ShowcaseKeys.todos),
              )
              .ignore(),
        ),
        _ActionButton(
          'Toggle todo 1',
          enabled: !busy,
          onPressed: () {
            // Read, not watched: the button needs the current `done` once,
            // at the tap, and must not become a reader of its own.
            final todo = client
                .getQueryData<List<Todo>>(ShowcaseKeys.todos)
                ?.where((todo) => todo.id == 1)
                .firstOrNull;
            if (todo != null) {
              update.mutate((id: 1, text: null, done: !todo.done));
            }
          },
        ),
        _ActionButton(
          'Rename todo 2',
          enabled: !busy,
          onPressed: () {
            _renames += 1;
            update
                .mutate((id: 2, text: 'Renamed todo 2 x$_renames', done: null));
          },
        ),
      ],
    );
  }
}

class _ActionButton extends StatelessWidget {
  const _ActionButton(
    this.label, {
    required this.onPressed,
    this.enabled = true,
  });

  final String label;
  final VoidCallback onPressed;
  final bool enabled;

  // The visible label is the button's accessible name; the tooltip is for
  // hovering only, so it stays out of the semantics tree rather than
  // doubling the text.
  @override
  Widget build(BuildContext context) => Tooltip(
        message: label,
        excludeFromSemantics: true,
        child: FilledButton.tonal(
          onPressed: enabled ? onPressed : null,
          child: Text(label),
        ),
      );
}

/// An honest build count. [builds] is every build; [dataBuilds] only the
/// ones whose selected value differed from the one built before — `!=`, so
/// value equality for a scalar or a record and identity for a list.
class _Counter {
  int builds = 0;
  int dataBuilds = 0;
  Object? _lastData;

  void record(Object? data) {
    builds += 1;
    if (data != _lastData) {
      dataBuilds += 1;
      _lastData = data;
    }
  }
}

/// One reader's row: its style, what it selects, the selected value as exact
/// `key=value` texts, and its two counters. A semantics group named
/// `reader <id>`, so a browser-driving test can scope to one reader the way
/// it scopes to a debug strip.
class _ReaderRow extends StatelessWidget {
  const _ReaderRow({
    required this.id,
    required this.style,
    required this.selection,
    required this.counter,
    required this.result,
    required this.facts,
  });

  final String id;
  final String style;
  final String selection;
  final _Counter counter;
  final QueryResult<Object?> result;

  /// The selected value as exact texts, given the data.
  final List<String> Function() facts;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final values = switch (result) {
      QueryPending() => const <String>['pending'],
      QueryError(:final error, staleData: null) => <String>['error=$error'],
      QuerySuccess() || QueryError() => facts(),
    };
    return SemanticsGroup(
      name: 'reader $id',
      child: Padding(
        padding: const EdgeInsets.symmetric(vertical: 6),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            Row(
              children: <Widget>[
                Expanded(
                  child: Text(style, style: theme.textTheme.labelLarge),
                ),
                Text(selection, style: theme.textTheme.bodySmall),
              ],
            ),
            const SizedBox(height: 2),
            Wrap(
              spacing: 12,
              runSpacing: 2,
              crossAxisAlignment: WrapCrossAlignment.center,
              children: <Widget>[
                FactList(values, dense: true),
                Pill('builds=${counter.builds}'),
                Pill(
                  'data builds=${counter.dataBuilds}',
                  color: theme.colorScheme.tertiary,
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

/// Reader 1: `context.selectQuery`, selecting the count.
class _ContextReader extends StatefulWidget {
  const _ContextReader({required this.sharing});

  final bool sharing;

  @override
  State<_ContextReader> createState() => _ContextReaderState();
}

class _ContextReaderState extends State<_ContextReader> {
  final _Counter _counter = _Counter();

  @override
  Widget build(BuildContext context) {
    final result = context.selectQuery<List<Todo>, int>(
      todosQuery(
        ShowcaseScope.apiOf(context),
        select: countTodos,
        sharing: widget.sharing,
      ),
    );
    _counter.record(result.dataOrNull);
    return _ReaderRow(
      id: 'context',
      style: 'context.selectQuery',
      selection: 'int: the count',
      counter: _counter,
      result: result,
      facts: () => <String>['count=${result.dataOrNull}'],
    );
  }
}

/// Reader 2: `QuerySelectBuilder`, selecting the first todo's text, with a
/// `buildWhen` that ignores everything but the data.
class _BuilderReader extends StatefulWidget {
  const _BuilderReader({required this.sharing});

  final bool sharing;

  @override
  State<_BuilderReader> createState() => _BuilderReaderState();
}

class _BuilderReaderState extends State<_BuilderReader> {
  final _Counter _counter = _Counter();

  @override
  Widget build(BuildContext context) => QuerySelectBuilder<List<Todo>, String>(
        options: todosQuery(
          ShowcaseScope.apiOf(context),
          select: firstText,
          sharing: widget.sharing,
        ),
        buildWhen: (previous, next) => previous.dataOrNull != next.dataOrNull,
        builder: (context, result) {
          _counter.record(result.dataOrNull);
          return _ReaderRow(
            id: 'builder',
            style: 'QuerySelectBuilder + buildWhen',
            selection: 'String: the first text',
            counter: _counter,
            result: result,
            facts: () => <String>['first=${result.dataOrNull}'],
          );
        },
      );
}

/// Reader 3: `QueryMixin.watchSelectQuery`, selecting a done/open record.
class _MixinReader extends StatefulWidget {
  const _MixinReader({required this.sharing});

  final bool sharing;

  @override
  State<_MixinReader> createState() => _MixinReaderState();
}

class _MixinReaderState extends State<_MixinReader> with QueryMixin {
  final _Counter _counter = _Counter();

  @override
  Widget build(BuildContext context) {
    final result = watchSelectQuery<List<Todo>, ({int done, int open})>(
      todosQuery(
        ShowcaseScope.apiOf(context),
        select: doneAndOpen,
        sharing: widget.sharing,
      ),
    );
    _counter.record(result.dataOrNull);
    return _ReaderRow(
      id: 'mixin',
      style: 'QueryMixin.watchSelectQuery',
      selection: 'record: done and open',
      counter: _counter,
      result: result,
      facts: () {
        final data = result.dataOrNull!;
        return <String>['done=${data.done}', 'open=${data.open}'];
      },
    );
  }
}

/// Reader 4: a `QueryController` selecting the list of texts, read through
/// a `ListenableBuilder`.
class _ControllerReader extends StatefulWidget {
  const _ControllerReader({required this.sharing});

  final bool sharing;

  @override
  State<_ControllerReader> createState() => _ControllerReaderState();
}

class _ControllerReaderState extends State<_ControllerReader> {
  final _Counter _counter = _Counter();
  QueryController<List<Todo>, List<String>>? _controller;

  QuerySelectOptions<List<Todo>, List<String>> get _options => todosQuery(
        ShowcaseScope.apiOf(context),
        select: todoTexts,
        sharing: widget.sharing,
      );

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    // The controller is created here, not in `initState`: it needs the
    // provider's client, which is an inherited widget.
    final client = QueryClientProvider.of(context);
    if (_controller?.client != client) {
      _controller?.dispose();
      _controller = QueryController<List<Todo>, List<String>>(client, _options);
    }
  }

  @override
  void didUpdateWidget(_ControllerReader oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (oldWidget.sharing != widget.sharing) {
      _controller!.setOptions(_options);
    }
  }

  @override
  void dispose() {
    _controller?.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final controller = _controller!;
    return ListenableBuilder(
      listenable: controller,
      builder: (context, _) {
        final result = controller.value;
        _counter.record(result.dataOrNull);
        return _ReaderRow(
          id: 'controller',
          style: 'QueryController + ListenableBuilder',
          selection: 'List<String>: the texts',
          counter: _counter,
          result: result,
          facts: () => result.dataOrNull!,
        );
      },
    );
  }
}

/// Reader 5, the control: no `select`, the cache's own list. The only reader
/// whose selected value is the thing `structuralSharing` decides about.
class _RawReader extends StatefulWidget {
  const _RawReader({required this.sharing});

  final bool sharing;

  @override
  State<_RawReader> createState() => _RawReaderState();
}

class _RawReaderState extends State<_RawReader> {
  final _Counter _counter = _Counter();

  @override
  Widget build(BuildContext context) => QueryBuilder<List<Todo>>(
        options: rawTodosQuery(
          ShowcaseScope.apiOf(context),
          sharing: widget.sharing,
        ),
        builder: (context, result) {
          _counter.record(result.dataOrNull);
          return _ReaderRow(
            id: 'raw',
            style: 'QueryBuilder, no select',
            selection: "List<Todo>: the cache's own list",
            counter: _counter,
            result: result,
            facts: () => <String>['length=${result.dataOrNull!.length}'],
          );
        },
      );
}
```

</details>

## Related

- Guides: [Structural sharing](https://dualmeta-gmbh.github.io/query_kit/docs/guides/structural-sharing.md), [What rebuilds, and when](https://dualmeta-gmbh.github.io/query_kit/docs/guides/render-optimizations.md), [Four ways to read a query](https://dualmeta-gmbh.github.io/query_kit/docs/guides/reading-queries-in-widgets.md)
- Tested by `test/features/select_and_sharing_test.dart` (widget) and `e2e/tests/select_and_sharing.spec.ts` (browser)
- [View the feature on GitHub](https://github.com/dualmeta-gmbh/query_kit/tree/main/examples/showcase/lib/features/select_and_sharing)
