Skip to content

Defer the visible content of the view together with the notification (#115) - #125

Open
aetos382 wants to merge 13 commits into
Cysharp:masterfrom
aetos382:issue-115
Open

Defer the visible content of the view together with the notification (#115)#125
aetos382 wants to merge 13 commits into
Cysharp:masterfrom
aetos382:issue-115

Conversation

@aetos382

Copy link
Copy Markdown

Background

When ToNotifyCollectionChanged() is given an ICollectionEventDispatcher, only the CollectionChanged invocation is deferred to the UI thread; the internal list (listView) is
updated immediately, on the mutating thread.

As a result, when the collection is mutated from a thread other than the UI thread, the content has already moved on by the time the UI thread receives the notification, so the indices carried by the
notification no longer match the content. A consumer that reads the collection right after the notification — such as the WinUI 3 ListView, which calls IBindableVector.GetAt(index) — crashes
there with an out-of-range access (#115).

Changes

1. Defer the visible content together with the notification (the core of #115)

Added Internal/DeferredViewList<TView>. When a dispatcher is specified, the view now holds both the list as the subscribers see it (published) and a queue of not-yet-raised changes (pending):

  • when a change happens, it is enqueued into pending before ICollectionEventDispatcher.Post
  • the change is applied to published at the moment the notification is raised

This keeps the content of the notification and the visible content consistent at all times.
this[int] / Count / IndexOf / Contains / GetEnumerator all read published.

The following are also guaranteed:

  • A dispatcher may raise some events synchronously and others asynchronously; a later event never overtakes the ones already queued (when an event is raised, all older pending changes are applied
    and raised first, in order).
  • Reset does not carry items, so the writer-side content is captured as a snapshot at Post time.
  • If there is no subscriber and nothing is pending, there is no notification to stay consistent with, so the change is applied on the spot. Nothing is enqueued, so a subscriber that attaches
    later does not receive past notifications.
  • After unsubscribing, deferral continues while changes are still pending — switching to immediate application mid-stream would reorder the applications and corrupt the content.
  • If a subscriber throws, the remaining pending changes are still applied and raised, and the exception is rethrown afterwards (AggregateException when there is more than one).
  • CollectionChanged / PropertyChanged became explicit events whose add / remove are guarded by gate, so that "there is no subscriber" can be decided atomically with applying a change.

2. Index translation for position-based writes on writable views

The indexer setter, Insert and RemoveAt of ToWritableNotifyCollectionChanged() passed the view index straight through to the source collection. This is now fixed:

  • On a filtered view, the index is translated back to a source index via AlternateIndexList.GetAlternateIndex (previously it was off by the number of filtered-out elements).
  • While notifications are pending, the visible index is translated to the writer-side index by replaying the pending changes (Add / Remove / Move).
  • An Insert at the tail of the view has no determined source index, so it is treated as AddToSourceCollection.
  • An out-of-range index is rejected with ArgumentOutOfRangeException, whether or not a dispatcher is used.
  • When the target element itself has been removed by a pending change, or when a pending Reset makes any index unresolvable, an InvalidOperationException is thrown, with the two reasons (element removed vs. collection reset) worded distinctly.
  • The setter publishes a Replace so that the visible content is never mutated without a notification.

3. Thread check in SynchronizationContextCollectionEventDispatcher

The condition for raising synchronously was "SynchronizationContext.Current is not null", so in a process with more than one thread that has a SynchronizationContext, a mutation from a UI thread
other than the bound one was raised directly on that thread. The check is now "is Current the same context this dispatcher is bound to".

public static readonly ... Current = current.Value; was also changed to => current.Value.
Previously the Lazy was evaluated during static initialization of the type, so if a thread without a SynchronizationContext touched the type first, the result was a TypeInitializationException.

4. Notification inconsistencies (found while investigating #115)

  • Move on a filtered view raised the notification with the translated indices and then fell through and raised the original one as well — notifying the same move twice, the second time with untranslated indices.
  • When a filter evaluates differently at add time and at remove time, a remove notification arrives for an element that is not in the view. Since listView is unchanged, no notification must be raised. (Enqueuing a notification with an unknown index (-1) into the deferral queue makes it inapplicable at raise time, permanently breaking consistency.)
  • A range remove with OldStartingIndex == -1 raised, for each element, the arguments of the whole batch, and with an uninitialized index of -1. It now raises a single-item notification carrying the position actually removed.

5. Documented the limitation of ToNotifyCollectionChangedSlim()

By design ToNotifyCollectionChangedSlim() shares the data with the source, so its visible content cannot be deferred and the same inconsistency remains. This is accepted as the trade-off for its performance, and is documented instead: the XML doc remarks and the README now state that when it is combined with a dispatcher, the collection must be mutated on the dispatcher thread.

Behavioral changes

  • A view with a dispatcher holds one additional internal list for the visible content (one copy at construction time).
  • With a dispatcher, a change is not reflected in Count or the indexer until the notification is raised — even as seen from the mutating thread. This is intentional; consistency with the notification is the point.
  • Position-based writes on a writable view now throw InvalidOperationException for an index that cannot be resolved while notifications are pending.
  • SynchronizationContextCollectionEventDispatcher.Current changed from a static readonly field to a property, so binary compatibility is not preserved.

Known bugs not fixed in this PR

  • A writable view writes to the source without calling the converter when typeof(T) == typeof(TView), so Add / Insert / Remove ignore the converter even for cases that need conversion between identical types (split out into a separate branch; out of scope here).
  • SynchronizationContextCollectionEventDispatcher.Current is pinned to the SynchronizationContext of whichever thread reads it first, and Lazy<T> caches exceptions too, so if a thread without a context reads it first, it throws InvalidOperationException forever for the rest of the process.
    (Making it a property avoids the static-initialization failure, but the pinning and the cached exception remain.)
  • ToNotifyCollectionChangedSlim() still carries the same inconsistency as described above; only documentation was added.

aetos382 and others added 11 commits August 31, 2026 12:48
When notifications are deferred by ICollectionEventDispatcher, the internal
list was updated immediately on the mutating thread, so a subscriber on the
dispatcher thread observed a state ahead of the notification it received.

- add DeferredViewList<TView>, which holds the list as subscribers see it and
  applies each change at the same time as the notification is raised
- translate visible indexes into writer-side indexes on positional writes
  (setter, Insert, RemoveAt) by replaying the pending changes
- fix SynchronizationContextCollectionEventDispatcher.Post to compare against
  the bound SynchronizationContext instead of testing Current for null
- fix duplicated notification on Move in the filtered view
- document the limitation of ToNotifyCollectionChangedSlim, which shares the
  actual data and therefore can not defer its content

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Only the head is ever taken, so a queue expresses the intent and drops the
O(n) List.RemoveAt(0). Draining n pending changes in order becomes O(n) instead
of O(n squared), which matters because the queue grows whenever the mutating
thread runs ahead of the dispatcher thread.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The setter updated the visible list without a notification, so its content
diverged from the notification stream and never recovered. Route the change
through Publish, and notify only when the converter rejects the source write
because otherwise the Replace of the source carries the same content.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The drain loop of InvokeChangedEvent raises an older pending event before the
event it was called for. A subscriber exception escaped the loop and left the
newer events pending, so their content and notification were never delivered
until the next change happened, if any.

Collect the exceptions, finish the drain, then rethrow. A single exception is
rethrown with ExceptionDispatchInfo so the behavior stays as it was.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
When the source does not provide the index, the view looks the element up by
value. A filter that is not pure can make the lookup miss, and the resulting
notification carried -1 as its index. Such an event can not be applied to the
deferred list, so it threw inside the gate after being dequeued and broke the
consistency between the visible content and the notifications for good.

The multiple items branch also never updated the index and raised the whole
range once per element.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ToListViewIndex is the only entry point of the indexer setter, Insert and
RemoveAt, so validate the index there before translating it.

Filtable fell back to appending when the translated index was past the end of
listView, which was meant for the insertion point at the tail but also swallowed
an out of range index. Insert(Count + 1, x) appended silently instead of
throwing, with or without a dispatcher.

UntrackableIndex is -1 as well, so an index of -1 passed by the caller was
mistaken for an untrackable one. Insert(-1, x), RemoveAt(-1) and this[-1] = x
threw InvalidOperationException with a message about another thread, only when a
dispatcher was configured.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The message blamed another thread for changing the element, but a dispatcher
defers a change of the same thread as well, and the change is never a Replace,
which does not move any element. It is either a remove of the element itself or
a reset of the whole content. Return the reason from ToWriterIndex and tell
which one it is, and what to do about it.

A pending reset makes every index unresolvable, so do not report an index for it
as if another one could work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adding the tests did not add any project to the solution, so its only real
change was the version of Visual Studio that happened to open it. The rest of
the diff was the line endings being rewritten from LF to CRLF.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bumping it to net10.0 has nothing to do with issue Cysharp#115. Restore net6.0 and stop
using a collection expression, which the resulting C# 10 does not have.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@aetos382
aetos382 requested review from neuecc and a balanced review from Copilot August 31, 2026 08:57

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Defers synchronized-view state alongside dispatched notifications to keep UI-visible content and event indices consistent.

Changes:

  • Adds deferred view state and writable-index translation.
  • Fixes dispatcher thread detection and notification inconsistencies.
  • Adds documentation and extensive regression tests.

Reviewed changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
README.md Documents slim-view threading limitations.
src/ObservableCollections/ICollectionEventDispatcher.cs Corrects synchronization-context dispatching.
src/ObservableCollections/Internal/DeferredViewList.cs Implements deferred visible state.
src/ObservableCollections/ObservableList.OptimizeView.cs Documents slim-view behavior.
src/ObservableCollections/SynchronizedViewList.cs Integrates deferral and index translation.
tests/ObservableCollections.Tests/DeferredNotificationConsistencyTest.cs Tests notification-state consistency.
tests/ObservableCollections.Tests/DeferredNotificationTest.cs Covers deferred behavior and writes.
tests/ObservableCollections.Tests/MultipleUiThreadTest.cs Tests multiple UI contexts.
tests/ObservableCollections.Tests/NotifyCollectionChangedContractTracker.cs Adds notification contract validation.
tests/ObservableCollections.Tests/QueuedCollectionEventDispatcher.cs Adds queued dispatcher fixture.
tests/ObservableCollections.Tests/QueuedSynchronizationContext.cs Adds synchronization-context fixture.
tests/ObservableCollections.Tests/TestUiThread.cs Adds simulated UI thread.
tests/ObservableCollections.Tests/ToNotifyCollectionChangedTest.cs Cleans unused imports.
tests/ObservableCollections.Tests/WritableViewIndexTranslationTest.cs Tests writable index translation.
Suppressed comments (3)

src/ObservableCollections/SynchronizedViewList.cs:1167

  • When the converter rejects the source write (setValue == false) and no dispatcher was supplied, this condition skips Publish, so the notify collection changes its visible item without raising CollectionChanged. Emit the synthetic Replace for the inline path as well; Publish already supports it.
                    if (deferred != null)

src/ObservableCollections/SynchronizedViewList.cs:1076

  • Breaking as soon as the callback's own event is reached leaves any newer pending changes untouched. If this callback's subscriber throws, the exception is rethrown and can abort the dispatcher pump before those callbacks run, so remaining pending changes are not applied and raised before rethrow. Drain all changes already pending at callback entry before propagating collected exceptions.
            if (ReferenceEquals(applied, e)) break;

src/ObservableCollections/SynchronizedViewList.cs:1174

  • Suppressing this synthetic notification assumes SetToSourceCollection will always deliver the source Replace. An ObservableList.CollectionChanged subscriber registered before the view can throw before the view handler runs; the setter then throws after listView was changed, and this queued change is applied silently with no source notification to replace it. Ensure the synthetic update is notified or rolled back when the source update fails.
                            IsInvokeCollectionChanged = !setValue, // the Replace of the source carries the same content

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +487 to +497
if (deferred != null)
{
// never touch the visible list without a notification, the Replace of the source updates it
Publish(new CollectionEventDispatcherEventArgs(NotifyCollectionChangedAction.Replace, value, oldView, listViewIndex)
{
Collection = this,
Invoker = raiseChangedEventInvoke,
IsInvokeCollectionChanged = !setValue, // the Replace of the source carries the same content
IsInvokePropertyChanged = false
});
}
(exceptions ??= new()).Add(ex);
}

if (ReferenceEquals(applied, e)) break;
Comment on lines +85 to +93
bool IsPending(CollectionEventDispatcherEventArgs ev)
{
foreach (var change in pending)
{
if (ReferenceEquals(change.Args, ev))
{
return true;
}
}
{
Collection = this,
Invoker = raiseChangedEventInvoke,
IsInvokeCollectionChanged = !setValue, // the Replace of the source carries the same content
aetos382 and others added 2 commits August 31, 2026 18:30
The previous commit routed the set of a writable view through Publish, but it
still updated listView first and then suppressed the notification when the
source write was going to carry the same content. Both halves were wrong.

When the write goes through, the parent view recomputes the view from the
selector, so the value put into listView is overwritten by selector(newOriginal)
a moment later. The deferred list applied the change twice, the first time
without a notification. When the source write did not come back - either
SetToSourceCollection throws, or a CollectionChanged subscriber registered
before the view throws so the view never sees the Replace - that first
application was all that was left, and the visible content changed with nothing
to say so. Let the Replace of the source be the only change.

When the converter rejects the write there is no source notification at all, so
the set has to notify by itself. It was guarded by a dispatcher being
configured, so without one the visible content changed silently. That was the
behavior before this branch as well.

Both were pointed out by Copilot on the pull request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
IsPending walked the queue on every TryApplyNext. In the normal case the target
sits at the head and the walk ends on the first comparison, but when a
synchronous notification overtakes the queued ones - the case the drain loop
exists for - the target is at the tail, so draining N changes compared
N + (N-1) + ... + 1 times while holding the gate.

The queue already applies the changes strictly in the order they were enqueued
and no event is enqueued twice, so an increasing sequence per change answers the
same question by itself: an event is pending exactly while its sequence is above
the one applied last. An event that was never enqueued keeps the default 0 and
is never reported as pending, so the ApplyWithoutNotification path is unaffected.

Hoisting the check out of the drain loop instead would not work, because the loop
releases the gate to raise the notification and another callback may dequeue the
target in the meantime.

Pointed out by Copilot on the pull request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants