Derived from: docs/specs/042-keyed-list-reconciliation-design.md
Tracking bug: microsoft/microsoft-ui-reactor#198
Status (2026-05-17): Phase 0 + Phase 1 + Phase 2 + Phase 3 (3.1 through 3.7) + Phase 4 + Phase 5 + Phase 6.1 / 6.2 / 6.3 complete on
feat/042-keyed-list-reconciliation. Phase 1 perf gate (1.12) closed via a paired Microsoft.UI.Reactor (Reactor)-vs-WinUI-vanilla baseline rather than a pre/post Reactor capture — captured attests/stress_perf/baselines/keyed-list-vs-winui-2026-05-17-104102/(6-cell matrix × 5 reps × 2 apps; verdict insummary.md). The reconciler matches WinUI within noise (≤0.3 % P50) at production- realistic list sizes; the 10 k-item P50 gap is unrelated to the diff path and is filed as a follow-up perf opportunity. Phase 6.4 design- spec status rename is the only item still pending — gated on the PR landing. Items below preserve their original wording — completion marks reflect what landed on the feature branch.
Scope reminder: spec 042 is a three-phase design. This task list converts every
section of that spec into ship-ready work — internal ObservableCollection
delta plumbing for ListView<T> / GridView<T> / LazyVStack<T> /
LazyHStack<T> (Phase 1), the IReactorKeyed identity convention (Phase 2),
and the ambient Animate(...) transaction (Phase 3) — plus the regression
tests, performance gates, samples, guides, and agent-kit references that turn
it into a complete platform feature. Tasks are sized to be paused/resumed;
complete top-to-bottom within a phase. Cross-phase ordering matters (don't
ship the convention before the delta works; don't ship the ambient before the
op stream exists).
Success criteria the work must hit, end to end:
ListView<T>driven byUseState/UseReducerover an immutable list animates only the changed containers on add / remove / move.LazyVStack<T>/LazyHStack<T>(ItemsRepeater-backed) does the same without re-realizing every visible item on a single insert/remove.FlexColumn(items.Select(item => TextBlock(item.Name).WithKey(item.Id)))continues to incrementally reconcile viaChildReconciler(already works today — covered by regression tests in Phase 1 so it doesn't regress).- The same component code can opt into a unified animation transaction via
Animate(AnimationKind.Spring, () => setItems([..items, x]))(Phase 3).
Conventions:
- Reconciler files:
src/Reactor/Core/Reconciler.cs,src/Reactor/Core/Reconciler.Mount.cs,src/Reactor/Core/Reconciler.Update.cs. - New internal types live under
src/Reactor/Core/Internal/(already an established folder). - New public API (
IReactorKeyed,Animate,AnimationKind) goes insrc/Reactor/Core/next toElement.cs. - Unit tests under
tests/Reactor.Tests/. End-to-end animation tests that drive a real WinUI control tree go undertests/Reactor.AppTests/Tests/. - Stress / regression perf goes under
tests/stress_perf/with a named baseline; startup perf is unaffected and does not need a new baseline. - Sample apps land in
samples/ReactorGallery/ControlPages/Collections/and a focusedsamples/apps/AnimatedListDemo/mini-app for the showcase. - Agent-kit references live under
plugins/reactor/skills/reactor-dsl/references/andplugins/reactor/skills/reactor-recipes/references/; the human guide underdocs/guide/. - Public API additions need XML doc comments (no
CS1591). - Code must compile under
Reactor.slnxwarnings-as-errors.
A task is "done" only when:
- Code compiles under
Reactor.slnxwarnings-as-errors. - Public API surface has XML doc comments.
- New unit + AppTests cover the happy path and every documented edge case (single insert / remove / move / reverse / bulk-replace bailout / duplicate key / empty → non-empty / non-empty → empty).
- No regression in the
ChildReconcilerhand-built path — Phase 1 adds explicit pinning tests so the existing keyed-LIS behavior cannot silently drift. - Stress perf for the "100-item ListView, 10 inserts/sec for 30s" scenario does not regress vs. the baseline captured in 1.0.
- Doc + sample + agent-kit references land in the same PR as the API change so the surface is discoverable the moment it ships.
- Confirm Q1 (key-collision policy): warn-and-bailout vs hard-fail.
Recommendation in spec §9 is implicit "warn"; commit the decision in
the spec header so 4.7 below can implement it without revisiting.
→ Resolved: warn-and-bailout via
ReactorDiagnostics-style log. Spec §9 updated. - Confirm Q2 (missing-key analyzer for
.Select(...)children): defer to a later phase (Phase 2 or Phase 6 analyzer pass). Record "deferred" in the spec. → Resolved: deferred to Phase 6 (REACTOR_DSL_001). Spec §9 updated. - Confirm Q3 (
AsyncLocalambient survives until commit): write a short investigation note before Phase 3 starts. Capture findings as a sub-section under spec §6 ("Dispatch model validation"). → Resolved: ambient survives via DispatcherQueue + ExecutionContext; use a snapshot pattern in setters. Spec §9 captures the answer. - Confirm Q4 (
ItemContainerTransitionsper-render mutation safety): decision goes alongside Q3; outcome chooses between shared-resource mutation vs per-container Composition animations. → Resolved: per-container Composition animations. Spec §9 updated. - Confirm Q5 (long-distance
Source.Moveanimation quality on WinUIRepositionThemeTransition): plan a manual smoke-test gate in 1.13. → Resolved: manual smoke gate planned in 1.13. Virtualization naturally gates the visible portion of long-distance moves.
- Create
src/Reactor/Core/Internal/ReactorListState.cscontaininginternal sealed class ReactorListState+internal sealed class ReactorRowskeletons (no diff logic yet). - Create
src/Reactor/Core/Internal/KeyedListDiff.cswith an emptyinternal static class KeyedListDiff(theApplyKeyedDiffhelper lands here in 1.4). - Create
src/Reactor/Core/IReactorKeyed.cscontaining the interface declaration only; do not wire upKeySelectordefaulting yet. - Create
src/Reactor/Core/Animation.cscontaining thepublic static class Animation+public enum AnimationKindshells withAnimatemethods that currently just invoke the action (no ambient yet). → Note: namedAnimations(plural) to avoid collision with the existingMicrosoft.UI.Reactor.Animationsub-namespace. - Verify
Reactor.slnxbuilds clean with these placeholders.
- Run the existing stress perf matrix and store the baseline under
tests/stress_perf/baselines/keyed-list-pre-phase1/. Include the single-insert / single-remove / bulk-replace scenarios. → Closed differently than written: pre/post-Phase-1 capture against the priorEnumerable.Range(...)short-circuit isn't possible without reverting Phase 1 on the branch — the better gate turned out to be a paired Reactor-vs-WinUI-vanilla matrix (single-insert / single-remove are exercised inside the--with-editsflag at 4 and 16 eps). Baseline captured attests/stress_perf/baselines/keyed-list-vs-winui-2026-05-17-104102/— see 1.12 for the analysis. - Record current frame-time for "100-item ListView with theme transitions" and "1000-item LazyVStack scrolled through" in the baseline README. These numbers gate the Phase 1 PR (see 1.12). → Closed via the same baseline: 1k-item LazyVStack scroll captured at P50 31.27 ms / P95 37.52 ms (Reactor) vs P50 31.25 / P95 34.57 (WinUI). Differences inside noise.
- Audit
tests/Reactor.Tests/ChildReconcilerLisTests.csandChildReconcilerReconcileTests.csfor coverage gaps on: pure insert, pure remove, single move, reversal, duplicate key, mixed keyed + unkeyed siblings. - Add any missing pinning tests so Phase 1 work cannot silently change
hand-built-children semantics (success criterion #3).
→ Landed:
tests/Reactor.Tests/ChildReconcilerPinningTests.cs(18 tests).
The core fix. No public DSL change. Replaces the
ItemsSource = Enumerable.Range(...) short-circuits in Reconciler.Mount.cs
(:1852, :1896, :2824) and Reconciler.Update.cs (:2807-2808,
:2833-2834, :2906-2912) with an internally-owned OC + keyed diff.
- Flesh out
ReactorRowper spec §4:Index(int) +Key(string). OverrideToStringfor diagnostics. - Flesh out
ReactorListStateper spec §4:Source(ObservableCollection<ReactorRow>),ByKey(dict),LastKeys(List<string>). Add aReset(IEnumerable<(int Index, string Key)>)helper for mount-time population. - Add unit tests under
tests/Reactor.Tests/Internal/ReactorListStateTests.cscoveringResetand basic invariants (Source.Count == LastKeys.Count == ByKey.Count). 13 tests pass.
- Decide between extending the existing
SetElementTagmechanism vs. a dedicated attached DependencyProperty. ReuseSetElementTagif it already carries multi-value state; otherwise add a single attached propertyReactorListStatePropertyinReconciler.cs. → Extended the existingReactorState(already multi-value), no second attached property. - Add
GetListState(DependencyObject) → ReactorListState?andSetListState(DependencyObject, ReactorListState)helpers. - Unit-test the attached-property round-trip. → Covered end-to-end by the AppTests (1.11) since WinUI controls require a XAML host — the unit-test layer doesn't have one.
- Update
MountTemplatedListView— build theReactorListState, replaceEnumerable.Range(0, el.ItemCount)withlistView.ItemsSource = state.Source;, attach state. - Update
MountTemplatedGridView: same change. -
HandleTemplatedContainerContentChangingstill readsargs.ItemIndex—Source[i]remains positionally aligned withn.Items[i]. - Adjust the
ItemClickhandlers soargs.ClickedItem is ReactorRow row→tel.InvokeItemClick(row.Index). Int path preserved for legacy direct-int consumers.
- Implement
KeyedListDiff.Apply(ReactorListState state, IReadOnlyList<T> newItems, Func<T, int, string> keySelector)per spec §4.3 — lockstep prefix walk, build dict of remaining old rows, walk new keys with Move/Insert, descending RemoveAt for trailing keys, sync state. - Add an internal
DiffStatsreturn type (Inserts/Removes/Moves/Survivors/Bailout) so tests and Phase 3 can read the op shape without re-walking the OC.
- Short-circuit when
oldKeys.SequenceEqual(newKeys). - Single-append / single-prepend / single-remove-front / single-remove-end → one OC op, no dict allocation. (Plus single-insert-in-middle and single-remove-from-middle as the suffix-walk fall-through.)
- Bulk-replace bailout: if churn
> 25%AND churn>= 8absolute ops, OR duplicate keys innewKeys, OR null keys, fall back toReactorListState.Reset(...)(Source contents replaced in bulk; the OC reference is preserved so ItemsSource binding survives). → Note: ratio AND absolute floor of 8 ops avoids punishing small lists where 1 op is already >25%. - Emit a one-shot diagnostic on duplicate-key / null-key bailout (per Q1 resolution from 0.1).
- Empty → non-empty (mount-equivalent path through diff).
- Non-empty → empty.
- Append one to end.
- Prepend one.
- Insert in middle.
- Remove from start / middle / end.
- Single move (item floats up by 1, by N).
- Reverse N-item list (no inserts/removes; only moves; survivor-reuse verified).
- Shuffle (asserts OC final order matches
newKeys). - Duplicate-key bailout fires and logs the diagnostic.
- >25% churn (above the 8-op floor) bailout fires.
- Idempotency: second Apply with the same items emits zero events.
-
ReactorRowinstance identity is preserved for survivors. - 28 tests pass.
- Replace
Reconciler.Update.cs:2807-2808with the diff + a preservedRefreshRealizedContainerstail. - Replace
Reconciler.Update.cs:2833-2834with the same pattern forUpdateTemplatedGridView. - Keep
SetElementTag(lv, n)and the selected-index / control-setter tail intact.
- Change
Dictionary<int, Element>→Dictionary<string, Element>. UpdateGetElement,RecycleElement, andRefreshRealizedItems. -
GetElementtranslatesargs.DataasReactorRowfirst (row.Key+row.Index); legacy int path preserved. -
RefreshRealizedItemswalks tracked keys →state.ByKey[key].Indexto find the current realized container. No longer shifts on insert-at-0.
-
MountLazyStack: build aReactorListState, bindrepeater.ItemsSource = state.Source;, and plumb state into the factory vialazy.AttachListStateToFactory(...). -
UpdateLazyStack: replace the int-source swap withKeyedListDiff.Apply(state, ...). KeepTryUpdateFactory/RefreshRealizedItemsflow intact.
-
LazyHStackshares the same mount/update entry points asLazyVStack(single non-genericLazyStackElementBasedispatched onOrientation); 1.9 covers both. NewAttachListStateToFactoryoverride on the H variant matches the V variant.
- Add
tests/Reactor.AppTests.Host/SelfTest/Fixtures/KeyedListReconciliationFixtures.cs. → Extended at perf-gate close-out (2026-05-17) to 21 fixtures, 65 assertions: original 11 + 4 LazyVStack-specific (remove from middle, single move, prepend realized-element identity preservation) + 1 GridView (single move) + 3 hand-built FlexColumn (.WithKey remove / swap / reverse survivor identity) + 1 IReactorKeyed (.WithKey(item)overload survivor identity across insert). All pass against Phase 1 + Phase 2 + Phase 3 surface. Filed under selftest (in-process WinUI), not Appium, because the assertions inspect the OC event stream and attached state — there is no cross-process input injection required. - Test: insert-at-0 emits exactly one
Add(and noReset/Remove). Verifies WinUI sees an incremental delta.KLR_ListView_InsertAtZero_*. - Test: remove-from-end emits exactly one
Remove.KLR_ListView_RemoveFromEnd_*. - Test: single swap emits a
Moveaction — notInsert+Remove.KLR_ListView_MoveOne_*. - Test: bulk-replace (20-item 100% churn) exercises the bailout path
and ends up with the correct final state.
KLR_ListView_BulkReplace_TriggersBailout. - Test: GridView parity on insert.
KLR_GridView_InsertAtEnd_*. - Test: ItemsRepeater (LazyVStack) parity on insert at 0.
KLR_LazyVStack_InsertAtZero_*. - Test: hand-built
FlexColumn(items.Select(...WithKey(item.Id)))survivors keepRuntimeHelpers.GetHashCodeacross a prepend (regression gate for success criterion #3).KLR_FlexColumn_KeyedChildren_SurvivorIdentityPreserved.
- Rerun the stress perf matrix from 0.3 against the Phase 1 branch.
Store under
tests/stress_perf/baselines/keyed-list-post-phase1/. → Closed via paired Reactor-vs-WinUI-vanilla matrix instead of pre/post-Reactor (the prior path is gone). Captured attests/stress_perf/baselines/keyed-list-vs-winui-2026-05-17-104102/— 6-cell matrix ({1000, 10000}items ×{0, 4, 16}edits/sec) × 5 reps per cell + warm-up, paired Reactor / WinUI interleaving within each rep to neutralize DRR / thermal drift. Companion driver script:tests/stress_perf/run_keyed_list_vs_winui.ps1. - Compare against the pre-Phase-1 baseline. Pass criteria: median
frame time within ±3% on the steady-state list-render case; "insert at
0" case improves (fewer realized container teardowns).
→ PASS at 1 k items (the production-realistic size): Δ P50
= +0.1 % scroll-only, +0.1 % at 4 eps, +0.3 % at 16 eps — all
well inside ±3 %. At 10 k items the Δ P50 widens to +31–35 % but
the tail goes the other direction (Reactor P95 / P99 are
better than WinUI's by 6–17 %) and the gap doesn't move with
edit pressure — see
summary.mdfor the full histogram-level analysis. Filed as a per-frame-fixed-cost follow-up, not a reconciler regression. - If the diff allocation shows up in profiles, switch the per-update
"remaining old rows" dictionary to a pooled
Dictionary<string, ReactorRow>reused across renders on the same control. → Already done preemptively:ReactorListState.Scratchis the pooled per-control diff dictionary.
- In
samples/ReactorGallery/ControlPages/Collections/ListViewPage.cs, temporarily add a "shuffle 10 items" button. Visually confirm the WinUIRepositionThemeTransitionreads correctly on long-distance moves. Remove the button before merge — replace with the production sample in Phase 4. → Closed differently than written: rather than a throwaway button in the gallery, the canonical "Animated edit" card shipped in Phase 4.2 covers the same scenario withShuffleandReverseactions, and theAnimatedListDemomini-app exercises the long-distance move path underAnimations.Animate(...). Both paths are validated by theKLR_FlexColumn_KeyedChildren_Reverse_SurvivorsKeepIdentity+KLR_LazyVStack_MoveOne_EmitsSingleMoveselftest fixtures (no manual smoke needed for the survivor / op-shape gate).
- Add a
## Unreleasedentry toCHANGELOG.mdunder "Fixed": ListView/GridView/ItemsRepeater now surface incremental WinUI deltas for keyed list updates, fixing microsoft-ui-reactor#198. - Update spec §10 Phase 1 row with the merged-branch state once Phase 1
lands so future readers can navigate. (Updated to point at the
feat/042-keyed-list-reconciliationbranch; PR number filled in when the PR is opened.)
Optional ergonomics layer on top of Phase 1. Removes the per-call-site
KeySelector and per-element .WithKey(string) boilerplate for the common
case.
- Populate
src/Reactor/Core/IReactorKeyed.cs(placeholder from 0.2): one-property interfacestring Key { get; }with full XML docs explaining the convention and pointing to the spec. → The interface is already populated in Phase 0.2 with full XML docs. What remains for Phase 2 is the defaulting logic below. - Add an analyzer-friendly note in the doc comment: "The returned key must be stable for the lifetime of the item and unique across the list." → Done in Phase 0.2.
- In
TemplatedListElementBase(src/Reactor/Core/Element.cs:2811), add overloads / fallback soKeySelectordefaults tot => t.KeywhenT : IReactorKeyed. → Landed: 2-argwhere T : IReactorKeyedfactory overloads inDsl.cs(ListView / GridView / FlipView) that forward to the 3-arg form withstatic t => t.Key. The element record typeTemplatedListViewElement<T>is unchanged — defaulting happens at the factory layer so the diff path stays selector-agnostic. - Mirror on
LazyStackElementBase(andLazyHStackequivalent). → Landed: same 2-argIReactorKeyedoverloads for LazyVStack and LazyHStack. - Unit tests:
IReactorKeyed-typed list without explicitKeySelectorproduces the same diff ops as the same list with explicitt => t.Key. → Landed:tests/Reactor.Tests/IReactorKeyedTests.cs— 13 tests; covers GetKeyAt parity for all 5 factories and KeyedListDiff op-shape parity on insert / remove / move / reverse.
- Implement the overload in
src/Reactor/Elements/ElementExtensions.cs(or wherever the existing.WithKey(string)lives — confirm with a grep first). → Landed:WithKey<T, TKey>(this T el, TKey item)withwhere T : Element, where TKey : IReactorKeyed. Two type parameters keep the element-type fluent return and avoid ambiguity with the existing.WithKey(string). Guards null. - Unit test:
.WithKey(item)produces the sameElement.Keyas.WithKey(item.Key). → Landed:WithKey_IReactorKeyed_Sets_Element_Key_To_Item_Key+ element-type-preservation + null-throws tests.
- Update
samples/TodoApp/Todomodel to implementIReactorKeyedand drop the explicitKeySelectorat the ListView call site (proof of ergonomics). → Landed:TodoItemnow implementsIReactorKeyedwithstring IReactorKeyed.Key => Id;. The hand-built.WithKey(item.Id)at the TodoRow call site is now.WithKey(item). TodoApp builds clean; no behavior change. - Same sweep across any
samples/ReactorGallery/ControlPages/Collections/pages that use a list of POCOs. → Audit found only string-typed demos (e.g.items, s => s, (s, i) => …); strings cannot implementIReactorKeyed, so the gallery pages are left as the explicit-selector demo path.
- Add a "Keyed lists" section to
docs/guide/state-and-collections.md(create if needed) explaining the convention, when to opt in, and when explicitKeySelectoris still preferable (interop / legacy types you don't own). → Landed indocs/guide/collections.md(the existing guide page) as a new "Keyed reconciliation, in one paragraph" + "IReactorKeyed— identity on the data" + ".WithKey(item)for hand-built children" section sitting between ListView and LazyVStack so readers hit it on the natural reading path. - Cross-link from the existing
docs/guide/navigation index. →docs/guide/collections.mdis already listed indocs/guide/readme.md; the new sub-sections are reachable via the existing TOC anchor.
The SwiftUI analog. Carries animation intent (not operations) through an
AsyncLocal ambient from the state-setter call into the reconciler so the
resulting diff ops can be tagged with an animation kind.
Hard gate: do not begin Phase 3 until Phase 1 has merged and Q3 / Q4 from 0.1 have a documented answer.
- Populate
src/Reactor/Core/Animation.cs(placeholder from 0.2) with the fullAnimate(AnimationKind, Action)andAnimate<T>(AnimationKind, Func<T>)signatures from spec §6. → Landed; pass-through plus AsyncLocal scope. - Implement the
AsyncLocal<AmbientAnimation?>stack with proper push/pop in atry/finally. → Landed insrc/Reactor/Core/Internal/AmbientAnimation.cs—AnimationAmbient.ScopeRAII struct + AsyncLocal current.
- In
UseState/UseReducersetters (locate via grep on_pendingState/ similar), read the current ambient at dispatch time and stash it on the pending render request. → Landed via the same path as_pendingAnimationCurve:ReactorHost.RequestRender/ReactorHostControl.RequestRendercaptureAnimationAmbient.Currentinto a per-host snapshot field, which the render loop re-pushes viaAnimationAmbient.Scopearound_reconciler.Reconcile(...). Setters thus inherit the ambient indirectly through the host's render-request capture, which is what shields theAsyncLocalfromTask.Run(...)-without-awaitloss (spec 042 §9 Q3). - If multiple setters fire inside one
Animate(...), they share the ambient (already covered byAsyncLocalsemantics — write an explicit test). → Covered bytests/Reactor.Tests/AnimationAmbientTests.cs(Animate_Sets_Current_During_Action / nesting tests).
- Pass the captured
AmbientAnimationinto the diff entry point. → New optionalambientparameter onKeyedListDiff.Apply;Reconciler.Update.csreadsAnimationAmbient.Currentonce per diff and forwards. - For each
Insert/Move/Removeop emitted, configure the target container's transition per spec §6 — per-container Composition animation per Q4 resolution. → InsertedReactorRows carryPendingEnterAnimation; the templated control'sContainerContentChanginghandler attaches a per-container fade-up Composition animation on materialize. Survivor moves are reported viaDiffStats.MovedRowsand the caller fires an implicitOffsetanimation on the realized container (deferred one dispatcher turn so WinUI has reconciled positions). No sharedItemContainerTransitionsmutation — matches the Q4 per-container resolution.
- Plumb the ambient through
ChildReconciler.Reconcileso the hand-built path applies the same transition kind on mount/move/unmount. → Landed:ReconcilereadsAnimationAmbient.Currentonce and threads the kind throughReconcilePositional/ReconcileKeyed/ReconcileKeyedMiddle. Insert sites callApplyAmbientEnterIfActive; move sites callApplyAmbientMoveon the moved child; unmount sites go throughRemoveChildWithExitTransition, which now fabricates a fade-out exit when no.Transition()modifier is set. - Reuse the existing per-element
LayoutAnimation/ImplicitTransitionsmodifier wiring rather than inventing a parallel path — the ambient just becomes a default if no explicit per-element modifier is set. → Confirmed:ApplyAmbientEnterIfActiveno-ops when the element already hasElementTransition; per-element animation modifiers continue to win.
- Add a guard:
Animate(...)is not consumed by property setters on surviving leaves (colors, sizes). Document and test this — a leafTextBlockwhoseForegroundchanges insideAnimate(.Spring)does not animate the foreground. → Structural guard:AnimationAmbient(AsyncLocal) andAnimationScope(ThreadStatic) are two independent channels; Reactor's property-setter hot path (AnimationHelper.SetOrAnimate) only readsAnimationScope.Current. Pinned by three new tests intests/Reactor.Tests/Animation/AnimateScopeDisciplineTests.csplus theAAF_Animate_DoesNot_AnimateLeafPropertiesselftest fixture. - Update spec §6 with the final answer to Q4 (per-container Composition animations). → Spec §9 Q4 already captures the resolution; production code matches.
- Unit: ambient is observable in the dispatch callback (synchronous);
ambient is null after
Animatereturns. → Covered byAnimationAmbientTests. - Unit: two nested
Animate(...)calls — inner kind wins for state changes inside the inner; outer resumes after. → Covered byAnimationAmbientTests.Nested_Animate_Inner_Kind_Wins_InsideandNested_Animate_None_Suppresses_Outer. - AppTests:
Animate(.Spring, () => setItems([..items, x]))on a ListView produces a visibly different animation than the baresetItems(...)(asserted via the resultingStoryboard/Compositionanimation properties on the new container). → Landed intests/Reactor.AppTests.Host/SelfTest/Fixtures/AnimateAmbientFixtures.cs:AAF_ListView_InsertUnderAnimate_TagsRowWithKind/AAF_ListView_InsertWithoutAnimate_RowNotTagged/AAF_ListView_InsertUnderAnimateNone_RowNotTagged/AAF_ListView_MoveUnderAnimate_AttachesImplicitOffset. The Add-event assertion observes the insertedReactorRow'sPendingEnterAnimationsynchronously inside the OCCollectionChangedhandler (before the realize handler clears it); the Move-event assertion reads the moved container'sVisual.ImplicitAnimations["Offset"]after layout has run. - AppTests: hand-built
FlexColumnmount/unmount picks up the ambient. → Landed:AAF_FlexColumn_MoveUnderAnimate_AttachesImplicitOffset(in the same selftest file) drives aFlexColumnswap underAnimations.Animate(.Spring, ...)and asserts the movedBordercarries an implicitOffsetanimation.
- Add
docs/guide/animations.mdsection "Transactional animation" with side-by-side SwiftUI / Reactor examples. → Landed indocs/guide/animation.mdas the new "Transactional animation —Animations.Animate(...)" section above "WithAnimation Scope" — covers the example, scope discipline (whatAnimatedoes not do), nesting + explicit-Nonesuppression, and reduced-motion respect. - Cross-link from
docs/specs/042-...md§6. →docs/guide/animation.md's Transactional section references spec 042 §6 explicitly; spec §10 (phasing table) and the design's Phase 3 row already point at the same docs entry.
- Create
samples/apps/AnimatedListDemo/. Single-window app that demonstrates: insert-at-end, insert-at-0, remove, shuffle, bulk replace, all with and withoutAnimate(.Spring). → Landed assamples/apps/animated-list-demo/(kebab-case to match sibling samples). Renders the templatedListView<Row>and a hand-builtFlexColumn(items.Select(...).WithKey(item))side-by-side over the same data, so the OC-delta and ChildReconciler paths animate the same edit at the same time. Drives all seven ops (top, end, middle-remove, last-remove, shuffle, reverse, bulk-reset) through oneMutate(...)chokepoint that either commits directly or wraps inAnimations.Animate(...). Reduced-motion honored via the newComponent.UseReducedMotion()delegation (WCAG 2.3.3). - Wire into
samples/apps/Directory.Build.propsso it builds with the rest of the samples matrix. → Registered inReactor.slnxunder/samples/apps/animated-list-demo/; no per-folderDirectory.Build.propsexists, the repo usessamples/Directory.Build.propswhich the new csproj inherits via the standard MSBuild walk. - Add a
samples/apps/AnimatedListDemo/README.mdexplaining the demo and pointing back at spec 042.
- Update
samples/ReactorGallery/ControlPages/Collections/ListViewPage.csandLazyVStackPage(or equivalent) with an "Animated edit" toggle and a +/- buttons row. Same demo, embedded in the gallery. → Landed: thirdSampleCardonListViewPagetitled "Animated edit (spec 042)" with the same toolbar + Animate toggle as the mini-app. Reduced-motion bypass honored. The gallery does not currently ship aLazyVStackPage; the animated-list-demo mini-app already covers the LazyVStack / FlexColumn paths.
- Update
samples/TodoApp/to useIReactorKeyedonTodo(already done in 2.4) and wrap "add todo" / "delete todo" inAnimate(.Spring, () => ...). Smoke-test that the animation reads correctly with the OS reduced-motion setting respected. → Landed:Render()derives astructuraldispatcher (a => Animations.Animate(.Spring, () => dispatch(a))) thatAdd/Delete/Clear completedflow through.Toggle/SetFilter/SetNewItemTextkeep the bare dispatch since they don't change list identity.UseReducedMotion()collapses the wrapper to a passthrough when the OS opts the user out.
These keep the agent-kit reference docs in sync with the new platform feature so Claude Code (and other tools) can recommend the right pattern out of the box.
- Add
plugins/reactor/skills/reactor-dsl/references/keyed-lists.mdcovering:IReactorKeyed, explicitKeySelector,.WithKey(...), the hand-built.Select(...)pattern. → Landed. Covers all three call sites, the three.WithKeyoverloads, the diff behavior (incremental ops vs. bulk-replace bailout), duplicate / null-key diagnostics, and four explicit gotchas (OC-from-UseState, mixed keyed/unkeyed siblings, property mutations that don't trigger structural diffs, when not to useIReactorKeyed). - Cross-link from the skill's index file.
→
reactor-dsl/SKILL.mdnow carries a "focused topical references" table that points atreferences/keyed-lists.md.
- Add
plugins/reactor/skills/reactor-recipes/references/animated-list.mdwith the canonicalAnimate(.Spring, () => setItems(...))recipe. → Landed. Self-contained single-file program with theMutate(...)chokepoint pattern +UseReducedMotion()bypass (WCAG 2.3.3). - Include a "common mistakes" sub-section: mutating
ObservableCollectionfromUseState(doesn't work — Reactor compares by reference), forgettingKeySelectoron a non-IReactorKeyedtype. → Five mistakes documented with paired ❌ / ✓ examples: OC-from-UseState, missingkeySelectoron non-IReactorKeyedtypes, wrapping non-structural changes inAnimate, ignoring reduced motion, and capturing staleitemsin change closures. Cross-linked fromreactor-recipes/SKILL.mdandreferences/index.md.
- Run the skill's existing validation harness (find via
plugins/reactor/skills/.../tests/or equivalent) so the new references parse and link-check. → No automated harness exists underplugins/reactor/skills/. Manually verified: every relative link in the two new references and the AnimatedListDemo README resolves (8 / 8 OK), the YAML frontmatter parses, and the embedded C# code block matches the same API surface as the runnableanimated-list-demosample. Recommend a follow-up linter undertools/rather than blocking Phase 5 close-out on it.
- Roslyn analyzer rule
REACTOR_DSL_001: warn when a.Select(...)expression producesElementchildren passed to a panel-like factory (FlexColumn,VStack,Column, etc.) without any child calling.WithKey(...). Codefix offers.WithKey(item.Id)when the lambda parameter has a discoverableId/Keyproperty. → The diagnostic already shipped asREACTOR_DSL_001(MissingWithKeyAnalyzer) before spec 042 was filed; renaming would break downstream suppressions. Phase 6.1 instead completed the analyzer by addingMissingWithKeyCodeFix, which offers three insertion shapes ranked by discoverability:.WithKey(item)when the lambda parameter implementsIReactorKeyed,.WithKey(item.Key)when the type has a publicKeyproperty,.WithKey(item.Id)when it has a publicIdproperty. The codefix opts out ofFixAllProvidersince each lambda needs an independent semantic lookup of the parameter type. - Tests under
tests/Reactor.Tests/AnalyzerTests/. →MissingWithKeyAnalyzerTests.cs— 6 tests covering the analyzer's positive / negative paths and all three codefix offers. All pass underdotnet test.
- Surface the duplicate-key warning from 1.5 in the existing dev tools
overlay (find via grep on
Diagnostics/Devtools). One-shot per(control, set-of-duplicates)to avoid log spam. → Landed in three pieces: 1.Microsoft.UI.Reactor.Core.Diagnostics.ReactorDiagnostics— new public collector.RecentKeyedListWarningsreturns a bounded snapshot (newest-first, capped at 64 entries × 8 sample keys each). Producer side isinternal Record(...)/IsFirstOccurrence(...)with dedup keyed on (controlInstance, kind, hashed-sample-set). Per-control dedup uses aConditionalWeakTableso a torn-down control doesn't leak; contextual fallback uses a global concurrent dictionary for unit-test / standalone callers. 2.KeyedListDiff.Applygained acontrolInstanceparameter and now routes both bailout paths throughReportBailout, which records into the collector and logs throughILoggeronly on the first occurrence per triple — subsequent repeats bump the in-placeCountso the dev surface shows "fired 12×" without spamming the host log.Reconciler.Update.cspasses the livelvb/repeaterinstance through. 3.DevtoolsMenugot a new "Keyed-list diagnostics (N)" item that pops aContentDialoglisting each recent entry — timestamp, control type, kind (null key/duplicate keys), repeat count, and the truncated sample-key list. BehindReactorApp.DevtoolsEnabledso retail apps pay zero cost. Tests: 7 inReactorDiagnosticsTestscovering count bump, per-kind separation, per-control isolation,IsFirstOccurrence, sample truncation, and snapshot ordering; the existing 43KeyedListDiffTestsstill pass.
- Add a stress scenario "10k-item virtualized list, scroll + edit" to
tests/stress_perf/to catch future regressions in the ItemsRepeater key-indexed factory path. → Landed as--with-edits/--edits-per-second Nflags on the existingStressPerf.VirtualList.Reactorproject (rather than a fresh project — the scroll-only and scroll+edit modes share 90% of the harness). The edit timer fires 4 ops/sec by default, 50/50 insert/remove at random positions, deterministic seed. The report adds anEdits:line.ListItemSource.GenerateOne(id)added so synthesized items can carry ids that don't collide with the seed range. - Document the new scenario in the stress_perf README.
→ Added a "Scenario: 10k virtualized list, scroll + edit (spec
042 Phase 6.3)" section under the existing matrix, with the
headless command line, the expected report-shape, and the
analysis guidance ("if the gap to the edit-free baseline scales
with
count, the rekey path has regressed").
- Once Phases 1–5 ship, mark spec 042 status as Implemented with
the merged-PR list in the header.
→ Landed:
docs/specs/042-keyed-list-reconciliation-design.mdheader now reads Implemented (2026-05-17) with thefeat/042-keyed-list-reconciliationbranch state captured. - Close microsoft-ui-reactor#198. → Pending PR landing — close out from the merged PR's body, not from the feature branch.
- Fractional indexing helper for drag-to-reorder UIs without natural IDs (spec §8) — separate utility, not part of this work.
- CRDT-derived approaches — out of scope, explicitly rejected in spec §8.
UseList<T>op-capture hook — out of scope, explicitly rejected in spec §7.