Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ jobs:
trap 'rm -f "$UPM_INDEX"' EXIT
export GIT_INDEX_FILE="$UPM_INDEX"
git read-tree "$TAG"
git rm -r --cached --quiet --ignore-unmatch .github
git rm -r --cached --quiet --ignore-unmatch .github CLAUDE.md CLAUDE.md.meta
TREE=$(git write-tree)
unset GIT_INDEX_FILE
PARENT=$(git rev-parse "$TAG^{commit}")
Expand Down
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- `FilteredList<T>`: incremental dispatch of source `Add`/`Remove` (single and batch), `Replace` and `Move` (single) — no full rebuild on every event for the common cases. Reset and batch `Replace`/`Move` still fall back to a full `Update()`.
- `ObservableList<T>.RemoveRange(int startIndex, int count)` and the protected `OnRemovedRange(in IReadOnlyList<T>)` hook. `ObservableListSync<TFrom, TTo>` forwards source batch removes through it.
- Batch propagation in `ObservableDictionarySync<TKey, TFrom, TTo>` and `ObservableHashSetSync<TFrom, TTo>` for `Add` / `Remove` (and `Replace` on Dictionary) — previously threw `NotImplementedException`.
- `CollectionChangedEvent<T>`: re-entrancy-safe via copy-on-write of the subscriber list during `Invoke`; a lazy `HashSet<Handler>` tracks unsubscribes during the invoke chain for O(1) skip of removed handlers.
- Performance benchmark scaffold under `Tests/Observable/Performance/`, gated by the `ASPID_COLLECTIONS_PERFORMANCE_TESTING` define.
- Aspid script icon at `Editor/Resources/Icons/aspid_icon_medium_green_1022x1011.png`; runtime and test `.cs.meta` files point to it so scripts surface the project icon in the Unity Project view.

### Changed
- `ObservableQueueSync` / `ObservableStackSync` / `ObservableDictionarySync`: unsupported actions now throw `NotSupportedException` with a descriptive message instead of `NotImplementedException`.
- Release workflow excludes `CLAUDE.md` / `CLAUDE.md.meta` from the published UPM tree (alongside `.github`).

### Fixed
- `ObservableStackSync.OnPoppedRange` and `ObservableQueueSync.OnDequeuedRange` overrides recursed into themselves via C# overload resolution, causing `StackOverflowException` on `Dispose` and any batch dequeue/pop.
- `ObservableDictionarySync.OnReplaced` passed `newItem.Value` to `OnRemoved`, disposing/notifying the freshly inserted value instead of the discarded one.
- `FilteredList.ApplyMoveSingle` did not re-seat the moved entry when a `Comparer` was set; for comparer-equal items the source-index tie-break could drift from a fresh `Update()` rebuild.
- `ObservableHashSetSync.AddOne` ignored the `Add` return value, silently corrupting `_sync` on a non-injective converter — now throws `InvalidOperationException`. `RemoveOne` uses `TryGetValue` instead of the indexer to avoid `KeyNotFoundException` inside the source's `Invoke`.
- `ObservableList<T>.RemoveRange` validates `count < 0` → `ArgumentOutOfRangeException` (previously `OverflowException` from `new T[-1]`).
- `FilteredList` 3-arg constructor: source subscription now happens after `_filter` / `_comparer` are set, closing a subscribe-before-init window.

## [1.0.2] — 2026-05-18

### Added
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

30 changes: 24 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ collections with synchronization, filtering, and sorting.

## Package Info

- **Package name**: `tech.aspid.collections` (`package.json`, v1.0.1)
- **Unity**: 2022.3+
- **Package name**: `tech.aspid.collections` (`package.json`, v1.0.2)
- **Unity**: `package.json` declares `2021.3` as the manifest minimum; the
active development target is 2022.3+ (matches the parent MVVM project).
- **Engine dependency**: none — `Aspid.Collections.Observable.asmdef` sets
`noEngineReferences: true`. Runtime is pure C# and must stay that way
so the package works in non-engine assemblies too.
Expand All @@ -29,12 +30,16 @@ Collections/
│ ├── INotifyCollectionChangedEventArgs.cs
│ ├── NotifyCollectionChangedEventHandler.cs
│ ├── CollectionChangedEvent.cs
│ ├── Events/ # IObservableEvents + SplitByEvents
│ ├── Events/ # IObservableEvents, ObservableCollectionEvents
│ │ └── Extensions/ # SplitEventsExtensions
│ ├── Extensions/ # ObservableListExtensions
│ ├── Filtered/ # FilteredList + CreateFiltered
│ └── Synchronizer/ # Observable*Sync + CreateSync
│ ├── Filtered/ # FilteredList, IReadOnlyFilteredList
│ │ └── Extensions/ # CreateFilteredExtensions
│ └── Synchronizer/ # Observable*Sync, IReadOnly*Sync
│ └── Extensions/ # CreateSyncExtensions
└── Tests/Observable/ # EditMode tests (UTF)
└── Helpers/
├── Helpers/
└── Performance/ # Optional perf benchmarks (gated)
```

## Namespaces
Expand All @@ -50,6 +55,7 @@ Collections/
|--------|---------|
| `Aspid.Collections.Observable.asmdef` | Runtime (`noEngineReferences: true`) |
| `Aspid.Collections.Observable.Tests.asmdef` | Unity Test Framework tests |
| `Aspid.Collections.Observable.PerformanceTests.asmdef` | Perf benchmarks; compiled only when both `UNITY_INCLUDE_TESTS` and `ASPID_COLLECTIONS_PERFORMANCE_TESTING` are defined (the latter is auto-set when `com.unity.test-framework.performance` is installed) |

## Testing

Expand Down Expand Up @@ -83,11 +89,23 @@ Tests live in `Tests/Observable/` and run via Unity Test Runner
`CreateFiltered`. Each wrapper holds a subscription on its source; if
you drop the reference without calling `Dispose()`, the source keeps
the chain alive.
- **FilteredList is single-thread.** Source collections are guarded by
`SyncRoot`, but `FilteredList` itself is not — its internal index map
is mutated in place from `OnCollectionChanged`. Build / read /
enumerate it from one thread (the same one that mutates the source).
- **Performance tests are off by default.** `Tests/Observable/Performance/`
only compiles when `com.unity.test-framework.performance` is installed —
the asmdef sets `ASPID_COLLECTIONS_PERFORMANCE_TESTING` via
`versionDefines`. If you add benchmarks, they will silently be skipped
in projects without that package.

## Pointers

- `README.md` / `README_RU.md` — full public API reference with
examples (collections, events, sync, filter, sample patterns).
- `CHANGELOG.md` — release notes; the **Unreleased** block lists in-flight
work (incremental FilteredList dispatch, batch propagation in Sync
wrappers, re-entrancy safety in `CollectionChangedEvent`).
- Parent framework CLAUDE.md: `../../../../CLAUDE.md` (relative to this
file, resolves to `Projects/Aspid.MVVM/CLAUDE.md`) — Aspid.MVVM-wide
context and conventions.
8 changes: 8 additions & 0 deletions Editor.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions Editor/Resources.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Editor/Resources/Icons.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
143 changes: 143 additions & 0 deletions Editor/Resources/Icons/aspid_icon_medium_green_1022x1011.png.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

88 changes: 78 additions & 10 deletions Runtime/Observable/CollectionChangedEvent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,30 +6,98 @@ namespace Aspid.Collections.Observable
{
public abstract class CollectionChangedEvent<T> : IDisposable
{
// We store handlers in a List instead of combining them via Delegate.Combine because
// subscribers with different but variance-compatible T parameters cannot be combined by
// the runtime — each handler must be kept and invoked independently.
private List<NotifyCollectionChangedEventHandler<T>>? _handlers;


// Bumped while Invoke iterates _handlers; add/remove fork the list (copy-on-write) only
// when this is non-zero, so the in-flight foreach iterates an undisturbed list.
private int _invokeDepth;

// Handlers unsubscribed while any Invoke is on the stack. Populated lazily on the first
// removal during an invoke, cleared when _invokeDepth returns to zero. Lookup is O(1).
private HashSet<NotifyCollectionChangedEventHandler<T>>? _removedDuringInvoke;

public event NotifyCollectionChangedEventHandler<T>? CollectionChanged
{
add
{
_handlers ??= new List<NotifyCollectionChangedEventHandler<T>>();
_handlers.Add(value ?? throw ThrowValueNullReferenceException());
var v = value ?? throw ThrowValueNullReferenceException();

if (_invokeDepth > 0)
{
_handlers = _handlers is null
? new List<NotifyCollectionChangedEventHandler<T>> { v }
: new List<NotifyCollectionChangedEventHandler<T>>(_handlers) { v };
}
else
{
_handlers ??= new List<NotifyCollectionChangedEventHandler<T>>();
_handlers.Add(v);
}
}
remove
{
var v = value ?? throw ThrowValueNullReferenceException();
if (_handlers is null) return;

if (_invokeDepth > 0)
{
var forked = new List<NotifyCollectionChangedEventHandler<T>>(_handlers);

if (forked.Remove(v))
{
(_removedDuringInvoke ??= new HashSet<NotifyCollectionChangedEventHandler<T>>()).Add(v);
_handlers = forked;
}
}
else
{
_handlers.Remove(v);
}
}
remove => _handlers?.Remove(value ?? throw ThrowValueNullReferenceException());
}

protected void Invoke(INotifyCollectionChangedEventArgs<T> e)
{
if (_handlers is null) return;

foreach (var handler in _handlers)
handler.Invoke(e);
var handlers = _handlers;
if (handlers is null) return;

var count = handlers.Count;
if (count is 0) return;

if (count is 1)
{
// Single subscriber: nothing to iterate after the call, so the handler is free
// to mutate _handlers in place (no fork needed, no depth tracking).
handlers[0].Invoke(e);
return;
}

_invokeDepth++;

try
{
foreach (var handler in handlers)
{
// Skip handlers that were unsubscribed at any point during this invoke chain.
if (_removedDuringInvoke is not null && _removedDuringInvoke.Contains(handler))
continue;

handler.Invoke(e);
}
}
finally
{
if (--_invokeDepth == 0) _removedDuringInvoke = null;
}
}

private static NullReferenceException ThrowValueNullReferenceException() =>
throw new NullReferenceException("value");

public virtual void Dispose() =>
_handlers?.Clear();
_handlers = null;
}
}
}
2 changes: 1 addition & 1 deletion Runtime/Observable/CollectionChangedEvent.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading