diff --git a/lib/providers/collab/strategy_op_queue_provider.dart b/lib/providers/collab/strategy_op_queue_provider.dart index 1fe501bf..928d9099 100644 --- a/lib/providers/collab/strategy_op_queue_provider.dart +++ b/lib/providers/collab/strategy_op_queue_provider.dart @@ -1275,6 +1275,7 @@ class StrategyOpQueueNotifier extends Notifier { state.successorByEntityKey, ); var changed = false; + _storingMany = true; try { for (final key in keys) { if (_awaitingRemoteAdoption.contains(key)) continue; @@ -1589,9 +1590,11 @@ class StrategyOpQueueNotifier extends Notifier { changed = true; } } catch (error, stackTrace) { + _storedMany(); _recordPersistenceFailure(error, stackTrace); return; } + _storedMany(); if (!changed) return; _pruneCanvasOpIds(); final attentionMessage = _loadedAttentionMessage( @@ -3675,41 +3678,31 @@ class StrategyOpQueueNotifier extends Notifier { return Uri.decodeComponent(parts[1]); } + /// Publishes the outbox as its records now stand: the account's summary + /// together with the open strategy's waiting work. Never one without the + /// other: a state that counted a newly stored change in the summary while + /// its waiting work still left it out read as nothing left to send, and + /// the canvas was redrawn from the server over the edit just stored. + /// + /// While a page's work is being stored record by record ([_storingMany]), + /// it waits until all of it is, so a page of many items publishes once. void _publishAccountSummary() { - if (_isDisposed) return; - final summary = _accountSummary(state.accountId); - final hasDurabilityFailure = _hasDurabilityFailureForCurrentAccount; - if (_sameAccountSummary(state.accountOutbox, summary) && - state.hasDurabilityFailure == hasDurabilityFailure) { + if (_storingMany) { + _publishWaits = true; return; } - state = state.copyWith( - accountOutbox: summary, - hasDurabilityFailure: hasDurabilityFailure, - ); + _refreshActiveQueueView(); } - bool _sameAccountSummary( - AccountStrategyOutboxSummary left, - AccountStrategyOutboxSummary right, - ) { - if (left.accountId != right.accountId || - left.strategies.length != right.strategies.length) { - return false; - } - for (final entry in left.strategies.entries) { - final other = right.strategies[entry.key]; - if (other == null || - other.queuedCount != entry.value.queuedCount || - other.inFlightCount != entry.value.inFlightCount || - other.pausedCount != entry.value.pausedCount || - other.attentionCount != entry.value.attentionCount || - other.successorCount != entry.value.successorCount || - other.reason != entry.value.reason) { - return false; - } - } - return true; + bool _storingMany = false; + bool _publishWaits = false; + + /// Ends [_storingMany], publishing if a publish waited for it. + void _storedMany() { + _storingMany = false; + if (!_publishWaits) return; + _publishWaits = false; + _refreshActiveQueueView(); } Future _serializeWrite(Future Function() action) { diff --git a/test/strategy_page_session_provider_test.dart b/test/strategy_page_session_provider_test.dart index 2dfb1f4f..12de7c26 100644 --- a/test/strategy_page_session_provider_test.dart +++ b/test/strategy_page_session_provider_test.dart @@ -9997,6 +9997,54 @@ void main() { }); }); + test( + "an edit made offline lands when a teammate's edit to another item " + 'arrived first', () async { + final page = _page('page-1', 0); + server = _FakeServer(page.publicId, elements: [ + _textElement(page.publicId, 'a', 'alpha', worldSized: true), + _textElement(page.publicId, 'c', 'charlie', + sortIndex: 1, worldSized: true), + ]) + ..mergesByField = true; + final (container, _) = await openOnRealQueue(page); + for (var i = 0; i < 10; i++) { + await _settle(); + } + + container.read(_online.notifier).state = false; + container.read(textProvider.notifier).commitText('a', 'mine'); + // The teammate's edit waits to be drawn until the user's is saved. + server.teammateEditElement('c', (data) => data['text'] = 'theirs'); + showServer(); + for (var i = 0; i < 4; i++) { + await _settle(); + } + + container.read(_online.notifier).state = true; + for (var round = 0; round < 3; round++) { + await container + .read(strategyProvider.notifier) + .forceSaveNow('cloud-strategy'); + await container.read(strategyOpQueueProvider.notifier).flushNow(); + for (var i = 0; i < 6; i++) { + await _settle(); + } + showServer(); + for (var i = 0; i < 6; i++) { + await _settle(); + } + } + + String? drawn(String id) => container + .read(textProvider) + .firstWhere((text) => text.id == id) + .text; + expect(cloudPayloadData(server.element('a').payload)['text'], 'mine'); + expect(drawn('a'), 'mine'); + expect(drawn('c'), 'theirs'); + }); + test('offline edits to a group nobody changed land on reconnect', () async { final page = _page('page-1', 0); server = _FakeServer(page.publicId, lineups: [fanIn(page.publicId)]);