Skip to content

[Bug]: Filter.ReFilter throws InvalidOperationException (Collection was modified) on .NET Framework 4.8 β€” regression from 9.4.1Β #1122

Description

@Snailya

Describe the bug 🐞

The dynamic Filter operator throws System.InvalidOperationException: Collection was modified; enumeration operation may not execute on .NET Framework 4.8 whenever the filter predicate is re-evaluated while items are present.

  • Works: DynamicData 9.4.1
  • Broken: DynamicData 9.4.31
  • Reproduces only on net48 β€” not on net6.0 / net8.0.

Filter.Dynamic<...>.Subscription.ReFilter reassigns into the backing dictionary while enumerating its Keys:

private void ReFilter(TState predicateState)
{
    foreach (var key in _itemStatesByKey.Keys)          // enumerate Keys
    {
        ...
        _itemStatesByKey[key] = new() { IsIncluded = ..., Item = ... }; // invalidate
    }
}

On .NET Framework 4.8, Dictionary<,>.this[set] bumps the internal version counter even when only updating an existing key (Insert does entries[i].value = value; version++;), so the in-progress Keys enumeration throws. .NET Core / .NET 5+ changed this so the update path no longer bumps version β€” which is why the same code is silent there and the bug is easy to miss when testing only on modern frameworks.

This breaks the (common) "dynamic filter whose predicate depends on the filtered output" pattern, e.g. an expand/collapse tree where the visible-node set feeds back into its own visibility filter.

Step to reproduce

Minimal console repro, target net48, reference DynamicData 9.4.31:

var source  = new SourceCache<Node, int>(n => n.Id);
var visible = new SourceCache<Node, int>(n => n.Id);   // input to the filter AND output of the pipeline

// Self-referential filter: a node is visible iff it is a root, or its parent is expanded.
var visibleFilter = visible.Connect()
    .AutoRefresh(n => n.IsExpanded)
    .StartWithEmpty()
    .QueryWhenChanged(q =>
    {
        var expanded = q.Items.Where(n => n.IsExpanded).Select(n => n.Id).ToHashSet();
        return new Func<Node, bool>(node => !node.ParentId.HasValue || expanded.Contains(node.ParentId.Value));
    });

source.Connect()
    .Filter(visibleFilter)
    .PopulateInto(visible);

source.AddOrUpdate(new[] { new Node(1, null), new Node(2, 1), new Node(3, 2) });
// ^ throws on net48 + 9.4.31: root passes the filter -> PopulateInto mutates `visible`
//   -> predicate re-emits -> ReFilter runs over the now non-empty _itemStatesByKey
  1. dotnet build -c Release / dotnet run -c Release with DynamicDataVersion=9.4.31 on net48 β†’ throws on load.
  2. Set DynamicDataVersion=9.4.1, rebuild/run β†’ prints After load (expect [1]): [1], no exception.
  3. (Optional) Retarget to net8.0 on 9.4.31 β†’ no exception (demonstrates it is framework-specific).

Node is a plain INotifyPropertyChanged with int Id, int? ParentId, bool IsExpanded.

Reproduction repository

https://gist.github.com/Snailya/da8dda754d84425c76bce67a5f9606c9 (3 files: Program.cs, Node.cs, DynamicDataRepro.csproj β€” git clone-able)

Expected behavior

No exception; the predicate re-evaluation completes (as on 9.4.1, and as on net8.0).

Screenshots πŸ–ΌοΈ

n/a

IDE

Visual Studio 2022

Operating system

Windows

Version

Windows 11

Device

n/a

DynamicData Version

9.4.31 (regressed from 9.4.1)

Additional information ℹ️

Stack trace (net48, 9.4.31):

System.InvalidOperationException
   at System.ThrowHelper.ThrowInvalidOperationException(ExceptionResource resource)
   at System.Collections.Generic.Dictionary`2.KeyCollection.Enumerator.MoveNext()
   at DynamicData.Cache.Internal.Filter.Dynamic<...>.Subscription.ReFilter(Func`2 predicateState) Line 368
   at DynamicData.Cache.Internal.Filter.Dynamic<...>.Subscription.OnPredicateStateNext(Func`2 predicateState) Line 173
   at System.Reactive.ObserverBase`1.OnNext(...)

Suggested fix β€” snapshot the keys before mutating:

foreach (var key in _itemStatesByKey.Keys.ToList())
{
    ...
    _itemStatesByKey[key] = new() { IsIncluded = isIncluded, Item = itemState.Item };
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions