Repository: zulip/zulip-flutter
Pull request #1482: Edit-message (5/n): Fix bug where compose progress can be lost on new event queue
Author: login: chrisbobbe
Labels: name: integration review
description: Added by maintainers when PR may be ready for integration
Created: 2025-04-24T02:33:58Z | Merged: 2025-04-25T05:15:38Z

And factor out a helper for the part that reads `widget.narrow` and sets a new controller accordingly; we'll use this helper for the edit-message UI, to reset the compose box at the end of an edit-message session.

Fixes: #1470


Diff:
diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart
index 005321719d..11d27d5402 100644
--- a/lib/widgets/compose_box.dart
+++ b/lib/widgets/compose_box.dart
@@ -1523,14 +1523,26 @@ class _ComposeBoxState extends State<ComposeBox> with PerAccountStoreAwareStateM
 
   @override
   void onNewStore() {
+    final newStore = PerAccountStoreWidget.of(context);
+
+    final controller = _controller;
+    if (controller == null) {
+      _setNewController(newStore);
+      return;
+    }
+
+    switch (controller) {
+      case StreamComposeBoxController():
+        controller.topic.store = newStore;
+      case FixedDestinationComposeBoxController():
+        // no reference to the store that needs updating
+    }
+  }
+
+  void _setNewController(PerAccountStore store) {
     switch (widget.narrow) {
       case ChannelNarrow():
-        final store = PerAccountStoreWidget.of(context);
-        if (_controller == null) {
-          _controller = StreamComposeBoxController(store: store);
-        } else {
-          (controller as StreamComposeBoxController).topic.store = store;
-        }
+        _controller = StreamComposeBoxController(store: store);
       case TopicNarrow():
       case DmNarrow():
         _controller = FixedDestinationComposeBoxController();
diff --git a/test/widgets/compose_box_test.dart b/test/widgets/compose_box_test.dart
index e02fd97a15..65740a8d1e 100644
--- a/test/widgets/compose_box_test.dart
+++ b/test/widgets/compose_box_test.dart
@@ -28,6 +28,7 @@ import '../api/fake_api.dart';
 import '../example_data.dart' as eg;
 import '../flutter_checks.dart';
 import '../model/binding.dart';
+import '../model/store_checks.dart';
 import '../model/test_store.dart';
 import '../model/typing_status_test.dart';
 import '../stdlib_checks.dart';
@@ -58,14 +59,14 @@ void main() {
     zulipFeatureLevel ??= eg.futureZulipFeatureLevel;
     final selfAccount = eg.account(user: selfUser, zulipFeatureLevel: zulipFeatureLevel);
     await testBinding.globalStore.add(selfAccount, eg.initialSnapshot(
+      realmUsers: [selfUser, ...otherUsers],
+      streams: streams,
       zulipFeatureLevel: zulipFeatureLevel,
       realmMandatoryTopics: mandatoryTopics,
     ));
 
     store = await testBinding.globalStore.perAccount(selfAccount.id);
 
-    await store.addUsers([selfUser, ...otherUsers]);
-    await store.addStreams(streams);
     connection = store.connection as FakeApiConnection;
 
     await tester.pumpWidget(TestZulipApp(accountId: selfAccount.id,
@@ -111,6 +112,11 @@ void main() {
     await tester.enterText(contentInputFinder, content);
   }
 
+  void checkContentInputValue(WidgetTester tester, String expected) {
+    check(tester.widget<TextField>(contentInputFinder))
+      .controller.isNotNull().value.text.equals(expected);
+  }
+
   Future<void> tapSendButton(WidgetTester tester) async {
     connection.prepare(json: SendMessageResult(id: 123).toJson());
     await tester.tap(find.byIcon(ZulipIcons.send));
@@ -1239,4 +1245,39 @@ void main() {
         maxHeight: verticalPadding + 170 * 1.5, maxVisibleLines: 6);
     });
   });
+
+  group('ComposeBoxState new-event-queue transition', () {
+    testWidgets('content input not cleared when store changes', (tester) async {
+      // Regression test for: https://github.com/zulip/zulip-flutter/issues/1470
+
+      TypingNotifier.debugEnable = false;
+      addTearDown(TypingNotifier.debugReset);
+
+      final channel = eg.stream();
+      await prepareComposeBox(tester,
+        narrow: eg.topicNarrow(channel.streamId, 'topic'), streams: [channel]);
+
+      await enterContent(tester, 'some content');
+      checkContentInputValue(tester, 'some content');
+
+      store.updateMachine!
+        ..debugPauseLoop()
+        ..poll()
+        ..debugPrepareLoopError(
+            eg.apiExceptionBadEventQueueId(queueId: store.queueId))
+        ..debugAdvanceLoop();
+      await tester.pump();
+
+      final newStore = testBinding.globalStore.perAccountSync(store.accountId)!;
+      check(newStore)
+        // a new store has replaced the old one
+        ..not((it) => it.identicalTo(store))
+        // new store has the same boring data, in order to present a compose box
+        // that allows composing, instead of a no-posting-permission banner
+        ..accountId.equals(store.accountId)
+        ..streams.containsKey(channel.streamId);
+
+      checkContentInputValue(tester, 'some content');
+    });
+  });
 }


Reviews:
user: login: PIG208
body: Thanks! The fix looks good to me with manual testing. Left some comments.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-24T17:58:43Z
user: login: chrisbobbe
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-24T20:10:48Z
user: login: chrisbobbe
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-24T20:24:02Z
user: login: gnprice
body: Thanks both! Looks good; small comments.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-25T04:39:04Z

Review comments:
diff_hunk: @@ -1239,4 +1245,41 @@ void main() {
         maxHeight: verticalPadding + 170 * 1.5, maxVisibleLines: 6);
     });
   });
+
+  group('ComposeBoxState new-event-queue transition', () {
+    testWidgets('content input not cleared when store changes', (tester) async {
+      // Regression test for: https://github.com/zulip/zulip-flutter/issues/1470
+
+      TypingNotifier.debugEnable = false;
+      addTearDown(TypingNotifier.debugReset);
+
+      final channel = eg.stream();
+      await prepareComposeBox(tester,
+        narrow: eg.topicNarrow(channel.streamId, 'topic'), streams: [channel]);
+
+      await enterContent(tester, 'some content');
+      checkContentInputValue(tester, 'some content');
+
+      store.updateMachine!
+        ..debugPauseLoop()
+        ..poll()
+        ..debugPrepareLoopError(
+            eg.apiExceptionBadEventQueueId(queueId: store.queueId))
+        ..debugAdvanceLoop();
+      await tester.pump();
+
+      // new store replaces the old one
+      final newStore = testBinding.globalStore.perAccountSync(eg.selfAccount.id)!;
path: test/widgets/compose_box_test.dart
user: login: PIG208
body: How about this?

```suggestion
      final newStore = testBinding.globalStore.perAccountSync(store.accountId)!;
```

This way we don't rely on `prepareComposeBox` using a specific value for self-account ID.
created_at: 2025-04-24T17:47:42Z
author_association: MEMBER
diff_hunk: @@ -1239,4 +1245,41 @@ void main() {
         maxHeight: verticalPadding + 170 * 1.5, maxVisibleLines: 6);
     });
   });
+
+  group('ComposeBoxState new-event-queue transition', () {
+    testWidgets('content input not cleared when store changes', (tester) async {
+      // Regression test for: https://github.com/zulip/zulip-flutter/issues/1470
+
+      TypingNotifier.debugEnable = false;
+      addTearDown(TypingNotifier.debugReset);
+
+      final channel = eg.stream();
+      await prepareComposeBox(tester,
+        narrow: eg.topicNarrow(channel.streamId, 'topic'), streams: [channel]);
+
+      await enterContent(tester, 'some content');
+      checkContentInputValue(tester, 'some content');
+
+      store.updateMachine!
+        ..debugPauseLoop()
+        ..poll()
+        ..debugPrepareLoopError(
+            eg.apiExceptionBadEventQueueId(queueId: store.queueId))
+        ..debugAdvanceLoop();
+      await tester.pump();
+
+      // new store replaces the old one
+      final newStore = testBinding.globalStore.perAccountSync(eg.selfAccount.id)!;
+      check(store).not((it) => it.identicalTo(newStore));
+
+      // new store has the same basic boring data so the compose box
+      // has its content input instead of no-posting-permission error banner
+      // TODO instead, add data by preparing new store's initial snapshot
+      await newStore.addStream(channel);
+      await newStore.addUser(eg.selfUser);
path: test/widgets/compose_box_test.dart
user: login: PIG208
body: Maybe the TODO just calls for moving setup code into the initial snapshot in `prepareComposeBox`?

```diff
     await testBinding.globalStore.add(selfAccount, eg.initialSnapshot(
+      realmUsers: [selfUser, ...otherUsers],
+      streams: streams,
       zulipFeatureLevel: zulipFeatureLevel,
       realmMandatoryTopics: mandatoryTopics,
     ));
 
     store = await testBinding.globalStore.perAccount(selfAccount.id);
-
-    await store.addUsers([selfUser, ...otherUsers]);
-    await store.addStreams(streams);
```

The initial call to `testBinding.globalStore.add` sets the initial snapshot that will also be used when reloading the store.
created_at: 2025-04-24T17:55:41Z
author_association: MEMBER
diff_hunk: @@ -1523,14 +1523,26 @@ class _ComposeBoxState extends State<ComposeBox> with PerAccountStoreAwareStateM
 
   @override
   void onNewStore() {
+    final newStore = PerAccountStoreWidget.of(context);
+
+    if (_controller == null) {
+      _setNewControllerForNarrow(newStore);
+    } else {
path: lib/widgets/compose_box.dart
user: login: PIG208
body: nit: perhaps we can return early here and de-indent the else-block
created_at: 2025-04-24T17:56:52Z
author_association: MEMBER
diff_hunk: @@ -1239,4 +1245,41 @@ void main() {
         maxHeight: verticalPadding + 170 * 1.5, maxVisibleLines: 6);
     });
   });
+
+  group('ComposeBoxState new-event-queue transition', () {
+    testWidgets('content input not cleared when store changes', (tester) async {
+      // Regression test for: https://github.com/zulip/zulip-flutter/issues/1470
+
+      TypingNotifier.debugEnable = false;
+      addTearDown(TypingNotifier.debugReset);
+
+      final channel = eg.stream();
+      await prepareComposeBox(tester,
+        narrow: eg.topicNarrow(channel.streamId, 'topic'), streams: [channel]);
+
+      await enterContent(tester, 'some content');
+      checkContentInputValue(tester, 'some content');
+
+      store.updateMachine!
+        ..debugPauseLoop()
+        ..poll()
+        ..debugPrepareLoopError(
+            eg.apiExceptionBadEventQueueId(queueId: store.queueId))
+        ..debugAdvanceLoop();
+      await tester.pump();
+
+      // new store replaces the old one
+      final newStore = testBinding.globalStore.perAccountSync(eg.selfAccount.id)!;
path: test/widgets/compose_box_test.dart
user: login: chrisbobbe
body: Ooh, thanks for thinking of this.
created_at: 2025-04-24T20:10:48Z
author_association: COLLABORATOR
diff_hunk: @@ -1239,4 +1245,41 @@ void main() {
         maxHeight: verticalPadding + 170 * 1.5, maxVisibleLines: 6);
     });
   });
+
+  group('ComposeBoxState new-event-queue transition', () {
+    testWidgets('content input not cleared when store changes', (tester) async {
+      // Regression test for: https://github.com/zulip/zulip-flutter/issues/1470
+
+      TypingNotifier.debugEnable = false;
+      addTearDown(TypingNotifier.debugReset);
+
+      final channel = eg.stream();
+      await prepareComposeBox(tester,
+        narrow: eg.topicNarrow(channel.streamId, 'topic'), streams: [channel]);
+
+      await enterContent(tester, 'some content');
+      checkContentInputValue(tester, 'some content');
+
+      store.updateMachine!
+        ..debugPauseLoop()
+        ..poll()
+        ..debugPrepareLoopError(
+            eg.apiExceptionBadEventQueueId(queueId: store.queueId))
+        ..debugAdvanceLoop();
+      await tester.pump();
+
+      // new store replaces the old one
+      final newStore = testBinding.globalStore.perAccountSync(eg.selfAccount.id)!;
+      check(store).not((it) => it.identicalTo(newStore));
+
+      // new store has the same basic boring data so the compose box
+      // has its content input instead of no-posting-permission error banner
+      // TODO instead, add data by preparing new store's initial snapshot
+      await newStore.addStream(channel);
+      await newStore.addUser(eg.selfUser);
path: test/widgets/compose_box_test.dart
user: login: chrisbobbe
body: > The initial call to `testBinding.globalStore.add` sets the initial snapshot that will also be used when reloading the store.

Ohh, I didn't realize; thanks for pointing this out.
created_at: 2025-04-24T20:24:02Z
author_association: COLLABORATOR
diff_hunk: @@ -1523,14 +1523,27 @@ class _ComposeBoxState extends State<ComposeBox> with PerAccountStoreAwareStateM
 
   @override
   void onNewStore() {
+    final newStore = PerAccountStoreWidget.of(context);
+
+    if (_controller == null) {
+      _setNewControllerForNarrow(newStore);
+      return;
+    }
+
+    switch (_controller) {
+      case StreamComposeBoxController():
+        (_controller as StreamComposeBoxController).topic.store = newStore;
path: lib/widgets/compose_box.dart
user: login: gnprice
body: This can be simplified by extracting a local variable as a copy of the field, so that type promotion works:

```suggestion
    final controller = _controller;
    switch (controller) {
      case StreamComposeBoxController():
        controller.topic.store = newStore;
```

created_at: 2025-04-25T04:25:15Z
author_association: MEMBER
diff_hunk: @@ -1523,14 +1523,27 @@ class _ComposeBoxState extends State<ComposeBox> with PerAccountStoreAwareStateM
 
   @override
   void onNewStore() {
+    final newStore = PerAccountStoreWidget.of(context);
+
+    if (_controller == null) {
+      _setNewControllerForNarrow(newStore);
+      return;
+    }
+
+    switch (_controller) {
+      case StreamComposeBoxController():
+        (_controller as StreamComposeBoxController).topic.store = newStore;
+      case FixedDestinationComposeBoxController():
+        break; // no reference to the store that needs updating
+      case null:
+        assert(false);
+    }
path: lib/widgets/compose_box.dart
user: login: gnprice
body: nit: Similarly if the local is used for the initial null check, then the analyzer sees this case is impossible.
created_at: 2025-04-25T04:25:51Z
author_association: MEMBER
diff_hunk: @@ -1523,14 +1523,27 @@ class _ComposeBoxState extends State<ComposeBox> with PerAccountStoreAwareStateM
 
   @override
   void onNewStore() {
+    final newStore = PerAccountStoreWidget.of(context);
+
+    if (_controller == null) {
+      _setNewControllerForNarrow(newStore);
+      return;
+    }
+
+    switch (_controller) {
+      case StreamComposeBoxController():
+        (_controller as StreamComposeBoxController).topic.store = newStore;
+      case FixedDestinationComposeBoxController():
+        break; // no reference to the store that needs updating
+      case null:
+        assert(false);
+    }
+  }
+
+  void _setNewControllerForNarrow(PerAccountStore store) {
path: lib/widgets/compose_box.dart
user: login: gnprice
body: nit: drop "for narrow"? Seems like it's just the new controller that's appropriate for this compose box.
created_at: 2025-04-25T04:27:46Z
author_association: MEMBER

PR comments:
user: login: chrisbobbe
created_at: 2025-04-24T20:29:26Z
body: Thanks for the review! Revision pushed.
author_association: COLLABORATOR
user: login: PIG208
created_at: 2025-04-24T21:08:03Z
body: Thanks! Marking for Greg's review.
author_association: MEMBER
user: login: chrisbobbe
created_at: 2025-04-25T05:04:28Z
body: Thanks! Revision pushed.
author_association: COLLABORATOR
user: login: gnprice
created_at: 2025-04-25T05:15:22Z
body: Thanks! Looks good; merging.
author_association: MEMBER