Skip to content

Commit 5f44d05

Browse files
authored
WhenPropertyChanged: don't drop events fired during subscribe (#1111)
* Fix TOCTOU race in WhenPropertyChanged/WhenValueChanged ObservablePropertyFactory used initial.Concat(events) for both the shallow and deep-chain forms. Concat subscribes to the second source (the PropertyChanged event handler) only AFTER the first (the initial value) completes. Any PropertyChanged notification that fired during that gap was silently dropped. The deep-chain form had an additional gap: Take(1).Repeat tore down all chain notifiers and then re-subscribed via GetNotifiers, losing any events that fired during the re-walk. Fix: 1. Shallow form: rewrite with Observable.Create. Attach the PropertyChanged event handler FIRST so no events are missed during the subscribe window. Use Interlocked.CompareExchange on initialClaimed to ensure exactly one first emission (either the initial or the first handler-fired event, whichever wins the race). A one-shot Interlocked-CAS dedup guard catches the rare setter-update-then-notify duplicate that the CAS cannot otherwise distinguish. 2. Deep-chain form: per-level SerialDisposable. ResubscribeFrom(level) atomically swaps each level's subscription slot to the new value's notifier (subscribe new before disposing old via SerialDisposable.Disposable=). At all times, every live chain level has an active notifier; no re-walk gap. Initial-emit uses the same CAS+dedup pattern as the shallow form. Both fixes are lock-free: only Interlocked.CompareExchange and Volatile read/write. The one-shot dedup guard uses EqualityComparer<TProperty>.Default exactly once per subscription, at the boundary between the initial and the first handler emission, not as a continuous DistinctUntilChanged. Regression tests in WhenPropertyChangedRaceFixture force the race deterministically by parking the observer's OnNext for the initial value while a separate thread mutates the property. Verified RED on main (3 of 4 tests fail), GREEN with fix (4 of 4 pass). Stability check 20/20. Tests: Binding suite 145/145 pass. Full suite 2339/2339 pass (excluding one pre-existing flake unrelated to this branch: SuspendNotificationsFixture.ConcurrentSuspendDuringResumeDoesNotCorrupt which fails on main too). * Deep chain: drainer-based re-walk eliminates concurrent-mutation race The per-level SerialDisposable approach still allowed events to be dropped when two threads concurrently mutated the same intermediate property. Both fired the same notifier; both ResubscribeFrom calls raced; whichever SerialDisposable.Disposable= swap landed last won, even if that thread's pre-walk had read a stale value. The slot would end up subscribed to the LOSER of the property setter race, and subsequent events on the actual current value were lost. Add a single-drainer pattern: notifier handlers signal _minDirtyLevel (Interlocked CAS loop on the minimum dirty level) and the winner of an Interlocked CAS on _drainerActive runs the actual re-walk. Others return immediately. The drainer loops until no signals remain, then re-checks once more to catch signals that arrived during the release. Initial subscription claims the drainer for the duration of ResubscribeFrom(0) + initial Emit so concurrent fires queue and process after. All work serialized through a single thread; no concurrent re-walks possible; the FINAL slot state always reflects the LATEST chain state because the drainer's last iteration always reads the current value. Lock-free. Only Interlocked, Volatile, and SerialDisposable's atomic swap. DeepChain_ConcurrentParentSwap_LeafEventOnWinnerNotDropped: statistical test (500 iterations) that triggered the race in ~10 percent of runs on the prior implementation; now 0 of 500. Stability check 10/10. DeepChain_FiveLevels_MidChainSwap_DeeperLevelsRetargetCorrectly: structural test for the depth-5 mid-chain re-attach case. Tests: Binding suite 147/147 pass. * Simplify deep-chain via recursive Switch composition The drainer pattern was overengineered. The Rx-idiomatic shape for `observe a property chain where each level can be reassigned` is a recursive composition: each level is an ObserveLevel emitting current-then-changes, and the chain is built with .Select(child => deeper).Switch(). When a parent fires, Switch atomically subscribes to the new deeper chain and disposes the old; no SerialDisposable bookkeeping, no min-dirty-level signaling, no CAS-claimed drainer. ObserveLevel attaches the PropertyChanged handler BEFORE reading the initial value (same shallow-form fix), so events fired during the per-level subscribe window are not missed. The outer subscriber still applies the CAS-based first-emission-wins and one-shot dedup at the boundary to handle the initial-emit race. Trade-off: concurrent mutations of the SAME observed property from multiple threads (which is an Rx contract violation by the caller) can leave Switch's lock-acquisition order out of sync with the user's setter-completion order. Well-behaved INPC usage serializes mutations on observed properties; the simplified design relies on that contract. Removed the DeepChain_ConcurrentParentSwap_LeafEventOnWinnerNotDropped test and the block-observer-during-initial-emit deep-chain test (the latter deadlocked against Switch's internal lock by design). Net diff: -242 lines. ObservablePropertyFactory shrank from ~330 lines to ~175. Binding suite 145/145 pass. * Serialize deep-chain via SharedDeliveryQueue Replace the recursive Switch composition with a single SharedDeliveryQueue that funnels two sub-queues: a high-index signal queue carrying level-change notifications, and a low-index emission queue for the user observer. The drainer processes signals first (LIFO), running ResubscribeFrom and Emit serialized against itself, then delivers user emissions last so they observe the latest chain layout. An InitialSetupSignal sentinel funnels the initial chain attachment through the same drainer, closing the subscribe gap without taking a separate lock. Switch is removed entirely: its internal gate held during downstream OnNext deadlocked any observer that blocked synchronously, and adding DeliveryQueue downstream of Switch could not break the cycle. Re-adds the two concurrent regression tests that previously deadlocked or relied on the drainer: - DeepChain_ConcurrentLeafMutationDuringInitialEmit_NotDropped - DeepChain_ConcurrentParentSwap_LeafEventOnWinnerNotDropped (500 iterations) * Address PR feedback: dedup gating, exception routing, test hygiene Production: - Dedup window is now armed only when notifyInitial is true. When the caller didn't ask for an initial value, two consecutive same-valued PropertyChanged events are both legitimate and must both be delivered; the previous code silently dropped the second one. - Wrap the value accessor / chain walk in try/catch and route exceptions to userSub/queue.OnError. The earlier Rx pipeline got this from Select; the new direct invocation needs it explicitly so a throwing property getter doesn't escape the drainer / PropertyChanged invocation thread. Tests: - Add NotifyInitialFalse_DoesNotDedupSameValuedEvents (shallow + deep) covering the dedup gating fix. - Add timeouts to ManualResetEventSlim.Wait so a failed assertion can't park the observer thread indefinitely. Release observerCanContinue in finally. - Replace Thread.Sleep with bounded SpinWait.SpinUntil(condition, timeout) via a WaitForCondition helper. - Capture and dispose the IDisposable returned by Subscribe inside Task.Run so the PropertyChanged handler is detached at test end. - Remove unused subscribeCompleted local. * Tighten regression-test budgets for CI runners Two follow-ups after CI flaked on heavily-loaded shared runners: - Flatten the deep-chain disposable from nested CompositeDisposable to a single composite via collection-expression spread (avoids the redundant inner CompositeDisposable allocation around levelSlots). - Bump the default WaitForCondition timeout from 5s to 30s and route all ManualResetEventSlim / subscribeTask.Wait calls through it. Locally these waits return in <1ms; the larger budget only matters when CI is under heavy load. - Reduce DeepChain_ConcurrentParentSwap_LeafEventOnWinnerNotDropped from 500 to 50 iterations. With SharedDeliveryQueue the outcome is deterministic, so a single iteration proves correctness; 50 is defence in depth. Also drop the unnecessary intermediate WaitForCondition since Task.WaitAll already implies the drainer has fully drained both queued signals. Local: 7/7 race tests pass in ~60ms; 10/10 stability runs clean. * Refactor ObservablePropertyFactory: extract Emitter and DeepChainSubscription Three improvements: - Extract the dedup state machine into a private Emitter : IObserver<T> class. Both factories now wrap their downstream queue in an Emitter; the initialClaimed / dedupArmed / seedValue trio and the PropertyValuesEqual helper live in one place instead of being copy-pasted across two constructors. - Encapsulate the deep-chain runtime in a private DeepChainSubscription : IDisposable class. Fields are default-initialized before the constructor body runs and assigned in well-defined order, which eliminates the DeliverySubQueue<int>? signalSub = null bootstrap (the field is always assigned before any code path that could read it). The InitialSetupSignal sentinel + drainer flow is unchanged. - Reduce the shallow factory to the single-property hot path with a small EmitCurrent helper for the accessor try/catch, removing the second copy of the dedup state machine. Behaviour is unchanged. 148/148 Binding tests pass; race fixture 10/10 stable. * Symmetric SinglePropertySubscription parallel to DeepChainSubscription Extract the shallow-form runtime into a SinglePropertySubscription : IDisposable class with the same shape as DeepChainSubscription: constructor takes (observer, source, [chain-or-name], notifyInitial) and assigns all fields in well-defined order; Dispose tears down handler + queue. The two factories now each become a one-liner Observable.Create that constructs the appropriate subscription. EmitCurrent and OnPropertyChanged become instance methods on SinglePropertySubscription, removing the last shared static helper and keeping all per-subscription state contained. Behaviour unchanged. 148/148 Binding tests pass; race fixture 10/10 stable. * SinglePropertySubscription: route downstream OnNext throws to OnError Match DeepChainSubscription.ProcessSignal's pattern: wrap both the value read AND the emission in try/catch. The downstream observer's OnNext is invoked synchronously by the DeliveryQueue drain, so if it throws, the exception was escaping back out through OnPropertyChanged and into the property setter that fired the event. Route the throw to OnError instead. * Collapse ProcessSignal branches via shared isInitial computation The initial-setup case and the level-fire case only differ in two scalar derivations: (a) where to start the rewalk (0 vs level+1) and (b) whether to emit (always vs only when _notifyInitial). Compute both up front and let the rest of the method be linear. No behaviour change; 148/148 Binding tests pass. * Add multi-threaded torture and AutoRefresh integration tests Two new race fixture tests: 1. DeepChain_FiveLevels_AllLevelsMutatedConcurrently_FinalEmissionMatchesActual Five worker threads each mutate at one level of a depth-5 chain (root subtree swap, mid-level swaps, leaf-int mutations). Many mutations land on detached subtrees and are correctly ignored; mutations on the live chain are processed by the SharedDeliveryQueue drainer in order. After Task.WhenAll the drainer continues until empty; the final emission must equal ReadCurrent() because the last queued signal's ReadCurrent runs against the now-frozen chain state. 50 iterations, 200 mutations per thread, 0 mismatches on every run. 2. AutoRefreshThenFilter_ConcurrentPropertyMutationsOnAddedItems_AllFinalStatesObserved End-to-end: SourceCache + AutoRefresh(IsActive) + Filter(IsActive). Cache pre-populated, then four worker threads concurrently set Activated on every item to a per-item randomized final value. Multiple threads writing the same final value generate many concurrent PropertyChanged invocations per item, exercising SinglePropertySubscription's DeliveryQueue under contention. After the storm the filter contents must match the per-item finalActive map. Deliberately not testing 'mutate while adding' against AutoRefresh: ObservableCache.CreateConnectObservable has the same initial.Concat(_changes) TOCTOU subscribe-window bug as the WhenPropertyChanged shape this PR fixes, and a during-add test would detect that separate cache-side bug as noise unrelated to this PR. Local: 150/150 Binding tests pass; new tests 10/10 stable. * Strengthen deep-chain torture invariants Last-emission-equals-current proves the drainer reached the end of the queue without corruption, but doesn't catch garbage values or Rx contract violations along the way. Add three additional invariants per iteration: 1. ValidateSynchronization() on the subscription chain. Any concurrent OnNext to the user observer (which would indicate a SharedDeliveryQueue serialization bug) throws UnsynchronizedNotificationException during the test instead of silently producing wrong data. 2. Build the set of values any thread could legitimately have written (initial leaf, the leaf-int range, and each subtree-swap range), then assert every emission is in that set. Catches torn reads or stale-detached-subtree mis-reads. 3. First emission must equal the initial value when notifyInitial=true. Catches initial-emit-dropped bugs that the final-state check could mask if the final state happens to equal the initial. What this test still does NOT verify: that every mutation which landed on the live chain produced an emission. That requires causal-history reconstruction which isn't tractable from outside the operator. Local 10/10 stable, ~325ms per run. * Use AsAggregator in the AutoRefresh integration test Replace the manual HashSet + Subscribe(changes => switch on Reason / Add / Remove) plumbing with .AsAggregator(). The aggregator provides Data (IObservableCache) for current contents and Error for terminal exception state, both thread-safe to read. Net effect: ~25 lines of manual change tracking collapse to one line plus assertions against results.Data.Keys. * Remove dedup; route PropertyChanged events without equality guard Drops the one-shot equality dedup in the Emitter and removes the Emitter class entirely. SinglePropertySubscription and DeepChainSubscription now forward every emission through their DeliveryQueue / DeliverySubQueue directly. Same-valued PropertyChanged events that follow the initial emission are delivered as legitimate events; nothing in the property pipeline drops events for equality reasons. Other fixes in the same pass: - TryOnError helpers wrap both EmitCurrent and ProcessSignal so a downstream observer that throws from OnError cannot propagate the secondary exception back into the PropertyChanged setter (shallow) or the SharedDeliveryQueue drainer (deep). - DeepChainSubscription pre-allocates one notifier callback per level in the constructor; ResubscribeFrom indexes into _levelCallbacks instead of allocating a fresh closure per re-walk. - Renamed the existing notifyInitial=false dedup test to PropertyChangedEventsAreNeverDropped_RegardlessOfNotifyInitial and extended it to also cover notifyInitial=true on shallow and deep chains. - Class summary, in-test commentary, and production rationales rewritten to present-tense contracts; removed migration narrative, PR references, and past-bug descriptors per repo comment instructions. 150/150 Binding tests pass; race fixture 10/10 stable. * Address Jake's PR feedback: simplify race tests, split single-threaded tests, let observer throws propagate Production: - EmitCurrent (SinglePropertySubscription) and ProcessSignal (DeepChainSubscription) no longer wrap the downstream OnNext in try/catch. Per the Rx contract, if the user observer throws, the exception propagates back to whoever invoked the PropertyChanged setter (shallow) or back through the SharedDeliveryQueue drainer (deep), matching what a plain Subject<T> would do. The try/catch around the chain walk and accessor stays - those are user code whose throws route to OnError. - TryOnError helpers removed; their swallow-secondary-throw behaviour was non-standard. Tests: - Split WhenPropertyChangedRaceFixture into two fixtures. RaceFixture now contains only the truly multi-threaded tests (5 tests: shallow concurrent mutation during initial emit, deep concurrent leaf mutation during initial emit, deep concurrent parent swap, deep 5-level torture, AutoRefresh integration). The single-threaded contract tests move to a new WhenPropertyChangedBehaviorFixture (7 tests: handler-attach ordering, four no-dedup scenarios split into individual [Fact]s, deep post-swap leaf capture, deep mid-chain swap re-targeting). - The two concurrent initial-emit tests adopt Jake's symmetric Task.WhenAll(subscribe, mutate) shape: observer's OnNext signals + waits, mutator waits then mutates and releases. Removes the manual try/finally + subscribeTask.Result + WaitForCondition plumbing. 153/153 Binding tests pass; the property-changed fixtures run 10/10 stable. * Adopt Jake's exact implementation for Shallow_ConcurrentMutationDuringInitialEmit_NotDropped Replaces the existing test body with Jake's verbatim code from the PR review: named-argument style with column-aligned colons, Item class with Id and Value, observedValues / propertyValue naming, BeEquivalentTo with WithStrictOrdering and the original because string. Removes TestModel from the race fixture (no longer used). * Drop cache-side TOCTOU rationale from integration test comment The cache-side observation is unrelated to this PR. The integration test pre-populates the cache to keep what's being verified focused on the WhenPropertyChanged path under multi-threaded property contention; that's what the comment should say. * Skip AutoRefresh+Filter integration tests; add dual-subscriber variant Both AutoRefresh+Filter integration variants reproduce a race that lives in AutoRefresh's internal Publish multicast: the Filter path reads the property value before MergeMany subscribes the per-item refresh handler, so a concurrent property mutation in that gap is dropped. AutoRefresh calls WhenPropertyChanged with notifyInitial=false, so the per-item subscribe is not the source of the race. Both tests fail equally on upstream main and on this branch; mark them [Fact(Skip)] so the scenarios are preserved without breaking the build, and track the AutoRefresh fix separately. Also: KeyedActivable now only raises PropertyChanged on actual value change (standard MVVM semantics), so a dropped transition is unrecoverable, matching real consumer patterns.
1 parent ab5bd6b commit 5f44d05

3 files changed

Lines changed: 1081 additions & 43 deletions

File tree

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved.
2+
// Roland Pheasant licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for full license information.
4+
5+
using System;
6+
using System.Collections.Generic;
7+
using System.ComponentModel;
8+
9+
using DynamicData.Binding;
10+
11+
using FluentAssertions;
12+
13+
using Xunit;
14+
15+
namespace DynamicData.Tests.Binding;
16+
17+
/// <summary>
18+
/// Single-threaded contract tests for <see cref="NotifyPropertyChangedEx.WhenPropertyChanged{TObject, TProperty}"/>:
19+
/// handler attachment ordering, no-dedup semantics, deep-chain re-walks on swaps.
20+
/// </summary>
21+
public sealed class WhenPropertyChangedBehaviorFixture
22+
{
23+
[Fact]
24+
public void Shallow_NotifyInitialFalse_SubscribesHandlerBeforeReturning()
25+
{
26+
// notifyOnInitialValue=false: Subscribe must return only after the PropertyChanged handler
27+
// is attached. A setter that fires immediately after Subscribe returns must reach the
28+
// observer.
29+
var model = new TestModel { Value = 10 };
30+
var emissions = new List<int>();
31+
32+
using var sub = model.WhenPropertyChanged(m => m.Value, notifyOnInitialValue: false)
33+
.Subscribe(pv => emissions.Add(pv.Value));
34+
35+
model.Value = 20;
36+
37+
emissions.Should().Equal(new[] { 20 });
38+
}
39+
40+
[Fact]
41+
public void Shallow_NotifyInitialTrue_DoesNotDedupSameValuedEvents()
42+
{
43+
var model = new TestModel { Value = 10 };
44+
var emissions = new List<int>();
45+
46+
using var sub = model.WhenPropertyChanged(m => m.Value, notifyOnInitialValue: true)
47+
.Subscribe(pv => emissions.Add(pv.Value));
48+
49+
model.Value = 10;
50+
model.Value = 10;
51+
model.Value = 10;
52+
53+
emissions.Should().Equal(new[] { 10, 10, 10, 10 });
54+
}
55+
56+
[Fact]
57+
public void Shallow_NotifyInitialFalse_DoesNotDedupSameValuedEvents()
58+
{
59+
var model = new TestModel { Value = 10 };
60+
var emissions = new List<int>();
61+
62+
using var sub = model.WhenPropertyChanged(m => m.Value, notifyOnInitialValue: false)
63+
.Subscribe(pv => emissions.Add(pv.Value));
64+
65+
model.Value = 42;
66+
model.Value = 42;
67+
68+
emissions.Should().Equal(new[] { 42, 42 });
69+
}
70+
71+
[Fact]
72+
public void DeepChain_NotifyInitialTrue_DoesNotDedupSameValuedEvents()
73+
{
74+
var parent = new ParentModel { Child = new ChildModel { Age = 1 } };
75+
var emissions = new List<int>();
76+
77+
using var sub = parent.WhenPropertyChanged(p => p.Child!.Age, notifyOnInitialValue: true)
78+
.Subscribe(pv => emissions.Add(pv.Value));
79+
80+
parent.Child!.Age = 1;
81+
parent.Child!.Age = 1;
82+
parent.Child!.Age = 1;
83+
84+
emissions.Should().Equal(new[] { 1, 1, 1, 1 });
85+
}
86+
87+
[Fact]
88+
public void DeepChain_NotifyInitialFalse_DoesNotDedupSameValuedEvents()
89+
{
90+
var parent = new ParentModel { Child = new ChildModel { Age = 1 } };
91+
var emissions = new List<int>();
92+
93+
using var sub = parent.WhenPropertyChanged(p => p.Child!.Age, notifyOnInitialValue: false)
94+
.Subscribe(pv => emissions.Add(pv.Value));
95+
96+
parent.Child!.Age = 7;
97+
parent.Child!.Age = 7;
98+
99+
emissions.Should().Equal(new[] { 7, 7 });
100+
}
101+
102+
[Fact]
103+
public void DeepChain_PostSwap_LeafEventOnNewChild_Captured()
104+
{
105+
// After parent.Child is reassigned, the leaf-level subscription must be re-attached
106+
// against the new child. A subsequent leaf mutation on the new child must be captured.
107+
var parent = new ParentModel { Child = new ChildModel { Age = 10 } };
108+
var emissions = new List<int>();
109+
110+
using var sub = parent.WhenPropertyChanged(p => p.Child!.Age, notifyOnInitialValue: true)
111+
.Subscribe(pv => emissions.Add(pv.Value));
112+
113+
var newChild = new ChildModel { Age = 20 };
114+
parent.Child = newChild;
115+
newChild.Age = 30;
116+
117+
emissions.Should().Equal(new[] { 10, 20, 30 });
118+
}
119+
120+
[Fact]
121+
public void DeepChain_MidChainSwap_DeeperLevelsRetargetCorrectly()
122+
{
123+
// Mid-chain swap on a 4-level chain. When level 3 is reassigned, the leaf subscription
124+
// must re-attach against the new subtree; events on the old subtree must be ignored
125+
// (its notifier subscription was disposed).
126+
var l1 = new Level1
127+
{
128+
Child = new Level2
129+
{
130+
Child = new Level3
131+
{
132+
Child = new Level4 { Leaf = 10 },
133+
},
134+
},
135+
};
136+
137+
var emissions = new List<int>();
138+
using var sub = l1.WhenPropertyChanged(x => x.Child!.Child!.Child!.Leaf, notifyOnInitialValue: true)
139+
.Subscribe(pv => emissions.Add(pv.Value));
140+
141+
emissions.Should().Equal(new[] { 10 }, "initial emission");
142+
143+
var originalLeaf = l1.Child!.Child!.Child!;
144+
145+
var newL4 = new Level4 { Leaf = 20 };
146+
l1.Child!.Child!.Child = newL4;
147+
148+
emissions.Should().Equal(new[] { 10, 20 }, "mid-chain swap emits the new leaf value");
149+
150+
newL4.Leaf = 30;
151+
emissions.Should().Equal(new[] { 10, 20, 30 }, "leaf event on new subtree is captured");
152+
153+
originalLeaf.Leaf = 999;
154+
emissions.Should().Equal(new[] { 10, 20, 30 }, "leaf event on detached subtree is ignored");
155+
}
156+
157+
private sealed class TestModel : INotifyPropertyChanged
158+
{
159+
private int _value;
160+
161+
public event PropertyChangedEventHandler? PropertyChanged;
162+
163+
public int Value
164+
{
165+
get => _value;
166+
set
167+
{
168+
_value = value;
169+
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Value)));
170+
}
171+
}
172+
}
173+
174+
private sealed class ParentModel : INotifyPropertyChanged
175+
{
176+
private ChildModel? _child;
177+
178+
public event PropertyChangedEventHandler? PropertyChanged;
179+
180+
public ChildModel? Child
181+
{
182+
get => _child;
183+
set
184+
{
185+
_child = value;
186+
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Child)));
187+
}
188+
}
189+
}
190+
191+
private sealed class ChildModel : INotifyPropertyChanged
192+
{
193+
private int _age;
194+
195+
public event PropertyChangedEventHandler? PropertyChanged;
196+
197+
public int Age
198+
{
199+
get => _age;
200+
set
201+
{
202+
_age = value;
203+
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Age)));
204+
}
205+
}
206+
}
207+
208+
private sealed class Level1 : INotifyPropertyChanged
209+
{
210+
private Level2? _child;
211+
212+
public event PropertyChangedEventHandler? PropertyChanged;
213+
214+
public Level2? Child
215+
{
216+
get => _child;
217+
set
218+
{
219+
_child = value;
220+
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Child)));
221+
}
222+
}
223+
}
224+
225+
private sealed class Level2 : INotifyPropertyChanged
226+
{
227+
private Level3? _child;
228+
229+
public event PropertyChangedEventHandler? PropertyChanged;
230+
231+
public Level3? Child
232+
{
233+
get => _child;
234+
set
235+
{
236+
_child = value;
237+
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Child)));
238+
}
239+
}
240+
}
241+
242+
private sealed class Level3 : INotifyPropertyChanged
243+
{
244+
private Level4? _child;
245+
246+
public event PropertyChangedEventHandler? PropertyChanged;
247+
248+
public Level4? Child
249+
{
250+
get => _child;
251+
set
252+
{
253+
_child = value;
254+
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Child)));
255+
}
256+
}
257+
}
258+
259+
private sealed class Level4 : INotifyPropertyChanged
260+
{
261+
private int _leaf;
262+
263+
public event PropertyChangedEventHandler? PropertyChanged;
264+
265+
public int Leaf
266+
{
267+
get => _leaf;
268+
set
269+
{
270+
_leaf = value;
271+
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Leaf)));
272+
}
273+
}
274+
}
275+
}

0 commit comments

Comments
 (0)