store: Ensure sole ownership of MessageListView

Fixes #1358.

PerAccountStore shouldn't be an owner of the MessageListView elements.

Its relationship to MessageListView is similar to that of AutocompleteViewManager to MentionAutocompleteView (#645).

With two owners, the MessageListView can be disposed twice:

1. before the frame is rendered, `removeAccount` disposes the `PerAccountStoreWidget`, which disposes the `MessageListView`; `_MessageListState` is not yet disposed;

2. during build, because `store` is set to `null`, `PerAccountStoreWidget` gets rebuilt. `_MessageListState`, a descendent of it, is no longer in the render tree;

3. during finalization, `_MessageListState` tries to dispose the `MessageListView`.

This removes regression tests added for #810, because `MessageStoreImpl.dispose` no longer exists.  `MessageListView` does not get disposed unless there is a `_MessageListState` owner.

For reproduction, I created a testing branch with #1183 stacked on top of this, but has the fix reverted:

- Checkout https://github.com/PIG208/zulip-flutter/pull/1.
- Go to any `MessageListPage`
- Invalidate the API key (by regenerating it on web, for example)

You should see the following error:

```
======== Exception caught by widgets library =======================================================
The following assertion was thrown while finalizing the widget tree:
'package:zulip/model/message.dart': Failed assertion: line 54 pos 12: 'removed': is not true.

When the exception was thrown, this was the stack:
#2      MessageStoreImpl.unregisterMessageList (package:zulip/model/message.dart:54:12)
#3      PerAccountStore.unregisterMessageList (package:zulip/model/store.dart:540:15)
#4      MessageListView.dispose (package:zulip/model/message_list.dart:427:11)
#5      _MessageListState.dispose (package:zulip/widgets/message_list.dart:491:12)
#6      StatefulElement.unmount (package:flutter/src/widgets/framework.dart:5940:11)
#7      _InactiveElements._unmount (package:flutter/src/widgets/framework.dart:2085:13)
#8      _InactiveElements._unmount.<anonymous closure> (package:flutter/src/widgets/framework.dart:2083:7)
#9      ComponentElement.visitChildren (package:flutter/src/widgets/framework.dart:5781:14)
(elided 2 frames from class _AssertionError)
====================================================================================================
```

[CZO discussion](https://chat.zulip.org/#narrow/channel/243-mobile-team/topic/.60MentionAutocompleteView.2Edispose.60/near/2083074)

MessageListView could be disposed twice

MessageListView could be disposed twice
This is the issue thread for the bug fixed by:
* #1340

See also chat thread:
https://chat.zulip.org/#narrow/channel/243-mobile-team/topic/MessageListView.20lifecycle/near/2083074

I believe this was a crash bug — the stack trace in #1340 was while the Flutter framework is updating the element tree, and I think an exception there can leave things in an inconsistent state where the app no longer responds normally. Not sure of that, though.

OTOH it's probably hard to trigger: as far as I know we never heard from someone running into symptoms from it, and learned about it instead because @PIG208 was studying the code as part of work on #1183.

flutter test test/model/actions_test.dart test/model/message_test.dart test/model/store_test.dart test/widgets/actions_test.dart test/widgets/home_test.dart test/widgets/message_list_test.dart test/widgets/store_test.dart --reporter expanded 2>&1

diff --git a/lib/model/actions.dart b/lib/model/actions.dart
new file mode 100644
index 0000000000..a88eea0777
--- /dev/null
+++ b/lib/model/actions.dart
@@ -0,0 +1,36 @@
+import 'dart:async';
+
+import '../notifications/receive.dart';
+import 'store.dart';
+
+// TODO: Make this a part of GlobalStore
+Future<void> logOutAccount(GlobalStore globalStore, int accountId) async {
+  final account = globalStore.getAccount(accountId);
+  if (account == null) return; // TODO(log)
+
+  // Unawaited, to not block removing the account on this request.
+  unawaited(unregisterToken(globalStore, accountId));
+
+  await globalStore.removeAccount(accountId);
+}
+
+Future<void> unregisterToken(GlobalStore globalStore, int accountId) async {
+  final account = globalStore.getAccount(accountId);
+  if (account == null) return; // TODO(log)
+
+  // TODO(#322) use actual acked push token; until #322, this is just null.
+  final token = account.ackedPushToken
+    // Try the current token as a fallback; maybe the server has registered
+    // it and we just haven't recorded that fact in the client.
+    ?? NotificationService.instance.token.value;
+  if (token == null) return;
+
+  final connection = globalStore.apiConnectionFromAccount(account);
+  try {
+    await NotificationService.unregisterToken(connection, token: token);
+  } catch (e) {
+    // TODO retry? handle failures?
+  } finally {
+    connection.close();
+  }
+}
diff --git a/lib/model/message.dart b/lib/model/message.dart
index f228d6da91..84f3bbc3e5 100644
--- a/lib/model/message.dart
+++ b/lib/model/message.dart
@@ -61,12 +61,20 @@ class MessageStoreImpl with MessageStore {
   }

   void dispose() {
-    // When a MessageListView is disposed, it removes itself from the Set
-    // `MessageStoreImpl._messageListViews`. Instead of iterating on that Set,
-    // iterate on a copy, to avoid concurrent modifications.
-    for (final view in _messageListViews.toList()) {
-      view.dispose();
-    }
+    // Not disposing the [MessageListView]s here, because they are owned by
+    // (i.e., they get [dispose]d by) the [_MessageListState], including in the
+    // case where the [PerAccountStore] is replaced.
+    //
+    // TODO: Add assertions that the [MessageListView]s are indeed disposed, by
+    //   first ensuring that [PerAccountStore] is only disposed after those with
+    //   references to it are disposed, then reinstating this `dispose` method.
+    //
+    //   We can't add the assertions as-is because the sequence of events
+    //   guarantees that `PerAccountStore` is disposed (when that happens,
+    //   [GlobalStore] notifies its listeners, causing widgets dependent on the
+    //   [InheritedNotifier] to rebuild in the next frame) before the owner's
+    //   `dispose` or `onNewStore` is called.  Discussion:
+    //     https://chat.zulip.org/#narrow/channel/243-mobile-team/topic/MessageListView.20lifecycle/near/2086893
   }

   @override
diff --git a/lib/widgets/actions.dart b/lib/widgets/actions.dart
index 86c6e44875..2df502ec6c 100644
--- a/lib/widgets/actions.dart
+++ b/lib/widgets/actions.dart
@@ -15,44 +15,9 @@ import '../api/model/narrow.dart';
 import '../api/route/messages.dart';
 import '../generated/l10n/zulip_localizations.dart';
 import '../model/narrow.dart';
-import '../model/store.dart';
-import '../notifications/receive.dart';
 import 'dialog.dart';
 import 'store.dart';

-Future<void> logOutAccount(BuildContext context, int accountId) async {
-  final globalStore = GlobalStoreWidget.of(context);
-
-  final account = globalStore.getAccount(accountId);
-  if (account == null) return; // TODO(log)
-
-  // Unawaited, to not block removing the account on this request.
-  unawaited(unregisterToken(globalStore, accountId));
-
-  await globalStore.removeAccount(accountId);
-}
-
-Future<void> unregisterToken(GlobalStore globalStore, int accountId) async {
-  final account = globalStore.getAccount(accountId);
-  if (account == null) return; // TODO(log)
-
-  // TODO(#322) use actual acked push token; until #322, this is just null.
-  final token = account.ackedPushToken
-    // Try the current token as a fallback; maybe the server has registered
-    // it and we just haven't recorded that fact in the client.
-    ?? NotificationService.instance.token.value;
-  if (token == null) return;
-
-  final connection = globalStore.apiConnectionFromAccount(account);
-  try {
-    await NotificationService.unregisterToken(connection, token: token);
-  } catch (e) {
-    // TODO retry? handle failures?
-  } finally {
-    connection.close();
-  }
-}
-
 Future<void> markNarrowAsRead(BuildContext context, Narrow narrow) async {
   final store = PerAccountStoreWidget.of(context);
   final connection = store.connection;
diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart
index 9fa82144a1..2ad35e3e53 100644
--- a/lib/widgets/app.dart
+++ b/lib/widgets/app.dart
@@ -6,11 +6,11 @@ import 'package:flutter/scheduler.dart';

 import '../generated/l10n/zulip_localizations.dart';
 import '../log.dart';
+import '../model/actions.dart';
 import '../model/localizations.dart';
 import '../model/store.dart';
 import '../notifications/display.dart';
 import 'about_zulip.dart';
-import 'actions.dart';
 import 'dialog.dart';
 import 'home.dart';
 import 'login.dart';
@@ -265,7 +265,7 @@ class ChooseAccountPage extends StatelessWidget {
                   actionButtonText: zulipLocalizations.logOutConfirmationDialogConfirmButton,
                   onActionButtonPress: () {
                     // TODO error handling if db write fails?
-                    logOutAccount(context, accountId);
+                    logOutAccount(GlobalStoreWidget.of(context), accountId);
                   });
               },
               child: Text(zulipLocalizations.chooseAccountPageLogOutButton)),
diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart
index 57259640b1..4d0ff00072 100644
--- a/lib/widgets/message_list.dart
+++ b/lib/widgets/message_list.dart
@@ -483,6 +483,7 @@ class _MessageListState extends State<MessageList> with PerAccountStoreAwareStat

   @override
   void onNewStore() { // TODO(#464) try to keep using old model until new one gets messages
+    model?.dispose();
     _initModel(PerAccountStoreWidget.of(context));
   }

diff --git a/test/model/actions_test.dart b/test/model/actions_test.dart
new file mode 100644
index 0000000000..e24bb3223c
--- /dev/null
+++ b/test/model/actions_test.dart
@@ -0,0 +1,188 @@
+import 'package:checks/checks.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:http/http.dart' as http;
+import 'package:zulip/api/exception.dart';
+import 'package:zulip/model/actions.dart';
+import 'package:zulip/model/store.dart';
+import 'package:zulip/notifications/receive.dart';
+
+import '../api/fake_api.dart';
+import '../example_data.dart' as eg;
+import '../fake_async.dart';
+import '../model/binding.dart';
+import '../model/store_checks.dart';
+import '../model/test_store.dart';
+import '../stdlib_checks.dart';
+import 'store_test.dart';
+
+void main() {
+  TestZulipBinding.ensureInitialized();
+
+  late PerAccountStore store;
+  late FakeApiConnection connection;
+
+  Future<void> prepare({String? ackedPushToken = '123'}) async {
+    addTearDown(testBinding.reset);
+    final selfAccount = eg.selfAccount.copyWith(ackedPushToken: Value(ackedPushToken));
+    await testBinding.globalStore.add(selfAccount, eg.initialSnapshot());
+    store = await testBinding.globalStore.perAccount(selfAccount.id);
+    connection = store.connection as FakeApiConnection;
+  }
+
+  /// Creates and caches a new [FakeApiConnection] in [TestGlobalStore].
+  ///
+  /// In live code, [unregisterToken] makes a new [ApiConnection] for the
+  /// unregister-token request instead of reusing the store's connection.
+  /// To enable callers to prepare responses for that request, this function
+  /// creates a new [FakeApiConnection] and caches it in [TestGlobalStore]
+  /// for [unregisterToken] to pick up.
+  ///
+  /// Call this instead of just turning on
+  /// [TestGlobalStore.useCachedApiConnections] so that [unregisterToken]
+  /// doesn't try to call `close` twice on the same connection instance,
+  /// which isn't allowed. (Once by the unregister-token code
+  /// and once as part of removing the account.)
+  FakeApiConnection separateConnection() {
+    testBinding.globalStore
+      ..clearCachedApiConnections()
+      ..useCachedApiConnections = true;
+    return testBinding.globalStore
+      .apiConnectionFromAccount(eg.selfAccount) as FakeApiConnection;
+  }
+
+  String unregisterApiPathForPlatform(TargetPlatform platform) {
+    return switch (platform) {
+      TargetPlatform.android => '/api/v1/users/me/android_gcm_reg_id',
+      TargetPlatform.iOS     => '/api/v1/users/me/apns_device_token',
+      _                      => throw Error(),
+    };
+  }
+
+  void checkSingleUnregisterRequest(
+    FakeApiConnection connection, {
+    String? expectedToken,
+  }) {
+    final subject = check(connection.takeRequests()).single.isA<http.Request>()
+      ..method.equals('DELETE')
+      ..url.path.equals(unregisterApiPathForPlatform(defaultTargetPlatform));
+    if (expectedToken != null) {
+      subject.bodyFields.deepEquals({'token': expectedToken});
+    }
+  }
+
+  group('logOutAccount', () {
+    test('smoke', () => awaitFakeAsync((async) async {
+      await prepare();
+      check(testBinding.globalStore).accountIds.single.equals(eg.selfAccount.id);
+      const unregisterDelay = Duration(seconds: 5);
+      assert(unregisterDelay > TestGlobalStore.removeAccountDuration);
+      final newConnection = separateConnection()
+        ..prepare(delay: unregisterDelay, json: {'msg': '', 'result': 'success'});
+
+      final future = logOutAccount(testBinding.globalStore, eg.selfAccount.id);
+      // Unregister-token request and account removal dispatched together
+      checkSingleUnregisterRequest(newConnection);
+      check(testBinding.globalStore.takeDoRemoveAccountCalls())
+        .single.equals(eg.selfAccount.id);
+
+      async.elapse(TestGlobalStore.removeAccountDuration);
+      await future;
+      // Account removal not blocked on unregister-token response
+      check(testBinding.globalStore).accountIds.isEmpty();
+      check(connection.isOpen).isFalse();
+      check(newConnection.isOpen).isTrue(); // still busy with unregister-token
+
+      async.elapse(unregisterDelay - TestGlobalStore.removeAccountDuration);
+      check(newConnection.isOpen).isFalse();
+    }));
+
+    test('unregister request has an error', () => awaitFakeAsync((async) async {
+      await prepare();
+      check(testBinding.globalStore).accountIds.single.equals(eg.selfAccount.id);
+      const unregisterDelay = Duration(seconds: 5);
+      assert(unregisterDelay > TestGlobalStore.removeAccountDuration);
+      final exception = ZulipApiException(
+        httpStatus: 401,
+        code: 'UNAUTHORIZED',
+        data: {"result": "error", "msg": "Invalid API key", "code": "UNAUTHORIZED"},
+        routeName: 'removeEtcEtcToken',
+        message: 'Invalid API key',
+      );
+      final newConnection = separateConnection()
+        ..prepare(delay: unregisterDelay, exception: exception);
+
+      final future = logOutAccount(testBinding.globalStore, eg.selfAccount.id);
+      // Unregister-token request and account removal dispatched together
+      checkSingleUnregisterRequest(newConnection);
+      check(testBinding.globalStore.takeDoRemoveAccountCalls())
+        .single.equals(eg.selfAccount.id);
+
+      async.elapse(TestGlobalStore.removeAccountDuration);
+      await future;
+      // Account removal not blocked on unregister-token response
+      check(testBinding.globalStore).accountIds.isEmpty();
+      check(connection.isOpen).isFalse();
+      check(newConnection.isOpen).isTrue(); // for the unregister-token request
+
+      async.elapse(unregisterDelay - TestGlobalStore.removeAccountDuration);
+      check(newConnection.isOpen).isFalse();
+    }));
+  });
+
+  group('unregisterToken', () {
+    testAndroidIos('smoke, happy path', () => awaitFakeAsync((async) async {
+      await prepare(ackedPushToken: '123');
+
+      final newConnection = separateConnection()
+        ..prepare(json: {'msg': '', 'result': 'success'});
+      final future = unregisterToken(testBinding.globalStore, eg.selfAccount.id);
+      async.elapse(Duration.zero);
+      await future;
+      checkSingleUnregisterRequest(newConnection, expectedToken: '123');
+      check(newConnection.isOpen).isFalse();
+    }));
+
+    test('fallback to current token if acked is missing', () => awaitFakeAsync((async) async {
+      await prepare(ackedPushToken: null);
+      NotificationService.instance.token = ValueNotifier('asdf');
+
+      final newConnection = separateConnection()
+        ..prepare(json: {'msg': '', 'result': 'success'});
+      final future = unregisterToken(testBinding.globalStore, eg.selfAccount.id);
+      async.elapse(Duration.zero);
+      await future;
+      checkSingleUnregisterRequest(newConnection, expectedToken: 'asdf');
+      check(newConnection.isOpen).isFalse();
+    }));
+
+    test('no error if acked token and current token both missing', () => awaitFakeAsync((async) async {
+      await prepare(ackedPushToken: null);
+      NotificationService.instance.token = ValueNotifier(null);
+
+      final newConnection = separateConnection();
+      final future = unregisterToken(testBinding.globalStore, eg.selfAccount.id);
+      async.flushTimers();
+      await future;
+      check(newConnection.takeRequests()).isEmpty();
+    }));
+
+    test('connection closed if request errors', () => awaitFakeAsync((async) async {
+      await prepare(ackedPushToken: '123');
+
+      final newConnection = separateConnection()
+        ..prepare(exception: ZulipApiException(
+            httpStatus: 401,
+            code: 'UNAUTHORIZED',
+            data: {"result": "error", "msg": "Invalid API key", "code": "UNAUTHORIZED"},
+            routeName: 'removeEtcEtcToken',
+            message: 'Invalid API key',
+          ));
+      final future = unregisterToken(testBinding.globalStore, eg.selfAccount.id);
+      async.elapse(Duration.zero);
+      await future;
+      checkSingleUnregisterRequest(newConnection, expectedToken: '123');
+      check(newConnection.isOpen).isFalse();
+    }));
+  });
+}
diff --git a/test/model/message_test.dart b/test/model/message_test.dart
index 43f17be61a..9c9a940d42 100644
--- a/test/model/message_test.dart
+++ b/test/model/message_test.dart
@@ -77,18 +77,6 @@ void main() {
     checkNotified(count: messageList.fetched ? messages.length : 0);
   }

-  test('disposing multiple registered MessageListView instances', () async {
-    // Regression test for: https://github.com/zulip/zulip-flutter/issues/810
-    await prepare(narrow: const MentionsNarrow());
-    MessageListView.init(store: store, narrow: const StarredMessagesNarrow());
-    check(store.debugMessageListViews).length.equals(2);
-
-    // When disposing, the [MessageListView]s are expected to unregister
-    // themselves from the message store.
-    store.dispose();
-    check(store.debugMessageListViews).isEmpty();
-  });
-
   group('reconcileMessages', () {
     test('from empty', () async {
       await prepare();
diff --git a/test/model/store_test.dart b/test/model/store_test.dart
index c8c6b4c266..bc393d6d6f 100644
--- a/test/model/store_test.dart
+++ b/test/model/store_test.dart
@@ -12,8 +12,6 @@ import 'package:zulip/api/model/model.dart';
 import 'package:zulip/api/route/events.dart';
 import 'package:zulip/api/route/messages.dart';
 import 'package:zulip/api/route/realm.dart';
-import 'package:zulip/model/message_list.dart';
-import 'package:zulip/model/narrow.dart';
 import 'package:zulip/log.dart';
 import 'package:zulip/model/store.dart';
 import 'package:zulip/notifications/receive.dart';
@@ -824,25 +822,6 @@ void main() {
       checkReload(prepareHandleEventError);
     });

-    test('expired queue disposes registered MessageListView instances', () => awaitFakeAsync((async) async {
-      // Regression test for: https://github.com/zulip/zulip-flutter/issues/810
-      await preparePoll();
-
-      // Make sure there are [MessageListView]s in the message store.
-      MessageListView.init(store: store, narrow: const MentionsNarrow());
-      MessageListView.init(store: store, narrow: const StarredMessagesNarrow());
-      check(store.debugMessageListViews).length.equals(2);
-
-      // Let the server expire the event queue.
-      prepareExpiredEventQueue();
-      updateMachine.debugAdvanceLoop();
-      async.elapse(Duration.zero);
-
-      // The old store's [MessageListView]s have been disposed.
-      // (And no exception was thrown; that was #810.)
-      check(store.debugMessageListViews).isEmpty();
-    }));
-
     group('report error', () {
       String? lastReportedError;
       String? takeLastReportedError() {
diff --git a/test/widgets/actions_test.dart b/test/widgets/actions_test.dart
index 5a957c44e0..290d6f3e53 100644
--- a/test/widgets/actions_test.dart
+++ b/test/widgets/actions_test.dart
@@ -1,13 +1,9 @@
-import 'dart:async';
 import 'dart:convert';

 import 'package:checks/checks.dart';
-import 'package:flutter/foundation.dart';
 import 'package:flutter/material.dart';
-import 'package:flutter_checks/flutter_checks.dart';
 import 'package:flutter_test/flutter_test.dart';
 import 'package:http/http.dart' as http;
-import 'package:zulip/api/exception.dart';
 import 'package:zulip/api/model/initial_snapshot.dart';
 import 'package:zulip/api/model/model.dart';
 import 'package:zulip/api/model/narrow.dart';
@@ -15,20 +11,13 @@ import 'package:zulip/api/route/messages.dart';
 import 'package:zulip/model/localizations.dart';
 import 'package:zulip/model/narrow.dart';
 import 'package:zulip/model/store.dart';
-import 'package:zulip/notifications/receive.dart';
 import 'package:zulip/widgets/actions.dart';
-import 'package:zulip/widgets/app.dart';
-import 'package:zulip/widgets/inbox.dart';
-import 'package:zulip/widgets/page.dart';

 import '../api/fake_api.dart';
 import '../example_data.dart' as eg;
 import '../model/binding.dart';
-import '../model/store_checks.dart';
-import '../model/test_store.dart';
 import '../model/unreads_checks.dart';
 import '../stdlib_checks.dart';
-import '../test_navigation.dart';
 import 'dialog_checks.dart';
 import 'test_app.dart';

@@ -59,228 +48,6 @@ void main() {
     context = tester.element(find.byType(Placeholder));
   }

-  /// Creates and caches a new [FakeApiConnection] in [TestGlobalStore].
-  ///
-  /// In live code, [unregisterToken] makes a new [ApiConnection] for the
-  /// unregister-token request instead of reusing the store's connection.
-  /// To enable callers to prepare responses for that request, this function
-  /// creates a new [FakeApiConnection] and caches it in [TestGlobalStore]
-  /// for [unregisterToken] to pick up.
-  ///
-  /// Call this instead of just turning on
-  /// [TestGlobalStore.useCachedApiConnections] so that [unregisterToken]
-  /// doesn't try to call `close` twice on the same connection instance,
-  /// which isn't allowed. (Once by the unregister-token code
-  /// and once as part of removing the account.)
-  FakeApiConnection separateConnection() {
-    testBinding.globalStore
-      ..clearCachedApiConnections()
-      ..useCachedApiConnections = true;
-    return testBinding.globalStore
-      .apiConnectionFromAccount(eg.selfAccount) as FakeApiConnection;
-  }
-
-  String unregisterApiPathForPlatform(TargetPlatform platform) {
-    return switch (platform) {
-      TargetPlatform.android => '/api/v1/users/me/android_gcm_reg_id',
-      TargetPlatform.iOS     => '/api/v1/users/me/apns_device_token',
-      _                      => throw Error(),
-    };
-  }
-
-  void checkSingleUnregisterRequest(
-    FakeApiConnection connection, {
-    String? expectedToken,
-  }) {
-    final subject = check(connection.takeRequests()).single.isA<http.Request>()
-      ..method.equals('DELETE')
-      ..url.path.equals(unregisterApiPathForPlatform(defaultTargetPlatform));
-    if (expectedToken != null) {
-      subject.bodyFields.deepEquals({'token': expectedToken});
-    }
-  }
-
-  group('logOutAccount', () {
-    testWidgets('smoke', (tester) async {
-      await prepare(tester, skipAssertAccountExists: true);
-      check(testBinding.globalStore).accountIds.single.equals(eg.selfAccount.id);
-      const unregisterDelay = Duration(seconds: 5);
-      assert(unregisterDelay > TestGlobalStore.removeAccountDuration);
-      final newConnection = separateConnection()
-        ..prepare(delay: unregisterDelay, json: {'msg': '', 'result': 'success'});
-
-      final future = logOutAccount(context, eg.selfAccount.id);
-      // Unregister-token request and account removal dispatched together
-      checkSingleUnregisterRequest(newConnection);
-      check(testBinding.globalStore.takeDoRemoveAccountCalls())
-        .single.equals(eg.selfAccount.id);
-
-      await tester.pump(TestGlobalStore.removeAccountDuration);
-      await future;
-      // Account removal not blocked on unregister-token response
-      check(testBinding.globalStore).accountIds.isEmpty();
-      check(connection.isOpen).isFalse();
-      check(newConnection.isOpen).isTrue(); // still busy with unregister-token
-
-      await tester.pump(unregisterDelay - TestGlobalStore.removeAccountDuration);
-      check(newConnection.isOpen).isFalse();
-    });
-
-    testWidgets('unregister request has an error', (tester) async {
-      await prepare(tester, skipAssertAccountExists: true);
-      check(testBinding.globalStore).accountIds.single.equals(eg.selfAccount.id);
-      const unregisterDelay = Duration(seconds: 5);
-      assert(unregisterDelay > TestGlobalStore.removeAccountDuration);
-      final exception = ZulipApiException(
-        httpStatus: 401,
-        code: 'UNAUTHORIZED',
-        data: {"result": "error", "msg": "Invalid API key", "code": "UNAUTHORIZED"},
-        routeName: 'removeEtcEtcToken',
-        message: 'Invalid API key',
-      );
-      final newConnection = separateConnection()
-        ..prepare(delay: unregisterDelay, exception: exception);
-
-      final future = logOutAccount(context, eg.selfAccount.id);
-      // Unregister-token request and account removal dispatched together
-      checkSingleUnregisterRequest(newConnection);
-      check(testBinding.globalStore.takeDoRemoveAccountCalls())
-        .single.equals(eg.selfAccount.id);
-
-      await tester.pump(TestGlobalStore.removeAccountDuration);
-      await future;
-      // Account removal not blocked on unregister-token response
-      check(testBinding.globalStore).accountIds.isEmpty();
-      check(connection.isOpen).isFalse();
-      check(newConnection.isOpen).isTrue(); // for the unregister-token request
-
-      await tester.pump(unregisterDelay - TestGlobalStore.removeAccountDuration);
-      check(newConnection.isOpen).isFalse();
-    });
-
-    testWidgets("logged-out account's routes removed from nav; other accounts' remain", (tester) async {
-      Future<void> makeUnreadTopicInInbox(int accountId, String topic) async {
-        final stream = eg.stream();
-        final message = eg.streamMessage(stream: stream, topic: topic);
-        final store = await testBinding.globalStore.perAccount(accountId);
-        await store.addStream(stream);
-        await store.addSubscription(eg.subscription(stream));
-        await store.addMessage(message);
-        await tester.pump();
-      }
-
-      addTearDown(testBinding.reset);
-
-      final account1 = eg.account(id: 1, user: eg.user());
-      final account2 = eg.account(id: 2, user: eg.user());
-      await testBinding.globalStore.add(account1, eg.initialSnapshot());
-      await testBinding.globalStore.add(account2, eg.initialSnapshot());
-
-      final testNavObserver = TestNavigatorObserver();
-      await tester.pumpWidget(ZulipApp(navigatorObservers: [testNavObserver]));
-      await tester.pump();
-      final navigator = await ZulipApp.navigator;
-      navigator.popUntil((_) => false); // clear starting routes
-      await tester.pumpAndSettle();
-
-      final pushedRoutes = <Route<dynamic>>[];
-      testNavObserver.onPushed = (route, prevRoute) => pushedRoutes.add(route);
-      // TODO(#737): switch to a realistic setup:
-      //   https://github.com/zulip/zulip-flutter/pull/1076#discussion_r1874124363
-      final account1Route = MaterialAccountWidgetRoute(
-        accountId: account1.id, page: const InboxPageBody());
-      final account2Route = MaterialAccountWidgetRoute(
-        accountId: account2.id, page: const InboxPageBody());
-      unawaited(navigator.push(account1Route));
-      unawaited(navigator.push(account2Route));
-      await tester.pumpAndSettle();
-      check(pushedRoutes).deepEquals([account1Route, account2Route]);
-
-      await makeUnreadTopicInInbox(account1.id, 'topic in account1');
-      final findAccount1PageContent = find.text('topic in account1', skipOffstage: false);
-
-      await makeUnreadTopicInInbox(account2.id, 'topic in account2');
-      final findAccount2PageContent = find.text('topic in account2', skipOffstage: false);
-
-      final findLoadingPage = find.byType(LoadingPlaceholderPage, skipOffstage: false);
-
-      check(findAccount1PageContent).findsOne();
-      check(findLoadingPage).findsNothing();
-
-      final removedRoutes = <Route<dynamic>>[];
-      testNavObserver.onRemoved = (route, prevRoute) => removedRoutes.add(route);
-
-      final context = tester.element(find.byType(MaterialApp));
-      final future = logOutAccount(context, account1.id);
-      await tester.pump(TestGlobalStore.removeAccountDuration);
-      await future;
-      check(removedRoutes).single.identicalTo(account1Route);
-      check(findAccount1PageContent).findsNothing();
-      check(findLoadingPage).findsOne();
-
-      await tester.pump();
-      check(findAccount1PageContent).findsNothing();
-      check(findLoadingPage).findsNothing();
-      check(findAccount2PageContent).findsOne();
-    });
-  });
-
-  group('unregisterToken', () {
-    testWidgets('smoke, happy path', (tester) async {
-      await prepare(tester, ackedPushToken: '123');
-
-      final newConnection = separateConnection()
-        ..prepare(json: {'msg': '', 'result': 'success'});
-      final future = unregisterToken(testBinding.globalStore, eg.selfAccount.id);
-      await tester.pump(Duration.zero);
-      await future;
-      checkSingleUnregisterRequest(newConnection, expectedToken: '123');
-      check(newConnection.isOpen).isFalse();
-    }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS}));
-
-    testWidgets('fallback to current token if acked is missing', (tester) async {
-      await prepare(tester, ackedPushToken: null);
-      NotificationService.instance.token = ValueNotifier('asdf');
-
-      final newConnection = separateConnection()
-        ..prepare(json: {'msg': '', 'result': 'success'});
-      final future = unregisterToken(testBinding.globalStore, eg.selfAccount.id);
-      await tester.pump(Duration.zero);
-      await future;
-      checkSingleUnregisterRequest(newConnection, expectedToken: 'asdf');
-      check(newConnection.isOpen).isFalse();
-    });
-
-    testWidgets('no error if acked token and current token both missing', (tester) async {
-      await prepare(tester, ackedPushToken: null);
-      NotificationService.instance.token = ValueNotifier(null);
-
-      final newConnection = separateConnection();
-      final future = unregisterToken(testBinding.globalStore, eg.selfAccount.id);
-      await tester.pumpAndSettle();
-      await future;
-      check(newConnection.takeRequests()).isEmpty();
-    });
-
-    testWidgets('connection closed if request errors', (tester) async {
-      await prepare(tester, ackedPushToken: '123');
-
-      final newConnection = separateConnection()
-        ..prepare(exception: ZulipApiException(
-            httpStatus: 401,
-            code: 'UNAUTHORIZED',
-            data: {"result": "error", "msg": "Invalid API key", "code": "UNAUTHORIZED"},
-            routeName: 'removeEtcEtcToken',
-            message: 'Invalid API key',
-          ));
-      final future = unregisterToken(testBinding.globalStore, eg.selfAccount.id);
-      await tester.pump(Duration.zero);
-      await future;
-      checkSingleUnregisterRequest(newConnection, expectedToken: '123');
-      check(newConnection.isOpen).isFalse();
-    });
-  });
-
   group('markNarrowAsRead', () {
     testWidgets('smoke test on modern server', (tester) async {
       final narrow = TopicNarrow.ofMessage(eg.streamMessage());
diff --git a/test/widgets/home_test.dart b/test/widgets/home_test.dart
index 5bb789727f..1525355766 100644
--- a/test/widgets/home_test.dart
+++ b/test/widgets/home_test.dart
@@ -3,10 +3,10 @@ import 'package:flutter/material.dart';
 import 'package:flutter_checks/flutter_checks.dart';
 import 'package:flutter_test/flutter_test.dart';
 import 'package:zulip/api/model/events.dart';
+import 'package:zulip/model/actions.dart';
 import 'package:zulip/model/narrow.dart';
 import 'package:zulip/model/store.dart';
 import 'package:zulip/widgets/about_zulip.dart';
-import 'package:zulip/widgets/actions.dart';
 import 'package:zulip/widgets/app.dart';
 import 'package:zulip/widgets/app_bar.dart';
 import 'package:zulip/widgets/home.dart';
@@ -453,8 +453,7 @@ void main () {
     await tester.pump(); // wait for the loading page
     checkOnLoadingPage();

-    final element = tester.element(find.byType(MaterialApp));
-    final future = logOutAccount(element, eg.selfAccount.id);
+    final future = logOutAccount(testBinding.globalStore, eg.selfAccount.id);
     await tester.pump(TestGlobalStore.removeAccountDuration);
     await future;
     // No error expected from briefly not having
@@ -471,8 +470,7 @@ void main () {
     await tester.pump(); // wait for store
     checkOnHomePage(tester, expectedAccount: eg.selfAccount);

-    final element = tester.element(find.byType(HomePage));
-    final future = logOutAccount(element, eg.selfAccount.id);
+    final future = logOutAccount(testBinding.globalStore, eg.selfAccount.id);
     await tester.pump(TestGlobalStore.removeAccountDuration);
     await future;
     // No error expected from briefly not having
diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart
index b3cc208463..3f79f8cae6 100644
--- a/test/widgets/message_list_test.dart
+++ b/test/widgets/message_list_test.dart
@@ -7,11 +7,13 @@ import 'package:flutter/rendering.dart';
 import 'package:flutter_checks/flutter_checks.dart';
 import 'package:flutter_test/flutter_test.dart';
 import 'package:http/http.dart' as http;
+import 'package:zulip/api/exception.dart';
 import 'package:zulip/api/model/events.dart';
 import 'package:zulip/api/model/initial_snapshot.dart';
 import 'package:zulip/api/model/model.dart';
 import 'package:zulip/api/model/narrow.dart';
 import 'package:zulip/api/route/messages.dart';
+import 'package:zulip/model/actions.dart';
 import 'package:zulip/model/localizations.dart';
 import 'package:zulip/model/narrow.dart';
 import 'package:zulip/model/store.dart';
@@ -56,6 +58,7 @@ void main() {
     List<Subscription>? subscriptions,
     UnreadMessagesSnapshot? unreadMsgs,
     List<NavigatorObserver> navObservers = const [],
+    bool skipAssertAccountExists = false,
   }) async {
     TypingNotifier.debugEnable = false;
     addTearDown(TypingNotifier.debugReset);
@@ -77,6 +80,7 @@ void main() {
       eg.newestGetMessagesResult(foundOldest: foundOldest, messages: messages).toJson());

     await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id,
+      skipAssertAccountExists: skipAssertAccountExists,
       navigatorObservers: navObservers,
       child: MessageListPage(initNarrow: narrow)));

@@ -130,6 +134,47 @@ void main() {
       final state = MessageListPage.ancestorOf(tester.element(find.text("a message")));
       check(state.composeBoxController).isNull();
     });
+
+    testWidgets('dispose MessageListView when event queue expired', (tester) async {
+      final message = eg.streamMessage();
+      await setupMessageListPage(tester, messages: [message]);
+      final oldViewModel = store.debugMessageListViews.single;
+      final updateMachine = store.updateMachine!;
+      updateMachine.debugPauseLoop();
+      updateMachine.poll();
+
+      updateMachine.debugPrepareLoopError(ZulipApiException(
+        routeName: 'events', httpStatus: 400, code: 'BAD_EVENT_QUEUE_ID',
+        data: {'queue_id': updateMachine.queueId}, message: 'Bad event queue ID.'));
+      updateMachine.debugAdvanceLoop();
+      await tester.pump();
+      // Event queue has been replaced; but the [MessageList] hasn't been
+      // rebuilt yet.
+      final newStore = testBinding.globalStore.perAccountSync(eg.selfAccount.id)!;
+      check(connection.isOpen).isFalse(); // indicates that the old store has been disposed
+      check(store.debugMessageListViews).single.equals(oldViewModel);
+      check(newStore.debugMessageListViews).isEmpty();
+
+      (newStore.connection as FakeApiConnection).prepare(json: eg.newestGetMessagesResult(
+        foundOldest: true, messages: [message]).toJson());
+      await tester.pump();
+      await tester.pump(Duration.zero);
+      // As [MessageList] rebuilds, the old view model gets disposed and
+      // replaced with a fresh one.
+      check(store.debugMessageListViews).isEmpty();
+      check(newStore.debugMessageListViews).single.not((it) => it.equals(oldViewModel));
+    });
+
+    testWidgets('dispose MessageListView when logged out', (tester) async {
+      await setupMessageListPage(tester,
+        messages: [eg.streamMessage()], skipAssertAccountExists: true);
+      check(store.debugMessageListViews).single;
+
+      final future = logOutAccount(testBinding.globalStore, eg.selfAccount.id);
+      await tester.pump(TestGlobalStore.removeAccountDuration);
+      await future;
+      check(store.debugMessageListViews).isEmpty();
+    });
   });

   group('app bar', () {
diff --git a/test/widgets/store_test.dart b/test/widgets/store_test.dart
index e2d7821ae3..7e70f01a19 100644
--- a/test/widgets/store_test.dart
+++ b/test/widgets/store_test.dart
@@ -1,13 +1,22 @@
+import 'dart:async';
+
 import 'package:checks/checks.dart';
 import 'package:flutter/material.dart';
+import 'package:flutter_checks/flutter_checks.dart';
 import 'package:flutter_test/flutter_test.dart';
+import 'package:zulip/model/actions.dart';
 import 'package:zulip/model/store.dart';
+import 'package:zulip/widgets/app.dart';
+import 'package:zulip/widgets/inbox.dart';
+import 'package:zulip/widgets/page.dart';
 import 'package:zulip/widgets/store.dart';

 import '../flutter_checks.dart';
 import '../model/binding.dart';
 import '../example_data.dart' as eg;
 import '../model/store_checks.dart';
+import '../model/test_store.dart';
+import '../test_navigation.dart';

 /// A widget whose state uses [PerAccountStoreAwareStateMixin].
 class MyWidgetWithMixin extends StatefulWidget {
@@ -167,6 +176,71 @@ void main() {
     tester.widget(find.text('found store, account: ${eg.selfAccount.id}'));
   });

+  testWidgets("PerAccountStoreWidget.routeToRemoveOnLogout logged-out account's routes removed from nav; other accounts' remain", (tester) async {
+    Future<void> makeUnreadTopicInInbox(int accountId, String topic) async {
+      final stream = eg.stream();
+      final message = eg.streamMessage(stream: stream, topic: topic);
+      final store = await testBinding.globalStore.perAccount(accountId);
+      await store.addStream(stream);
+      await store.addSubscription(eg.subscription(stream));
+      await store.addMessage(message);
+      await tester.pump();
+    }
+
+    addTearDown(testBinding.reset);
+
+    final account1 = eg.account(id: 1, user: eg.user());
+    final account2 = eg.account(id: 2, user: eg.user());
+    await testBinding.globalStore.add(account1, eg.initialSnapshot());
+    await testBinding.globalStore.add(account2, eg.initialSnapshot());
+
+    final testNavObserver = TestNavigatorObserver();
+    await tester.pumpWidget(ZulipApp(navigatorObservers: [testNavObserver]));
+    await tester.pump();
+    final navigator = await ZulipApp.navigator;
+    navigator.popUntil((_) => false); // clear starting routes
+    await tester.pumpAndSettle();
+
+    final pushedRoutes = <Route<dynamic>>[];
+    testNavObserver.onPushed = (route, prevRoute) => pushedRoutes.add(route);
+    // TODO(#737): switch to a realistic setup:
+    //   https://github.com/zulip/zulip-flutter/pull/1076#discussion_r1874124363
+    final account1Route = MaterialAccountWidgetRoute(
+      accountId: account1.id, page: const InboxPageBody());
+    final account2Route = MaterialAccountWidgetRoute(
+      accountId: account2.id, page: const InboxPageBody());
+    unawaited(navigator.push(account1Route));
+    unawaited(navigator.push(account2Route));
+    await tester.pumpAndSettle();
+    check(pushedRoutes).deepEquals([account1Route, account2Route]);
+
+    await makeUnreadTopicInInbox(account1.id, 'topic in account1');
+    final findAccount1PageContent = find.text('topic in account1', skipOffstage: false);
+
+    await makeUnreadTopicInInbox(account2.id, 'topic in account2');
+    final findAccount2PageContent = find.text('topic in account2', skipOffstage: false);
+
+    final findLoadingPage = find.byType(LoadingPlaceholderPage, skipOffstage: false);
+
+    check(findAccount1PageContent).findsOne();
+    check(findLoadingPage).findsNothing();
+
+    final removedRoutes = <Route<dynamic>>[];
+    testNavObserver.onRemoved = (route, prevRoute) => removedRoutes.add(route);
+
+    final future = logOutAccount(testBinding.globalStore, account1.id);
+    await tester.pump(TestGlobalStore.removeAccountDuration);
+    await future;
+    check(removedRoutes).single.identicalTo(account1Route);
+    check(findAccount1PageContent).findsNothing();
+    check(findLoadingPage).findsOne();
+
+    await tester.pump();
+    check(findAccount1PageContent).findsNothing();
+    check(findLoadingPage).findsNothing();
+    check(findAccount2PageContent).findsOne();
+  });
+
   testWidgets('PerAccountStoreAwareStateMixin', (tester) async {
     final widgetWithMixinKey = GlobalKey<MyWidgetWithMixinState>();
     final accountId = eg.selfAccount.id;


anchors 2/n: Fix three sticky-header bugs

Fixes #1309.
Fixes #725. (which is the same underlying bug)

In addition to that live bug in the app (involving scrollOffsetCorrection, which we previously weren't handling), the last two commits in this branch fix a couple of bugs in the `sticky_header` library that were latent bugs in the app — they can only trigger once we start having two slivers share space in the list. That's a scenario we'll need as part of #80 / #82, as seen for example in #1169 (fyi @rajveermalviya).

Specifically, when both slivers are visible, the version in main
* will fail to show the sticky header for the top sliver;
* and will show a sticky header for the bottom sliver.

After this PR, there's one remaining bug I'm aware of that affects sticky headers in the presence of multiple slivers: ~~if the top sliver should show a header, but the header doesn't fit within the sliver's share of the viewport, then the header will get pushed off screen even if it has `allowOverflow` true. Concretely for the message list, this means that the last message in the top sliver will fail to share its recipient header with the first message in the bottom sliver, even if they're meant to share a recipient header.~~

**edit:** @chrisbobbe showed me the current symptom is a bit different from this. Rather than get pushed off screen, the header will have its bottom part covered up by the bottom sliver. The fix doesn't change, though.

I have a draft fix for that bug too, and I believe it works, but it still needs tests and a bit of other polishing-up. So it'll come as a separate PR later.

This is the long-awaited followup to #496, from a year ago this week. The two fixes here involving multiple slivers, as well as the upcoming fix coming in another PR, were all in the draft branch I had then. The scrollOffsetCorrection fix (which fixes the two live bugs) is new today, after I took a fresh look last week at some of the underlying framework code as part of trying to really understand what this `performLayout` method needs to do clearly enough to get to an implementation I'm confident is correct.

# Selected commit messages

#### 4b290452b sticky_header: Add example app

This is adapted lightly from an example app I made back in the first
week of the zulip-flutter project, 2022-12-23, as part of developing
the sticky_header library.

The example app was extremely helpful then for experimenting with
changes and seeing the effects visually and interactively, as well as
for print-debugging such experiments.  So let's get it into the tree.

The main reason I didn't send the example app back then is that it
was a whole stand-alone app tree under example/, complete with all
the stuff in android/ and ios/ and so on that `flutter create` spits
out for setting up a Flutter app.  That's pretty voluminous: well
over 100 different files totalling about 1.1MB on disk.  I did't
want to permanently burden the repo with all that, nor have to
maintain it all over time.

Happily, I realized today that we can skip that, and still have a
perfectly good example app, by reusing that infrastructure from the
actual Zulip app.  That way all we need is a Dart file with a `main`
function, corresponding to the old example's `lib/main.dart` which
was the only not-from-the-template code in the whole example app.

So here it is.  Other than moving the Dart file and discarding the
rest, the code I wrote back then has been updated to our current
formatting style; adjusted slightly for changes in Flutter's
Material widgets; and updated for changes I made to the
sticky_header API after that first week.

#### 3de58fe3e sticky_header [nfc]: Add asserts from studying possible child geometry; note a bug

The logic below -- particularly in the allowOverflow true case,
where it constructs a new SliverGeometry from scratch -- has been
implicitly relying on several of these facts already.  These
wouldn't be true of an arbitrary sliver child; but this sliver knows
what type of child it actually has, and they are true of that one.
So write down the specific assumptions we can take from that.

Reading through [RenderSliverList.performLayout] to see what it can
produce as the child geometry also made clear there's another case
that this method isn't currently correctly handling at all: the case
where scrollOffsetCorrection is non-null.  Filed an issue for that;
add a todo-comment for it.

#### 075a8cd79 sticky_header: Handle scrollOffsetCorrection

Fixes #1309.
Fixes #725.

This is necessary when scrolling back to the origin over items that
grew taller while off screen (and beyond the 250px of near-on-screen
cached items).  For example that can happen because a message was
edited, or because new messages came in that were taller than those
previously present.

#### de2006098 sticky_header: Fix _findChildAtEnd when viewport partly consumed already

In particular this will affect the upper sliver (with older messages)
in the message list, when we start using two slivers there in earnest.

#### 7583bb4b0 sticky_header: Avoid header at sliver/sliver boundary

Gap in message list

Gap in message list
There's a symptom I've seen happen just a couple of times, and just now while working on the sticky-header code (for #82) I realized what's causing it and how to fix it.

Repro recipe:
* Scroll up in history, a screenful or two past some message that you sent and can still edit.
* From a different client, edit that message to be much taller than it was.
* Scroll back down quickly, say by using the scroll-to-end button.

The result might look like this:
| Actual | Expected |
|--------|--------|
| ![Image](https://github.com/user-attachments/assets/9d2040fa-8071-4420-be2d-3a7e683b8e64) | ![Image](https://github.com/user-attachments/assets/e2b084b2-545a-4648-964c-049b95c1b432) |

Note the big gap above the "3" message; the recipient header at the very bottom of the list; and the recipient header out of order relative to the message (it has the wrong text for any later message).

Other times the gap can be smaller, like the height of a short message.

I saw a similar gap as part of #725. It's possible the cause of this issue was also the root cause of #725.

### Diagnosis

The cause of the issue is that the sticky-header implementation isn't properly handling one aspect of the sliver layout protocol. Specifically, when `_RenderSliverStickyHeaderListInner.performLayout` produces a `SliverGeometry` with `scrollOffsetCorrection` non-null, `_RenderSliverStickyHeaderList.performLayout` is swallowing that information instead of passing it on to its parent.

I'll send a fix as part of the sticky-headers PR I'm developing for #82.

Freeze in scrolling message list, null parentData.layoutOffset
Using the app today, I was scrolling in the message list and then it froze — stopped responding to my attempts to scroll.

I went and connected the phone to my desktop and looked at logcat. It shows the following stack trace:
```
Null check operator used on a null value
#0      _RenderSliverStickyHeaderListInner._findChildAtEnd (package:zulip/widgets/sticky_header.dart:719)
#1      _RenderSliverStickyHeaderListInner.performLayout (package:zulip/widgets/sticky_header.dart:740)
#2      RenderObject.layout (package:flutter/src/rendering/object.dart:2577)
#3      _RenderSliverStickyHeaderList.performLayout (package:zulip/widgets/sticky_header.dart:556)
#4      RenderObject.layout (package:flutter/src/rendering/object.dart:2577)
#5      RenderViewportBase.layoutChildSequence (package:flutter/src/rendering/viewport.dart:601)
#6      RenderViewport._attemptLayout (package:flutter/src/rendering/viewport.dart:1497)
#7      RenderViewport.performLayout (package:flutter/src/rendering/viewport.dart:1427)
#8      RenderObject._layoutWithoutResize (package:flutter/src/rendering/object.dart:2416)
#9      PipelineOwner.flushLayout (package:flutter/src/rendering/object.dart:1052)
#10     PipelineOwner.flushLayout (package:flutter/src/rendering/object.dart:1065)
#11     RendererBinding.drawFrame (package:flutter/src/rendering/binding.dart:602)
#12     WidgetsBinding.drawFrame (package:flutter/src/widgets/binding.dart:1164)
#13     RendererBinding._handlePersistentFrameCallback (package:flutter/src/rendering/binding.dart:468)
#14     SchedulerBinding._invokeFrameCallback (package:flutter/src/scheduler/binding.dart:1392)
#15     SchedulerBinding.handleDrawFrame (package:flutter/src/scheduler/binding.dart:1313)
#16     SchedulerBinding._handleDrawFrame (package:flutter/src/scheduler/binding.dart:1171)
#17     _invoke (dart:ui/hooks.dart:312)
#18     PlatformDispatcher._drawFrame (dart:ui/platform_dispatcher.dart:419)
#19     _drawFrame (dart:ui/hooks.dart:283)
```

Then over time after that there's this error, again and again many times:
```
Another exception was thrown: Instance of 'DiagnosticsProperty<void>'
```
There's a fresh burst of those any time I touch the screen and try to scroll, or when

---

The stack trace shows the exception is from our `sticky_header` library. The null value in question seems to be the `parentData.layoutOffset` below, quoting some code ending on the line that has the `!` that blew up:
```dart
    RenderBox? child;
    for (child = lastChild; ; child = childBefore(child)) {
      if (child == null) {
        // Ran out of children.
        return null;
      }
      final parentData = child.parentData! as SliverMultiBoxAdaptorParentData;
      assert(parentData.layoutOffset != null);
      if (endOffset > parentData.layoutOffset! + child.size.onAxis(constraints.axis)) {
```

So this function is assuming that the children have their `parentData` all set up, including `layoutOffset`, but there's a child that doesn't have that. I haven't debugged further.

---

Further observations from the context and symptoms:
* The app had been open a while earlier, showing the message list in question (namely "Combined feed"). Then it was in the background and I re-opened it.
  * Probably it was over 10 minutes, so the app would have re-loaded data from scratch.
  * It's possible that the immediate trigger for the bug was when the new data arrived and we swapped out the PerAccountStore. That causes the message list to go back to being empty and re-fetch its messages from scratch (#464).
* When the freeze happened, I was scrolled up a few screenfuls in the history.
* The visible state on the screen was a bit funny-looking: mostly full of messages as normal, but with a gray gap at the bottom.
* The app didn't crash or completely stop. As new messages subsequently come in, the message list keeps updating to show new messages. (At some point it jumped to the bottom of the history.)
  * The visible state continues to look funny with variations on the original: mostly full of messages, but with a gray gap somewhere between them.
  * Specifically at the moment it looks like one of the recipient headers, the one for the 4th out of 5 messages currently visible, is replaced with a gray gap that's about the height the recipient header should be. (I compared with the same narrow on web to see that there's no messages missing.)
  * At another time there was a much wider gray gap, immediately after a recipient header. So perhaps a message itself was being replaced with a gray gap. … OK, and now I'm seeing that again, and indeed it's one message missing. … And now again, and it's a message missing which isn't immediately after a recipient header (it's between two messages).
* Although it's updating in response to server events, nothing I do when I try to interact with the message list has any effect: not scrolling (as mentioned), nor touching a link, nor a recipient header, nor an image. They all just trigger another `Another exception was thrown: Instance of 'DiagnosticsProperty<void>'` in the log.
* After all those observations, I touched the back button at top left of the app, and it worked immediately. Then I navigated back into "Combined feed", and it's working normally.

So my best guess for how to potentially reproduce the issue is:
* Scroll up a few screenfuls in history.
* Have the `PerAccountState` get swapped out for a new one. (To do this without waiting 10 minutes, one can fiddle with the retry logic in `UpdateMachine.poll`.)

---

(Meanwhile the "Instance of 'DiagnosticsProperty<void>'" error messages are a bit of a mystery. Those seem like there's probably a bug in error-reporting somewhere, which would be good to fix in order to better enable debugging other bugs, like this one.)

Moving this to the post-launch milestone because it's only ever happened the one time as far as I know, and it's been a couple of months since then.

If we see it another time or two, or hear a user report of it, we can bump it back up in priority.

flutter test test/widgets/sticky_header_test.dart --reporter expanded 2>&1

diff --git a/lib/example/sticky_header.dart b/lib/example/sticky_header.dart
new file mode 100644
index 0000000000..3fa2185c1a
--- /dev/null
+++ b/lib/example/sticky_header.dart
@@ -0,0 +1,428 @@
+/// Example app for exercising the sticky_header library.
+///
+/// This is useful when developing changes to [StickyHeaderListView],
+/// [SliverStickyHeaderList], and [StickyHeaderItem],
+/// for experimenting visually with changes.
+///
+/// To use this example app, run the command:
+///     flutter run lib/example/sticky_header.dart
+/// or run this file from your IDE.
+///
+/// One inconvenience: this means the example app will use the same app ID
+/// as the actual Zulip app.  The app's data remains untouched, though, so
+/// a normal `flutter run` will put things back as they were.
+/// This inconvenience could be fixed with a bit more work: we'd use
+/// `flutter run --flavor`, and define an Android flavor in build.gradle
+/// and an Xcode scheme in the iOS build config
+/// so as to set the app ID differently.
+library;
+
+import 'package:flutter/material.dart';
+
+import '../widgets/sticky_header.dart';
+
+/// Example page using [StickyHeaderListView] and [StickyHeaderItem] in a
+/// vertically-scrolling list.
+class ExampleVertical extends StatelessWidget {
+  ExampleVertical({
+    super.key,
+    required this.title,
+    this.reverse = false,
+    this.headerDirection = AxisDirection.down,
+  }) : assert(axisDirectionToAxis(headerDirection) == Axis.vertical);
+
+  final String title;
+  final bool reverse;
+  final AxisDirection headerDirection;
+
+  @override
+  Widget build(BuildContext context) {
+    final headerAtBottom = axisDirectionIsReversed(headerDirection);
+
+    const numSections = 100;
+    const numPerSection = 10;
+    return Scaffold(
+      appBar: AppBar(title: Text(title)),
+
+      // Invoke StickyHeaderListView the same way you'd invoke ListView.
+      // The constructor takes the same arguments.
+      body: StickyHeaderListView.separated(
+        reverse: reverse,
+        reverseHeader: headerAtBottom,
+        itemCount: numSections,
+        separatorBuilder: (context, i) => const SizedBox.shrink(),
+
+        // Use StickyHeaderItem as an item widget in the ListView.
+        // A header will float over the item as needed in order to
+        // "stick" at the edge of the viewport.
+        //
+        // You can also include non-StickyHeaderItem items in the list.
+        // They'll behave just like in a plain ListView.
+        //
+        // Each StickyHeaderItem needs to be an item directly in the list, not
+        // wrapped inside other widgets that affect layout, in order to get
+        // the sticky-header behavior.
+        itemBuilder: (context, i) => StickyHeaderItem(
+          header: WideHeader(i: i),
+          child: Column(
+            verticalDirection: headerAtBottom
+              ? VerticalDirection.up : VerticalDirection.down,
+            children: List.generate(
+              numPerSection + 1, (j) {
+                if (j == 0) return WideHeader(i: i);
+                return WideItem(i: i, j: j-1);
+              })))));
+  }
+}
+
+/// Example page using [StickyHeaderListView] and [StickyHeaderItem] in a
+/// horizontally-scrolling list.
+class ExampleHorizontal extends StatelessWidget {
+  ExampleHorizontal({
+    super.key,
+    required this.title,
+    this.reverse = false,
+    required this.headerDirection,
+  }) : assert(axisDirectionToAxis(headerDirection) == Axis.horizontal);
+
+  final String title;
+  final bool reverse;
+  final AxisDirection headerDirection;
+
+  @override
+  Widget build(BuildContext context) {
+    final headerAtRight = axisDirectionIsReversed(headerDirection);
+    const numSections = 100;
+    const numPerSection = 10;
+    return Scaffold(
+      appBar: AppBar(title: Text(title)),
+      body: StickyHeaderListView.separated(
+
+        // StickyHeaderListView and StickyHeaderItem also work for horizontal
+        // scrolling.  Pass `scrollDirection: Axis.horizontal` to the
+        // StickyHeaderListView constructor, just like for ListView.
+        scrollDirection: Axis.horizontal,
+        reverse: reverse,
+        reverseHeader: headerAtRight,
+        itemCount: numSections,
+        separatorBuilder: (context, i) => const SizedBox.shrink(),
+        itemBuilder: (context, i) => StickyHeaderItem(
+          header: TallHeader(i: i),
+          child: Row(
+            textDirection: headerAtRight ? TextDirection.rtl : TextDirection.ltr,
+            children: List.generate(
+              numPerSection + 1,
+              (j) {
+                if (j == 0) return TallHeader(i: i);
+                return TallItem(i: i, j: j-1, numPerSection: numPerSection);
+              })))));
+  }
+}
+
+/// An experimental example approximating the Zulip message list.
+class ExampleVerticalDouble extends StatelessWidget {
+  const ExampleVerticalDouble({
+    super.key,
+    required this.title,
+    // this.reverse = false,
+    // this.headerDirection = AxisDirection.down,
+  }); // : assert(axisDirectionToAxis(headerDirection) == Axis.vertical);
+
+  final String title;
+  // final bool reverse;
+  // final AxisDirection headerDirection;
+
+  @override
+  Widget build(BuildContext context) {
+    const centerSliverKey = ValueKey('center sliver');
+    const numSections = 100;
+    const numBottomSections = 2;
+    const numPerSection = 10;
+    return Scaffold(
+      appBar: AppBar(title: Text(title)),
+      body: CustomScrollView(
+        semanticChildCount: numSections,
+        anchor: 0.5,
+        center: centerSliverKey,
+        slivers: [
+          SliverStickyHeaderList(
+            headerPlacement: HeaderPlacement.scrollingStart,
+            delegate: SliverChildBuilderDelegate(
+              childCount: numSections - numBottomSections,
+              (context, i) {
+                final ii = i + numBottomSections;
+                return StickyHeaderItem(
+                  header: WideHeader(i: ii),
+                  child: Column(
+                    children: List.generate(numPerSection + 1, (j) {
+                      if (j == 0) return WideHeader(i: ii);
+                      return WideItem(i: ii, j: j-1);
+                    })));
+              })),
+          SliverStickyHeaderList(
+            key: centerSliverKey,
+            headerPlacement: HeaderPlacement.scrollingStart,
+            delegate: SliverChildBuilderDelegate(
+              childCount: numBottomSections,
+              (context, i) {
+                final ii = numBottomSections - 1 - i;
+                return StickyHeaderItem(
+                  header: WideHeader(i: ii),
+                  child: Column(
+                    children: List.generate(numPerSection + 1, (j) {
+                        if (j == 0) return WideHeader(i: ii);
+                        return WideItem(i: ii, j: j-1);
+                      })));
+              })),
+        ]));
+  }
+}
+
+////////////////////////////////////////////////////////////////////////////
+//
+// That's it!
+//
+// The rest of this file is boring infrastructure for navigating to the
+// different examples, and for having some content to put inside them.
+//
+////////////////////////////////////////////////////////////////////////////
+
+class WideHeader extends StatelessWidget {
+  const WideHeader({super.key, required this.i});
+
+  final int i;
+
+  @override
+  Widget build(BuildContext context) {
+    return Material(
+      color: Theme.of(context).colorScheme.primaryContainer,
+      child: ListTile(
+        title: Text("Section ${i + 1}",
+          style: TextStyle(
+            color: Theme.of(context).colorScheme.onPrimaryContainer))));
+  }
+}
+
+class WideItem extends StatelessWidget {
+  const WideItem({super.key, required this.i, required this.j});
+
+  final int i;
+  final int j;
+
+  @override
+  Widget build(BuildContext context) {
+    return ListTile(title: Text("Item ${i + 1}.${j + 1}"));
+  }
+}
+
+class TallHeader extends StatelessWidget {
+  const TallHeader({super.key, required this.i});
+
+  final int i;
+
+  @override
+  Widget build(BuildContext context) {
+    final contents = Column(children: [
+      Text("Section ${i + 1}",
+        style: TextStyle(
+          fontWeight: FontWeight.bold,
+          color: Theme.of(context).colorScheme.onPrimaryContainer)),
+      const SizedBox(height: 8),
+      const Expanded(child: SizedBox.shrink()),
+      const SizedBox(height: 8),
+      const Text("end"),
+    ]);
+
+    return Container(
+      alignment: Alignment.center,
+      child: Card(
+        color: Theme.of(context).colorScheme.primaryContainer,
+        child: Padding(padding: const EdgeInsets.all(8), child: contents)));
+  }
+}
+
+class TallItem extends StatelessWidget {
+  const TallItem({super.key,
+    required this.i,
+    required this.j,
+    required this.numPerSection,
+  });
+
+  final int i;
+  final int j;
+  final int numPerSection;
+
+  @override
+  Widget build(BuildContext context) {
+    final heightFactor = (1 + j) / numPerSection;
+
+    final contents = Column(children: [
+      Text("Item ${i + 1}.${j + 1}"),
+      const SizedBox(height: 8),
+      Expanded(
+        child: FractionallySizedBox(
+          heightFactor: heightFactor,
+          child: ColoredBox(
+            color: Theme.of(context).colorScheme.secondary,
+            child: const SizedBox(width: 4)))),
+      const SizedBox(height: 8),
+      const Text("end"),
+    ]);
+
+    return Container(
+      alignment: Alignment.center,
+      child: Card(
+        child: Padding(padding: const EdgeInsets.all(8), child: contents)));
+  }
+}
+
+enum _ExampleType { vertical, horizontal }
+
+class MainPage extends StatelessWidget {
+  const MainPage({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    final verticalItems = [
+      _buildItem(context, _ExampleType.vertical,
+        primary: true,
+        title: 'Scroll down, headers at top (a standard list)',
+        headerDirection: AxisDirection.down),
+      _buildItem(context, _ExampleType.vertical,
+        title: 'Scroll up, headers at top',
+        reverse: true,
+        headerDirection: AxisDirection.down),
+      _buildItem(context, _ExampleType.vertical,
+        title: 'Scroll down, headers at bottom',
+        headerDirection: AxisDirection.up),
+      _buildItem(context, _ExampleType.vertical,
+        title: 'Scroll up, headers at bottom',
+        reverse: true,
+        headerDirection: AxisDirection.up),
+    ];
+    final horizontalItems = [
+      _buildItem(context, _ExampleType.horizontal,
+        title: 'Scroll right, headers at left',
+        headerDirection: AxisDirection.right),
+      _buildItem(context, _ExampleType.horizontal,
+        title: 'Scroll left, headers at left',
+        reverse: true,
+        headerDirection: AxisDirection.right),
+      _buildItem(context, _ExampleType.horizontal,
+        title: 'Scroll right, headers at right',
+        headerDirection: AxisDirection.left),
+      _buildItem(context, _ExampleType.horizontal,
+        title: 'Scroll left, headers at right',
+        reverse: true,
+        headerDirection: AxisDirection.left),
+    ];
+    final otherItems = [
+      _buildButton(context,
+        title: 'Double slivers',
+        page: ExampleVerticalDouble(title: 'Double slivers')),
+    ];
+    return Scaffold(
+        appBar: AppBar(title: const Text('Sticky Headers example')),
+        body: CustomScrollView(slivers: [
+          SliverToBoxAdapter(
+            child: Padding(
+              padding: const EdgeInsets.only(top: 24),
+              child: Center(
+                child: Text("Vertical lists",
+                  style: Theme.of(context).textTheme.headlineMedium)))),
+          SliverPadding(
+            padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
+            sliver: SliverGrid.count(
+              childAspectRatio: 2,
+              crossAxisCount: 2,
+              children: verticalItems)),
+          SliverToBoxAdapter(
+            child: Padding(
+              padding: const EdgeInsets.only(top: 24),
+              child: Center(
+                child: Text("Horizontal lists",
+                  style: Theme.of(context).textTheme.headlineMedium)))),
+          SliverPadding(
+            padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
+            sliver: SliverGrid.count(
+              childAspectRatio: 2,
+              crossAxisCount: 2,
+              children: horizontalItems)),
+          SliverToBoxAdapter(
+            child: Padding(
+              padding: const EdgeInsets.only(top: 24),
+              child: Center(
+                child: Text("Other examples",
+                  style: Theme.of(context).textTheme.headlineMedium)))),
+          SliverPadding(
+            padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
+            sliver: SliverGrid.count(
+              childAspectRatio: 2,
+              crossAxisCount: 2,
+              children: otherItems)),
+        ]));
+  }
+
+  Widget _buildItem(BuildContext context, _ExampleType exampleType, {
+    required String title,
+    bool reverse = false,
+    required AxisDirection headerDirection,
+    bool primary = false,
+  }) {
+    Widget page;
+    switch (exampleType) {
+      case _ExampleType.vertical:
+        page = ExampleVertical(
+          title: title, reverse: reverse, headerDirection: headerDirection);
+        break;
+      case _ExampleType.horizontal:
+        page = ExampleHorizontal(
+          title: title, reverse: reverse, headerDirection: headerDirection);
+        break;
+    }
+    return _buildButton(context, title: title, page: page);
+  }
+
+  Widget _buildButton(BuildContext context, {
+    bool primary = false,
+    required String title,
+    required Widget page,
+  }) {
+    var label = Text(title,
+      textAlign: TextAlign.center,
+      style: TextStyle(
+        inherit: true,
+        fontSize: Theme.of(context).textTheme.titleMedium?.fontSize));
+    var buttonStyle = primary
+      ? null
+      : ElevatedButton.styleFrom(
+          foregroundColor: Theme.of(context).colorScheme.onSecondary,
+          backgroundColor: Theme.of(context).colorScheme.secondary);
+    return Container(
+      padding: const EdgeInsets.all(16),
+      child: ElevatedButton(
+        style: buttonStyle,
+        onPressed: () => Navigator.of(context)
+          .push(MaterialPageRoute<void>(builder: (_) => page)),
+        child: label));
+  }
+}
+
+class ExampleApp extends StatelessWidget {
+  const ExampleApp({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    return MaterialApp(
+      title: 'Sticky Headers example',
+      theme: ThemeData(
+        colorScheme:
+          ColorScheme.fromSeed(seedColor: const Color(0xff3366cc))),
+      home: const MainPage(),
+    );
+  }
+}
+
+void main() {
+  runApp(const ExampleApp());
+}
diff --git a/lib/widgets/sticky_header.dart b/lib/widgets/sticky_header.dart
index 356a056094..24eeb8d518 100644
--- a/lib/widgets/sticky_header.dart
+++ b/lib/widgets/sticky_header.dart
@@ -489,6 +489,11 @@ class _RenderSliverStickyHeaderList extends RenderSliver with RenderSliverHelper
     if (_header != null) adoptChild(_header!);
   }

+  /// This sliver's child sliver, a modified [RenderSliverList].
+  ///
+  /// The child manages the items in the list (deferring to [RenderSliverList]);
+  /// and identifies which list item, if any, should be consulted
+  /// for a sticky header.
   _RenderSliverStickyHeaderListInner? get child => _child;
   _RenderSliverStickyHeaderListInner? _child;
   set child(_RenderSliverStickyHeaderListInner? value) {
@@ -552,44 +557,74 @@ class _RenderSliverStickyHeaderList extends RenderSliver with RenderSliverHelper

   @override
   void performLayout() {
+    // First, lay out the child sliver.  This does all the normal work of
+    // [RenderSliverList], then calls [_rebuildHeader] on this sliver
+    // so that [header] and [_headerEndBound] are up to date.
     assert(child != null);
     child!.layout(constraints, parentUsesSize: true);
     SliverGeometry geometry = child!.geometry!;

+    if (geometry.scrollOffsetCorrection != null) {
+      this.geometry = geometry;
+      return;
+    }
+
+    // We assume [child]'s geometry is free of certain complications.
+    // Probably most or all of these *could* be handled if necessary, just at
+    // the cost of further complicating this code.  Fortunately they aren't,
+    // because [RenderSliverList.performLayout] never has these complications.
+    assert(geometry.paintOrigin == 0);
+    assert(geometry.layoutExtent == geometry.paintExtent);
+    assert(geometry.hitTestExtent == geometry.paintExtent);
+    assert(geometry.visible == (geometry.paintExtent > 0));
+    assert(geometry.maxScrollObstructionExtent == 0);
+    assert(geometry.crossAxisExtent == null);
+    final childExtent = geometry.layoutExtent;
+
     if (header != null) {
       header!.layout(constraints.asBoxConstraints(), parentUsesSize: true);
-
       final headerExtent = header!.size.onAxis(constraints.axis);
+
       final double headerOffset;
       if (_headerEndBound == null) {
-        final paintedHeaderSize = calculatePaintOffset(constraints, from: 0, to: headerExtent);
-        final cacheExtent = calculateCacheOffset(constraints, from: 0, to: headerExtent);
-
-        assert(0 <= paintedHeaderSize && paintedHeaderSize.isFinite);
+        // The header's item has [StickyHeaderItem.allowOverflow] true.
+        // Show the header in full, with one edge at the edge of the viewport,
+        // even if the (visible part of the) item is smaller than the header,
+        // and even if the whole child sliver is smaller than the header.

+        final paintedHeaderSize = calculatePaintOffset(constraints, from: 0, to: headerExtent);
         geometry = SliverGeometry( // TODO review interaction with other slivers
           scrollExtent: geometry.scrollExtent,
-          layoutExtent: geometry.layoutExtent,
-          paintExtent: math.max(geometry.paintExtent, paintedHeaderSize),
-          cacheExtent: math.max(geometry.cacheExtent, cacheExtent),
+          layoutExtent: childExtent,
+          paintExtent: math.max(childExtent, paintedHeaderSize),
           maxPaintExtent: math.max(geometry.maxPaintExtent, headerExtent),
-          hitTestExtent: math.max(geometry.hitTestExtent, paintedHeaderSize),
           hasVisualOverflow: geometry.hasVisualOverflow
             || headerExtent > constraints.remainingPaintExtent,
+
+          // The cache extent is an extension of layout, not paint; it controls
+          // where the next sliver should start laying out content.  (See
+          // [SliverConstraints.remainingCacheExtent].)  The header isn't meant
+          // to affect where the next sliver gets laid out, so it shouldn't
+          // affect the cache extent.
+          cacheExtent: geometry.cacheExtent,
         );

         headerOffset = _headerAtCoordinateEnd()
-          ? geometry.layoutExtent - headerExtent
+          ? childExtent - headerExtent
           : 0.0;
       } else {
+        // The header's item has [StickyHeaderItem.allowOverflow] false.
+        // Keep the header within the item, pushing the header partly out of
+        // the viewport if the item's visible part is smaller than the header.
+
         // The limiting edge of the header's item,
         // in the outer, non-scrolling coordinates.
         final endBoundAbsolute = axisDirectionIsReversed(constraints.growthAxisDirection)
-          ? geometry.layoutExtent - (_headerEndBound! - constraints.scrollOffset)
+          ? childExtent - (_headerEndBound! - constraints.scrollOffset)
           : _headerEndBound! - constraints.scrollOffset;

         headerOffset = _headerAtCoordinateEnd()
-          ? math.max(geometry.layoutExtent - headerExtent, endBoundAbsolute)
+          ? math.max(childExtent - headerExtent, endBoundAbsolute)
           : math.min(0.0, endBoundAbsolute - headerExtent);
       }

@@ -706,7 +741,10 @@ class _RenderSliverStickyHeaderListInner extends RenderSliverList {
   ///
   /// This means (child start) < (viewport end) <= (child end).
   RenderBox? _findChildAtEnd() {
-    final endOffset = constraints.scrollOffset + constraints.viewportMainAxisExtent;
+    /// The end of the visible area available to this sliver,
+    /// in this sliver's "scroll offset" coordinates.
+    final endOffset = constraints.scrollOffset
+      + constraints.remainingPaintExtent;

     RenderBox? child;
     for (child = lastChild; ; child = childBefore(child)) {
@@ -736,10 +774,23 @@ class _RenderSliverStickyHeaderListInner extends RenderSliverList {

     final RenderBox? child;
     switch (widget.headerPlacement._byGrowth(constraints.growthDirection)) {
+      case _HeaderGrowthPlacement.growthStart:
+        if (constraints.remainingPaintExtent < constraints.viewportMainAxisExtent) {
+          // Part of the viewport is occupied already by other slivers.  The way
+          // a RenderViewport does layout means that the already-occupied part is
+          // the part that's before this sliver in the growth direction.
+          // Which means that's the place where the header would go.
+          child = null;
+        } else {
+          child = _findChildAtStart();
+        }
       case _HeaderGrowthPlacement.growthEnd:
+        // The edge this sliver wants to place a header at is the one where
+        // this sliver is free to run all the way to the viewport's edge; any
+        // further slivers in that direction will be laid out after this one.
+        // So if this sliver placed a child there, it's at the edge of the
+        // whole viewport and should determine a header.
         child = _findChildAtEnd();
-      case _HeaderGrowthPlacement.growthStart:
-        child = _findChildAtStart();
     }

     (parent! as _RenderSliverStickyHeaderList)._rebuildHeader(child);

diff --git a/test/widgets/sticky_header_test.dart b/test/widgets/sticky_header_test.dart
index fc363c71e8..c283652ae1 100644
--- a/test/widgets/sticky_header_test.dart
+++ b/test/widgets/sticky_header_test.dart
@@ -103,6 +103,116 @@ void main() {
       }
     }
   }
+
+  testWidgets('sticky headers: propagate scrollOffsetCorrection properly', (tester) async {
+    Widget page(Widget Function(BuildContext, int) itemBuilder) {
+      return Directionality(textDirection: TextDirection.ltr,
+        child: StickyHeaderListView.builder(
+          cacheExtent: 0,
+          itemCount: 10, itemBuilder: itemBuilder));
+    }
+
+    await tester.pumpWidget(page((context, i) =>
+      StickyHeaderItem(
+        allowOverflow: true,
+        header: _Header(i, height: 40),
+        child: _Item(i, height: 200))));
+    check(tester.getTopLeft(find.text("Item 2"))).equals(Offset(0, 400));
+
+    // Scroll down (dragging up) to get item 0 off screen.
+    await tester.drag(find.text("Item 2"), Offset(0, -300));
+    await tester.pump();
+    check(tester.getTopLeft(find.text("Item 2"))).equals(Offset(0, 100));
+
+    // Make the off-screen item 0 taller, so scrolling back up will underflow.
+    await tester.pumpWidget(page((context, i) =>
+      StickyHeaderItem(
+        allowOverflow: true,
+        header: _Header(i, height: 40),
+        child: _Item(i, height: i == 0 ? 400 : 200))));
+    // Confirm the change in item 0's height hasn't already been applied,
+    // as it would if the item were within the viewport or its cache area.
+    check(tester.getTopLeft(find.text("Item 2"))).equals(Offset(0, 100));
+
+    // Scroll back up (dragging down).  This will cause a correction as the list
+    // discovers that moving 300px up doesn't reach the start anymore.
+    await tester.drag(find.text("Item 2"), Offset(0, 300));
+
+    // As a bonus, mark one of the already-visible items as needing layout.
+    // (In a real app, this would typically happen because some state changed.)
+    tester.firstElement(find.widgetWithText(SizedBox, "Item 2"))
+      .renderObject!.markNeedsLayout();
+
+    // If scrollOffsetCorrection doesn't get propagated to the viewport, this
+    // pump will record an exception (causing the test to fail at the end)
+    // because the marked item won't get laid out.
+    await tester.pump();
+    check(tester.getTopLeft(find.text("Item 2"))).equals(Offset(0, 400));
+
+    // Moreover if scrollOffsetCorrection doesn't get propagated, this item
+    // will get placed at zero rather than properly extend up off screen.
+    check(tester.getTopLeft(find.text("Item 0"))).equals(Offset(0, -200));
+  });
+
+  testWidgets('sliver only part of viewport, header at end', (tester) async {
+    const centerKey = ValueKey('center');
+    final controller = ScrollController();
+    await tester.pumpWidget(Directionality(textDirection: TextDirection.ltr,
+      child: CustomScrollView(
+        controller: controller,
+        anchor: 0.5,
+        center: centerKey,
+        slivers: [
+          SliverStickyHeaderList(
+            headerPlacement: HeaderPlacement.scrollingStart,
+            delegate: SliverChildListDelegate(
+              List.generate(100, (i) => StickyHeaderItem(
+                header: _Header(99 - i, height: 20),
+                child: _Item(99 - i, height: 100))))),
+          SliverStickyHeaderList(
+            key: centerKey,
+            headerPlacement: HeaderPlacement.scrollingStart,
+            delegate: SliverChildListDelegate(
+              List.generate(100, (i) => StickyHeaderItem(
+                header: _Header(100 + i, height: 20),
+                child: _Item(100 + i, height: 100))))),
+        ])));
+
+    final overallSize = tester.getSize(find.byType(CustomScrollView));
+    final extent = overallSize.onAxis(Axis.vertical);
+    assert(extent == 600);
+
+    void checkState(int index, {required double item, required double header}) {
+      final itemElement = tester.firstElement(find.byElementPredicate((element) {
+        if (element.widget is! _Item) return false;
+        final renderObject = element.renderObject as RenderBox;
+        return (renderObject.size.contains(renderObject.globalToLocal(
+            Offset(overallSize.width / 2, 1)
+        )));
+      }));
+      final itemWidget = itemElement.widget as _Item;
+      check(itemWidget.index).equals(index);
+      check(_headerIndex(tester)).equals(index);
+      check((itemElement.renderObject as RenderBox).localToGlobal(Offset(0, 0)))
+        .equals(Offset(0, item));
+      check(tester.getTopLeft(find.byType(_Header))).equals(Offset(0, header));
+    }
+
+    check(controller.offset).equals(0);
+    checkState( 97, item:   0, header:   0);
+
+    controller.jumpTo(-5);
+    await tester.pump();
+    checkState( 96, item: -95, header: -15);
+
+    controller.jumpTo(-600);
+    await tester.pump();
+    checkState( 91, item:   0, header:   0);
+
+    controller.jumpTo(600);
+    await tester.pump();
+    checkState(103, item:   0, header:   0);
+  });
 }

 Future<void> _checkSequence(
@@ -174,7 +284,6 @@ Future<void> _checkSequence(
     final expectedHeaderIndex = first
       ? (scrollOffset / 100).floor()
       : (extent ~/ 100 - 1) + (scrollOffset / 100).ceil();
-    // print("$scrollOffset, $extent, $expectedHeaderIndex");
     check(tester.widget<_Item>(itemFinder).index).equals(expectedHeaderIndex);
     check(_headerIndex(tester)).equals(expectedHeaderIndex);