Skip to content

Commit e4617c7

Browse files
committed
Defer slice removals and keep the id map in step on replace
1 parent 9ceaefc commit e4617c7

3 files changed

Lines changed: 81 additions & 24 deletions

File tree

‎Telegram/ViewModels/DialogViewModel.cs‎

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1912,14 +1912,18 @@ protected void ProcessMessages(Chat chat, IList<MessageViewModel> messages, bool
19121912
{
19131913
ProcessAlbums(chat, messages, returnAlbumRoot);
19141914

1915+
// Removing from a MessageCollection also drops the date or topic separator that
1916+
// the removal orphans, and that one can sit above the item, so an index taken
1917+
// before a removal no longer points where it did. Collect and remove by identity
1918+
// once the walk is over; the list is only allocated if something is actually dropped.
1919+
List<MessageViewModel> discard = null;
1920+
19151921
for (int i = 0; i < messages.Count; i++)
19161922
{
19171923
var message = messages[i];
19181924
if (message.Content is MessageForumTopicCreated or MessageChatUpgradeFrom && Type == DialogType.Thread)
19191925
{
1920-
messages.RemoveAt(i);
1921-
i--;
1922-
1926+
(discard ??= new()).Add(message);
19231927
continue;
19241928
}
19251929
else if (message.Content is MessageChatUpgradeFrom upgradeFrom)
@@ -1930,9 +1934,7 @@ protected void ProcessMessages(Chat chat, IList<MessageViewModel> messages, bool
19301934
}
19311935
else
19321936
{
1933-
messages.RemoveAt(i);
1934-
i--;
1935-
1937+
(discard ??= new()).Add(message);
19361938
continue;
19371939
}
19381940
}
@@ -2026,6 +2028,14 @@ protected void ProcessMessages(Chat chat, IList<MessageViewModel> messages, bool
20262028
ProcessEmoji(message);
20272029
ProcessReplies(chat, message);
20282030
}
2031+
2032+
if (discard != null)
2033+
{
2034+
foreach (var message in discard)
2035+
{
2036+
messages.Remove(message);
2037+
}
2038+
}
20292039
}
20302040

20312041
[MethodImpl(MethodImplOptions.AggressiveInlining)]
@@ -2062,6 +2072,10 @@ private void ProcessAlbums(Chat chat, IList<MessageViewModel> slice, bool return
20622072
Dictionary<long, Tuple<MessageViewModel, long>> groups = null;
20632073
Dictionary<long, long> newGroups = null;
20642074

2075+
// Deferred for the same reason as in ProcessMessages, which also keeps the
2076+
// slice[i] assignments below addressing what they were written against.
2077+
List<MessageViewModel> discard = null;
2078+
20652079
for (int i = 0; i < slice.Count; i++)
20662080
{
20672081
var message = slice[i];
@@ -2097,8 +2111,7 @@ private void ProcessAlbums(Chat chat, IList<MessageViewModel> slice, bool return
20972111
}
20982112
else
20992113
{
2100-
slice.RemoveAt(i);
2101-
i--;
2114+
(discard ??= new()).Add(message);
21022115
}
21032116

21042117
if (group.Content is MessageAlbum album)
@@ -2110,6 +2123,14 @@ private void ProcessAlbums(Chat chat, IList<MessageViewModel> slice, bool return
21102123
}
21112124
}
21122125

2126+
if (discard != null)
2127+
{
2128+
foreach (var message in discard)
2129+
{
2130+
slice.Remove(message);
2131+
}
2132+
}
2133+
21132134
if (groups != null)
21142135
{
21152136
foreach (var group in groups.Values)

‎Telegram/ViewModels/Dialogs/MessageCollection.cs‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,38 @@ protected override void InsertItem(int index, MessageViewModel item)
373373
}
374374
}
375375

376+
// The one mutation that used to leave _messages stale. It maintains the id map only:
377+
// the sole caller swaps an album root in over the child that seeded it, so neither
378+
// the day nor the neighbours change and there is no attach state to recompute.
379+
protected override void SetItem(int index, MessageViewModel item)
380+
{
381+
var previous = this[index];
382+
if (previous.Content is MessageAlbum previousAlbum)
383+
{
384+
foreach (var child in previousAlbum.Messages)
385+
{
386+
_messages.Remove(child.Id);
387+
}
388+
}
389+
390+
_messages.Remove(previous.Id);
391+
392+
if (item.Content is MessageAlbum album)
393+
{
394+
foreach (var child in album.Messages)
395+
{
396+
_messages[child.Id] = item;
397+
}
398+
}
399+
400+
if (item.Id != 0)
401+
{
402+
_messages[item.Id] = item;
403+
}
404+
405+
base.SetItem(index, item);
406+
}
407+
376408
protected override void RemoveItem(int index)
377409
{
378410
var item = this[index];

‎notes/message-collection-plan.md‎

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ ctor also switches `CreateMessage` to its `forLanguageStatistics` overload, whic
156156
here: `UpdateLanguageStatistics` returns early on outgoing messages and every scheduled message
157157
is outgoing.
158158

159-
### M. `ProcessMessages` / `ProcessAlbums` mutate a slice by index
159+
### M. DONE - `ProcessMessages` / `ProcessAlbums` mutate a slice by index
160160

161161
Both take `IList<MessageViewModel>` and are handed a `MessageCollection` at four call sites, then
162162
walk it with an index while mutating it:
@@ -173,6 +173,20 @@ Neither reaches the live `Items` today - `ProcessMessages` is only ever given a
173173
`List` - so the stale map is thrown away before it matters. Latent, but it is the third thing the
174174
tightened signature points at.
175175

176+
Fixed by deferring instead of walking backwards. Both loops now collect the items to drop and
177+
remove them by identity once the walk is over, so no index survives a removal and nothing has to
178+
reason about how many items `RemoveItem` took. The `List` is only allocated when something is
179+
actually dropped, which is the rare case. Deferral also makes `ProcessAlbums`' `slice[i] = group`
180+
trivially valid, since the slice no longer shrinks underneath it.
181+
182+
The removals were placed immediately after the loop and before the `groups` block, which reads
183+
`first.IsFirst` and `album.Messages[^1].IsLast`: removing there replays the same `UpdateAttach`
184+
sequence the interleaved removals produced, so that block still sees what it saw.
185+
186+
`SetItem` is overridden and maintains the id map only. Its one caller swaps an album root in over
187+
the child that seeded it, so neither the day nor the neighbours change and there is no attach
188+
state to recompute - worth knowing before a second caller appears.
189+
176190
### N. DONE - `OnAttachChanged` gave up after the first neighbour
177191

178192
`ChatView.OnAttachChanged` looped over the reported items, and the `if
@@ -222,21 +236,11 @@ worth landing alone so the diff is reviewable against the three existing copies.
222236
once A is in: seam flag, drop `index`, delete `RawRemoveAt`, scope the flags, comment the
223237
`ReplaceSlice` invariant.
224238

225-
**3. Stop index-walking a slice while mutating it.** Finding M, agreed 2026-08-27. Two parts:
226-
227-
- `ProcessMessages` and `ProcessAlbums` must not assume `RemoveAt(i)` removes exactly one item.
228-
Either walk backwards (`for (int i = slice.Count - 1; i >= 0; i--)`), which is immune to
229-
anything the removal does below `i`, or re-read `slice.Count` and re-find the position after
230-
each removal the way `MoveMessageInOrder` does. Walking backwards is the cheaper fix and
231-
works for both loops, but check `ProcessAlbums` first: it accumulates album children into
232-
`album.Messages` in encounter order, so reversing the walk reverses the album.
233-
- `MessageCollection` should override `SetItem` to keep `_messages` in step, so
234-
`slice[i] = group` maps the album's id and its children rather than leaving the replaced
235-
message's id pointing at the old item. Cheap and it closes the hole rather than relying on
236-
every caller knowing about it.
237-
238-
Do this before phase 4: it is the only correctness item left, and `SetItem` is the last
239-
mutation path that does not maintain the id map.
239+
**3. DONE. Stop index-walking a slice while mutating it.** Finding M. Both loops defer their
240+
removals and remove by identity; `SetItem` is overridden to maintain the id map. Walking
241+
backwards was the other candidate and was rejected: it revisits the item below a removal that
242+
also took a separator, and `ProcessAlbums` accumulates `album.Messages` in encounter order, so
243+
reversing it would reverse the album.
240244

241245
**4. DONE. Stop allocating per message.** Findings B and N. `AttachChanged` is
242246
`Action<MessageViewModel, MessageViewModel>`, and the suppressed paths write into `Items`

0 commit comments

Comments
 (0)