|
Aria 2.0.0
C++23 MVVM framework (C++20 minimum) — reactive, coroutine-first, ABI-layered
|
This document is the framework's authoritative reference for list change semantics. Any type that satisfies the aria::ListSource concept (ObservableList<T> / FilteredList<T> / SortedList<T> / MappedList<S,T> and any future derived list) MUST emit ListChange<T> events that follow this contract. Any list-consuming adapter (Qt6 / AppKit / UIKit / future React Native / WASM / ...) MUST interpret event streams strictly per the rules below.
Together with lifecycle.md, api-style.md and error-model.md, this file forms the framework's contract document family. Every contract item is numbered D-N so that code, tests, and the CHANGELOG can reference it directly.
What a "best-in-class C++ MVVM framework" demands of its list protocol:
The numeric ordering is stable — never reordered, new entries can only be appended. Reason: this enum crosses the ABI boundary (abi::SignalErased multicasts ListChange<T>); reordering would break binary compatibility.
| kind | meaning of index | meaning of item | from_index |
|---|---|---|---|
| Insert | position after insertion into the receiver's mirror | owning handle to the inserted element | 0 |
| Remove | position before removal from the mirror | owning handle to the removed element | 0 |
| Replace | position of the replaced element | owning handle to the new element | 0 |
| ItemChanged | element's position in the mirror | owning handle to that element | 0 |
| Reset | 0 | empty | 0 |
| Move | position after the move in the mirror | owning handle to the moved element | position before the move |
Reset always carries a non-null snapshot, including for an empty list. Other kinds carry no snapshot. The snapshot contains the complete replacement sequence, not another stream of edits.
Copying an event retains its item or snapshot, so adapters can queue it across threads without borrowing an element from the source. The pointed-to T remains a shared, potentially mutable object: owning an event preserves identity and lifetime, not a deep historical copy of each field.
Consumers MUST use change.item and change.snapshot to interpret events. They MUST NOT fetch source.at(change.index) or a later source snapshot to recover an event payload. The source may already contain later batch edits or a reentrant mutation made by an earlier observer.
| API | Event |
|---|---|
| push_back(x) / emplace_back(...) / insert(pos, x) | 1 × Insert |
| remove_at(i) / remove_first(pred) (when matched) | 1 × Remove |
| replace_at(i, x) | 1 × Replace |
| move(from, to) (from != to and both in-range) | 1 × Move |
| clear() | 1 × Reset |
| T::on_changed fires (only when T exposes it) | 1 × ItemChanged |
move(from, to) with from == to or out-of-range emits no event.
Batch operations commit their structural changes efficiently, then deliver an ordered edit stream. Apply each event to the result of applying all preceding events; the producer may already hold the final batch state.
This permits one vector insertion or compaction for a whole range while keeping a deterministic stream for every adapter. Source reads during a callback are live reads; event coordinates belong to the receiver's mirror.
On Reset, replace the mirror with *change.snapshot. The replacement may be nonempty, for example after a derived view rebuild. No follow-up inserts are required to describe that snapshot. clear() emits an empty snapshot.
ObservableList<T> installs a per-item subscription only when T exposes Subscription on_changed(std::function<void(const T&)>). One subscription is installed per distinct object handle. If the same object occupies multiple rows, ItemChanged emits once for every valid occurrence, using indices frozen when the notification was produced. Property<U> and friends qualify; user-defined types that don't have that signature simply never get ItemChanged events — by design, not a bug.
ItemChanged on derived lists (FilteredList / SortedList / MappedList) does NOT necessarily mirror upstream events 1:1:
Derived-list specifics: see D-30.
ObservableList<T>::reconcile(next, key_of) brings the list in line with a whole new sequence. It introduces no new event kind and no new index rule: it drives the ordinary mutators, so every emission already obeys D-1, D-2 and the D-11 "as observed" index rule.
Why it exists: every other mutator is imperative (the caller names the operation), but server-backed data arrives declaratively — a whole new array, with no indication of what moved. The alternatives were clear() + insert_range, which emits Reset and therefore costs the observer its selection, scroll position and row animations (D-12), or a hand-rolled diff in user code, which forces the caller to track the intermediate coordinate system move(from, to) operates in.
Guarantees:
An unchanged sequence or append-only update takes expected O(n) work. Arbitrary reorderings can take O(n²) because suffix lookup and vector moves are linear. This is a keyed sequence reconciliation, not a minimum-edit-distance algorithm. Null target handles are ignored. Duplicate target keys produce one empty Reset followed by inserts, all in the same ordered batch.
Each fanout snapshots its subscribers and checks connection activity before invocation. Disconnecting a later subscriber prevents its callback in the current fanout; a callback already executing on another thread may finish. No user callback or capture destructor runs under the signal registry lock.
List signals serialize nested mutations behind their current fanout and any already queued batch. They do not recursively deliver the nested edit ahead of older events. Reactive batching is separate and controls graph flushes.
New subscribers skip events committed before their subscription, including the remaining events of an already queued batch. This allows a view created inside an observer to initialize from the current source snapshot without replaying that old batch twice. Initialization through separate snapshot() and observe() calls must still be serialized against concurrent writers; those two calls are not an atomic operation.
Exceptions escaping a TypedSignal::connect-installed handler are caught by the invoker (consistent with lifecycle.md L-13 / S-32). Rationale: a single misbehaving handler must not prevent subsequent handlers from running — observers must catch and handle their own exceptions.
Derived lists (FilteredList / SortedList / MappedList) still honour D-1 ... D-13 on their own emit stream, but their events do NOT necessarily map 1:1 to upstream events.
Any adapter that takes a list as template<ListSource L> automatically honours D-1 ... D-32. Adapters MUST NOT specialise on the concrete source type — every built-in derived list guarantees the same contract.
Every derived list takes its source as a template parameter constrained to ListSourceOf<Source, T>, defaulting to ObservableList<T>:
That means a derived list is itself a legal source for another one, so pipelines are expressible:
Guarantees:
Prefer the filtered() / sorted() / paged() / mapped<Target>() / distinct<Key>() / grouped<Key>() factory helpers: they deduce the source type, so the chain does not have to be spelled out (SortedList<Row, FilteredList<Row>> and so on).
Ordering caveat: composing stages that each re-order (for example sorted -> sorted) is legal but pointless — the last stage wins. Compose stages that do different jobs.
Any new derived-list implementation (or test fake) MUST pass the framework-provided conformance suite. The suite is templated on <ListSource L> and translates every mechanically verifiable fact from D-1 ... D-32 into doctest test cases.
| # | Anti-pattern | Consequence | Correct approach |
|---|---|---|---|
| DE1 | Observer keeps only change.item.get() for a later frame | Dropping the owning event may destroy the element | Retain the shared handle or event |
| DE2 | Observer interprets an event using live source size | The source may already contain later edits | Apply edits to an incremental mirror |
| DE3 | Observer recovers any event payload using list.at(idx) | The indexed row may already have changed | Use the owning item or Reset snapshot |
| DE4 | Throwing inside an emit callback | Exception is swallowed but can still corrupt state observed by later handlers | Use try/catch inside the handler |
| DE5 | Treating Move(to=2, from=5) as Remove(5) + Insert(2) | Adapters lose the "this is a move, not a destroy" signal | Adapters MUST distinguish Move from Remove+Insert |
| DE6 | Inside an ItemChanged callback, writing the element's Property<T> back | Feedback loop — relies on the equality gate to break or loops forever | ItemChanged is an observation event; do not set from inside it |
Every change to ListChangeKind / ListChange / ListSource MUST flow as: doc change → code change → conformance-suite change. Any new ListChangeKind value MUST first be registered in the D-1 table here together with an explicit ABI-compatibility strategy.