mobxjs / mobx.dart

MobX for the Dart language. Hassle-free, reactive state-management for your Dart and Flutter apps.
https://mobx.netlify.app
MIT License
2.39k stars 310 forks source link

Computeds not working when Observer in Observer #963

Closed subzero911 closed 8 months ago

subzero911 commented 9 months ago

I encountered the strange behaviour when the Observer is not rebuilding. When I have the following structrure: Observer->Column->TaskListView->Column->Button (where TaskListView is a StatelessWidget) image When showCollapseButton and endReached are changing, UI rebuilds successfully.

But if I wrap inner TextButton in additional Observer, for some reason it doesn't rebuild properly. Observer->Column->TaskListView->Column->Observer->Button image It's always "show more" button which never turns to "collapse".

Computeds:

image

I don't know exactly how to create a minimal reproducible example. I found this behaviour in our project.

subzero911 commented 9 months ago

I'm still struggling with this behaviour.

If I wrap the whole TaskListView into Observer, it's working image

If I remove .toList(), then no errors are logged, but the button stops working (but in theory it should log “no observables found”, as we do nothing on todaysTodoTasksWindow and pass it to the child parameter - it should be out of immediate context).

If I wrap the root of TaskListView widget into Observer, it won't work: image


It either glitches when I derive a computed from computed (endReached derived from todaysTodoTasksSource which is derived from _todaysTasks), or when I put an Observer into Observer. But I tested it on a simple "counter" app and can't find the bug.

amondnet commented 9 months ago

@subzero911 Can you give me the code to reproduce it?

subzero911 commented 9 months ago

I can't share the whole project as it's under NDA, but I can share some files which are related to error:

tasks_screen.dart:
```dart import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:get_it/get_it.dart'; import 'package:mobx/mobx.dart'; import '../../../i18n/strings.g.dart'; import '../controllers/task_controller.dart'; import '../widgets/task_list/no_tasks_placeholder.dart'; import '../widgets/task_list/task_list_loader.dart'; import '../widgets/task_list/task_list_view.dart'; import '../widgets/task_list/tasks_done_placeholder.dart'; import '../widgets/tasks_error_state.dart'; /// экран задач class TasksScreen extends StatefulWidget { @override State createState() => _TasksScreenState(); } class _TasksScreenState extends State { late final TaskController taskController; @override void initState() { super.initState(); taskController = GetIt.I(); } @override Widget build(BuildContext context) { final t = Translations.of(context); return Scaffold( body: SafeArea( child: RefreshIndicator( onRefresh: () async { await taskController.fetchTodaysTasks(); await taskController.fetchTaskStats(); }, child: SingleChildScrollView( physics: AlwaysScrollableScrollPhysics(), child: Column( children: [ Observer( builder: (context) { // если ошибка одновременно при загрузке заданий на сегодня и стат по заданиям - показ ошибки на весь экран if (taskController.fetchTodaysTasksFuture.status == FutureStatus.rejected && taskController.fetchTaskStatsByStatusFuture.status == FutureStatus.rejected) { return TasksErrorState( errorText: t.tasks.task_list.errors.task_loading_failed, onRetry: () { unawaited(taskController.fetchTodaysTasks()); unawaited(taskController.fetchTaskStats()); }, ); } return Column( children: [ // баннер Джуниор подписки - Временно отключаем // JuniorTasksBanner(), // список заданий Padding( padding: const EdgeInsets.only(left: 20, right: 20, bottom: 10), child: Observer( builder: (context) { // ошибки загрузки отдельно задач на сегодня if (taskController.fetchTodaysTasksFuture.status == FutureStatus.rejected) { return TasksErrorState( errorText: t.tasks.task_list.errors.task_loading_failed, onRetry: taskController.fetchTodaysTasks, ); } // ждем, когда загрузятся задачи if (taskController.fetchTodaysTasksFuture.status == FutureStatus.pending) { return TaskListLoader(); } // если список задач пустой, а статы еще не прогрузились, показываем лоадер if (taskController.todaysTodoTasksSource.isEmpty && taskController.fetchTaskStatsByStatusFuture.status == FutureStatus.pending) { return TaskListLoader(); } // если никогда не создавали задач if (taskController.neverHadTasks) { return Text('There will be tasks here'); } // если на сегодня нет задач if (taskController.noTodoTasksToday) { // показываем заглушку "Сегодня дел нет" return NoTasksPlaceholder(); } // если на сегодня есть задачи if (taskController.hasTodoTasksToday) { // показываем список "Дела на сегодня" return TaskListView(tasks: taskController.todaysTodoTasksWindow.toList()); } // если на сегодня задач нет, но есть задачи в списке выполненных if (taskController.allTodaysTasksDone) { // показываем заглушку "Все дела сделаны" return TasksDonePlaceholder(); } return TasksDonePlaceholder(); }, ), ), ], ); }, ), ], ), ), ), ), ); } } ```
task_list_view.dart
```dart import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:get_it/get_it.dart'; import '../../../../i18n/strings.g.dart'; import '../../controllers/task_controller.dart'; import '../../models/task.dart'; import 'task_tile.dart'; class TaskListView extends StatelessWidget { const TaskListView({super.key, required this.tasks}); final List tasks; @override Widget build(BuildContext context) { final t = Translations.of(context); final ctrl = GetIt.I(); final showMoreButtonStyle = TextButton.styleFrom( padding: EdgeInsets.zero, minimumSize: Size(50, 36), tapTargetSize: MaterialTapTargetSize.shrinkWrap, alignment: Alignment.centerLeft, ); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( t.tasks.task_list.header, // Дела на сегодня ), SizedBox( height: 12.0, width: 1, ), AnimatedSize( alignment: Alignment.topCenter, duration: 300.ms, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ ListView.separated( physics: NeverScrollableScrollPhysics(), padding: EdgeInsets.zero, shrinkWrap: true, itemCount: tasks.length, itemBuilder: (ctx, i) { return TaskTile( task: tasks[i], ); }, separatorBuilder: (_, __) => SizedBox(height: 14.0), ), if (ctrl.showCollapseButton) ...[ if (!ctrl.endReached) // Показать ещё TextButton( onPressed: ctrl.todoTasksAddMore, style: showMoreButtonStyle, child: Text(t.tasks.task_list.load_more), ) else // Свернуть всё TextButton( onPressed: ctrl.collapseTasks, style: showMoreButtonStyle, child: Text(t.tasks.task_list.collapse), ), ] ], ), ), ], ); } } ```
task_controller.dart
```dart // ignore_for_file: library_private_types_in_public_api import 'dart:async'; import 'package:collection/collection.dart'; import 'package:mobx/mobx.dart'; import '../../../graphql_generated/graphql/task.graphql.dart'; import '../../../utils/logger.dart'; import '../../auth/controllers/user_controller.dart'; import '../../task_tracker/models/task.dart'; import '../../wallet/controllers/wallet_controller.dart'; import '../models/status_to_task_stats.dart'; import '../models/task_decision.dart'; import '../models/task_input.dart'; import '../repositories/task_repository.dart'; part 'task_controller.g.dart'; /// контроллер главной страницы с задачами: "Дела на сегодня" и карусель выполненных class TaskController = _TaskControllerBase with _$TaskController; abstract class _TaskControllerBase with Store { _TaskControllerBase({ required this.taskRepository, required this.userController, required this.walletController, }); final TaskRepository taskRepository; final UserController userController; final WalletController walletController; // * Observables /// все задачи на сегодня @observable ObservableList _todaysTasks = ObservableList.of([]); @observable List taskStatsByStatus = []; // * Computeds @computed TaskStats get allTaskStats { int total = 0; int unseen = 0; for (var s in taskStatsByStatus) { total += s.stats.total; unseen += s.stats.unseen; } return TaskStats(total: total, unseen: unseen); } // выборка только задач в статусе Todo из сегодняшних @computed ObservableList get todaysTodoTasksSource { final tasks = _todaysTasks.where((e) => e.status.value == TaskStatus.created).toList(); tasks.sort(createdSortFunction); return tasks.asObservable(); } // выборка только задач в статусе Done из сегодняшних @computed ObservableList get todaysDoneTasks { final tasks = _todaysTasks.where((e) => e.status.value == TaskStatus.done).toList(); tasks.sort(doneSortFunction); return tasks.reversed.toList().asObservable(); } /// задачи на сегодня, с пагинацией по $limit шт. @observable var todaysTodoTasksWindow = [].asObservable(); int _offset = 0; final int _limit = 10; // показывать кнопку "Показать ещё" только если число задач превышает 1 страничку @computed bool get showCollapseButton => todaysTodoTasksSource.length > _limit; // дошли до конца списка @computed bool get endReached => _offset >= todaysTodoTasksSource.length; // если задачи отсутствуют и статы по ним отсутствуют - никогда не создавали задач @computed bool get neverHadTasks => _todaysTasks.isEmpty && taskStatsByStatus.isEmpty; // если задачи к выполнению отсутствуют, но статы есть - значит, нет задач на сегодня (иначе показываем neverHadTasks) @computed bool get noTodoTasksToday => todaysTodoTasksSource.isEmpty && todaysDoneTasks.isEmpty && taskStatsByStatus.isNotEmpty; @computed bool get hasTodoTasksToday => todaysTodoTasksSource.isNotEmpty; @computed bool get allTodaysTasksDone => todaysTodoTasksSource.isEmpty && todaysDoneTasks.isNotEmpty; // * Reactions // * Futures @observable ObservableFuture> fetchTodaysTasksFuture = tasksEmptyResponse; static ObservableFuture> tasksEmptyResponse = ObservableFuture.value([]); @observable ObservableFuture> fetchTaskStatsByStatusFuture = taskStatsEmptyResponse; static ObservableFuture> taskStatsEmptyResponse = ObservableFuture.value([]); // * Actions /// получает список задач на сегодня @action Future fetchTodaysTasks() async { fetchTodaysTasksFuture = ObservableFuture( taskRepository.getTasks( user: userController.user!, baseDate: DateTime.now(), statuses: [ Enum_TaskStatus.CREATED, Enum_TaskStatus.DONE, ], // 1 день, начиная с сегодняшнего дня == "сегодня" direction: Enum_TaskOffsetDirection.AFTER, limit: 1, ), ); final tasks = await fetchTodaysTasksFuture; if (tasks.isNotEmpty) { _todaysTasks = tasks.asObservable(); log.d('Сегодняшние задачи загрузились успешно: ${todaysTodoTasksSource.length} шт.'); // добавляем первые 10 задач в "План на сегодня" _initTasks(); } else { log.d('Список задач на сегодня пуст'); } } /// получает количество задач по статусам @action Future fetchTaskStats() async { final userId = userController.user?.id; if (userId == null) { throw Exception('userId == null'); } fetchTaskStatsByStatusFuture = ObservableFuture( taskRepository.getTaskStatsByStatus(userId), ); taskStatsByStatus = await fetchTaskStatsByStatusFuture; } @action Future createTask(TaskInput input) async { Task createdTask = await taskRepository.createTask(input.toDto()); log.d('Задача ${createdTask.toString()} создана'); // обновить статы (чтобы появились кнопки фильтров) await fetchTaskStats(); // добавляем новую задачу в начало списка _todaysTasks.add(createdTask); _todaysTasks.sort(createdSortFunction); todaysTodoTasksWindow.add(createdTask); // отражаем в "План на сегодня" todaysTodoTasksWindow.sort(createdSortFunction); _offset++; } @action Future findTaskById(String id) { return taskRepository.findTaskById(taskId: id, user: userController.user!); } @action Future editTask(String taskId, TaskInput input) async { Task editedTask = await taskRepository.editTask( taskId, userController.user!.id, input.toDto(), ); log.d('Задача ${editedTask.toString()} изменена'); // удалить старую задачу из списка и добавить отредактированную int index = _todaysTasks.indexWhere((e) => e.id == taskId); _todaysTasks.removeAt(index); _todaysTasks.insert(index, editedTask); // если задача отображается в делах на сегодня, заменим её тоже final target = todaysTodoTasksWindow.singleWhereOrNull((e) => e.id == taskId); if (target != null) { final todoTasksWindowIndex = todaysTodoTasksWindow.indexWhere((e) => e.id == taskId); todaysTodoTasksWindow.removeAt(todoTasksWindowIndex); todaysTodoTasksWindow.insert(todoTasksWindowIndex, editedTask); } } @action Future deleteTaskById(String taskId) async { await taskRepository.deleteTask(taskId, userController.user!.id); _todaysTasks.removeWhere((e) => e.id == taskId); // отражаем в "Делах на сегодня" final target = todaysTodoTasksWindow.singleWhereOrNull((e) => e.id == taskId); if (target != null) { todaysTodoTasksWindow.remove(target); } _offset--; } /// отклонить или принять задачу (взрослый) @action Future takeDecision(String taskId, TaskDecision decision) async { final newStatus = await taskRepository.takeDecision( taskId, userController.user!.id, decision.toDto(), ); // Обновляем таску и статы и баланс при необходимости await updateTask(taskId, newStatus); return newStatus; } /// пометить задачу выполненной (ребенок) @action Future markAsDone(String taskId, String doneBy) async { final newStatus = await taskRepository.markAsDone(taskId, doneBy); // Обновляем таску и статы и баланс при необходимости await updateTask(taskId, newStatus); return newStatus; } @action Future updateTask(String taskId, TaskStatus newStatus) async { // Обновляем текущую таску final task = _todaysTasks.singleWhereOrNull((t) => t.id == taskId); if (task != null) { if (newStatus == TaskStatus.verify) { task.verifyAt.value = DateTime.now(); } if (newStatus == TaskStatus.done) { task.doneAt.value = DateTime.now(); // обновляем баланс unawaited(walletController.refetchCurrentAccount()); } task.status.value = newStatus; } // рефетчим статы await fetchTaskStats(); } // при получении задач с сервера @action void _initTasks() { todaysTodoTasksWindow.clear(); _offset = 0; todoTasksAddMore(); } /// по нажатию на кнопку "Показать ещё" @action void todoTasksAddMore() { if (_offset + _limit > todaysTodoTasksSource.length) { todaysTodoTasksWindow.addAll(todaysTodoTasksSource.getRange(_offset, todaysTodoTasksSource.length)); _offset = todaysTodoTasksSource.length; } else { todaysTodoTasksWindow.addAll(todaysTodoTasksSource.getRange(_offset, _offset + _limit)); _offset += _limit; } } /// по нажатию на кнопку "Свернуть" @action void collapseTasks() { _initTasks(); } int createdSortFunction(Task a, Task b) { if (a.endAt == null) { return 1; } if (b.endAt == null) { return -1; } final dateDiff = a.endAt!.difference(b.endAt!).inMinutes; if (dateDiff != 0) { return dateDiff; } else { return b.cost - a.cost; } } int doneSortFunction(Task a, Task b) { if (a.doneAt.value == null) { return 1; } if (b.doneAt.value == null) { return -1; } final dateDiff = a.doneAt.value!.difference(b.doneAt.value!).inMinutes; if (dateDiff != 0) { return dateDiff; } else { return b.cost - a.cost; } } } ```
amondnet commented 8 months ago

@subzero911 Shouldn't _offset be observable?

When showCollapseButton and endReached are changing, UI rebuilds successfully.

But if I wrap inner TextButton in additional Observer, for some reason it doesn't rebuild properly. Observer->Column->TaskListView->Column->Observer->Button

In this case, it seems to be because the computed used by the button is not changing properly. When the button is not wrapped in an observer, a rebuild occurs in the parent widget and it changes properly.

amondnet commented 8 months ago
import 'package:mobx/mobx.dart';
import 'package:test/test.dart';

part 'nested_store.g.dart';

// ignore: library_private_types_in_public_api
class NestedStore = _NestedStore with _$NestedStore;

abstract class _NestedStore with Store {
  @observable
  ObservableList<int> list = ObservableList();

  int offset = 0;

  @computed
  bool get endReached => list.length >= offset;
}

class NestedStore2 = _NestedStore2 with _$NestedStore2;

abstract class _NestedStore2 with Store {
  @observable
  ObservableList<int> list = ObservableList();

  @observable
  int offset = 0;

  @computed
  bool get endReached => list.length >= offset;
}

void main() {
  test('offset', () {
    final store = NestedStore();

    var f;
    reaction((p0) => store.endReached, (value) {
      f = value;
    });
    store.offset = 1;
    expect(f, null);

    runInAction(() => store.list.add(1));
    expect(f, null);

    runInAction(() => store.list.add(1));
    expect(f, null);
  });

  test('offset observable', () {
    final store = NestedStore2();

    var f;
    reaction((p0) => store.endReached, (value) {
      f = value;
    });
    store.offset = 1;
    expect(f, false);

    runInAction(() => store.list.add(1));
    expect(f, true);

    runInAction(() => store.list.add(1));
    expect(f, true);
  });
}
subzero911 commented 8 months ago

Yes, you were right, I forgot to mark _offset as @observable! That's why computed wasn't recalculated. Thank you @amondnet ! I wrestled with it for a lot of time, and thought that there's some issue with MobX, but it was a problem on my side. So I'll close the issue.