Commit 599360b
fix(virtualization): proper ItemsRepeater recycling — stop leaking every realized container (#324)
* fix(virtualization): proper ItemsRepeater recycling — stop leaking every realized container
ElementFactory<T> never reused args.Element from RecycleElement — every
GetElement built a fresh Reactor Element tree and Mounted a fresh WinUI
control. Per microsoft-ui-xaml-lift `ViewManager.cpp:865-869` and
`ItemTemplateWrapper.cpp:120-136`, ItemsRepeater keeps every realized
UIElement parented to itself forever and expects the IElementFactory to
cycle them through GetElement/RecycleElement. The "fresh build every
time" pattern produced one orphan in Repeater.Children per realize.
Empirical: scrolling DataSystemDemo > VirtualList (Fixed Height, 100k
items) leaked ~75 KB working set per realize and unbounded managed
heap. Variable-height demo crashed with stowed exception 0xC000027B
once the orphan count interacted with structural divergence between
expanded and collapsed rows in RefreshRealizedItems.
Fix:
- ElementFactory.RecycleElement pushes args.Element onto a recycle
stack and drops _mountedElements/_keyByControl tracking. It does NOT
call UnmountChild — the WinUI tree stays alive for reuse.
- ElementFactory.GetElement pops from the recycle stack first and calls
Reconciler.Reconcile(oldElement, newElement, control, ...) to diff
the existing tree against the new content. Allocates fresh only when
the pool is empty.
- _lastElementByControl tracks the last-bound Element per control so
reuse has an oldElement to diff against.
- _keyByControl reverse-lookup map (also new) lets RecycleElement drop
the _mountedElements key entry in O(1) — without it, stale entries
drove Reconcile against mismatched realized children when
RefreshRealizedItems ran during a subsequent update, which is what
was triggering the variable-height demo's 0xC000027B crash.
FlexPanel: stop subscribing to XamlRoot.Changed per instance.
Loaded/Unloaded do not fire reliably for ItemsRepeater-recycled
containers (in this repro Unloaded fired for 12 of 2,774 containers),
so the subscribe in OnLoaded leaked every FlexPanel through XamlRoot's
multicast delegate list. Replaced with SyncPointScaleLazy() called
from MeasureOverride — reads XamlRoot.RasterizationScale directly and
updates YogaConfig.PointScaleFactor when it changes. No subscription,
same DPI-tracking behavior.
Result: at 14,506 realize/recycle cycles, FlexPanel constructor count
stays at 50 (the working set), managedMB flat at 17, workingSetMB flat
at 238. Previously these climbed 1:1 with realize count.
Tests: Reactor.Tests 7648 passed / Reactor.SelfTests 714 passed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(virtualization): pin the ItemsRepeater recycling contract
Adds three SelfTest fixtures that exercise ElementFactory<T> directly
through its IElementFactory interface, capturing the invariants that
the prior leak violated:
- EFR_Factory_BoundedDistinctControls_AcrossManyRealizeCycles:
100 realize-then-recycle cycles return ≤5 distinct UIElement instances
(pre-fix: 100, one per cycle). The headline regression gate.
- EFR_Factory_RecycledControlIsReusedOnNextRealize:
The control handed back via RecycleElement IS the control returned by
the next GetElement (reference equality). When the recycle pool is
empty, the next GetElement Mints fresh — different control.
- EFR_Factory_BookkeepingBoundedAcrossCycles:
After 50 realize+recycle pairs, internal dicts (_mountedElements,
_keyByControl, _recyclePool, _lastElementByControl) stay bounded at
≤1 each. Pre-fix _mountedElements / _keyByControl would have grown
to 50. Catches a second class of bug where the control gets reused
but bookkeeping entries accumulate — the exact corruption that drove
the variable-height demo's 0xC000027B crash via stale entries in
RefreshRealizedItems.
Fixtures drive a standalone ElementFactory<T> + Reconciler (no real
LazyVStack / ItemsRepeater) so the IElementFactory contract is the only
thing exercised. Standalone isolation matters: when an earlier draft
shared the same data items with a live LazyVStack, the framework's
pre-realized window + our cycles collided on string keys and corrupted
state — informative but not what the test intends to measure.
ElementFactory<T> exposes four `Debug*Count` accessors (gated by the
existing InternalsVisibleTo on Reactor.AppTests.Host) so the
bookkeeping fixture can read dict counts without reflection.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix+test(virtualization): address PR #324 review feedback
Three edge cases Copilot's review caught in the recycle path, plus
SelfTest fixtures that pin each one as a regression gate. The original
fix is solid for homogeneous-row demos like DataSystem; these are real
defects only the broader fleet (heterogeneous rows, re-renders during
scroll, navigation away from a LazyStack) would exercise.
1. ElementFactory.GetElement, heterogeneous-row replacement
When Reconcile returns a non-null replacement (root element type
changed mid-cycle), the popped `reused` control is still parented
to the ItemsRepeater — re-introducing the very orphan-in-Children
leak the recycle pool was meant to fix. Now we DetachFromParent
the orphan and drop its _lastElementByControl tracking before
returning the replacement. New fixture:
EFR_Factory_ReplacementOnRootTypeChange_DropsOldControlTracking.
2. ElementFactory.RefreshRealizedItems, stale _lastElementByControl
The refresh path updated _mountedElements[key] with the new
Element but left _lastElementByControl[control] pointing at the
pre-refresh Element. A later recycle-then-reuse of the same
control then fed Reconcile a stale oldElement and diffed against
the wrong tree shape (potentially skipping updates or walking the
wrong children). Refresh now writes both dicts together. New
fixture: EFR_Factory_RefreshRealizedItems_SyncsLastElementByControl.
3. Reconciler.UnmountRecursive, ItemsRepeater children
Verified via microsoft-ui-xaml-lift/.../ItemsRepeater.idl:154 —
ItemsRepeater projects to C# as a FrameworkElement, not Panel,
even though its C++ implementation uses DeriveFromPanelHelper.
So the existing Panel branch in UnmountRecursive did NOT descend
into repeater children, leaving every row Component's UseEffect
cleanups unrun when the LazyStack itself unmounted (navigation,
conditional render). Long-lived timers / subscriptions / async
loops inside row Components would leak forever post-navigation.
Added an ItemsRepeater branch that walks via VisualTreeHelper
(the public surface doesn't expose Children directly). New
fixture: EFR_LazyStack_Unmount_CleansUpAllRecycledRowComponents.
Not addressed in this PR (design-scope, documented for follow-up):
Per-row Component identity. LazyStack's user keySelector is not
propagated to row Element.Key, so reusing a control across two
logical items preserves any inner Component's UseState/UseEffect
state. This is the same behavior as WinUI RecyclingElementFactory
and is the documented expectation, but Reactor should give users
a clean way to opt into per-item state reset. Tracking separately.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>1 parent 516aa2d commit 599360b
5 files changed
Lines changed: 530 additions & 64 deletions
File tree
- src/Reactor
- Core
- Yoga
- tests/Reactor.AppTests.Host/SelfTest
- Fixtures
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
33 | 33 | | |
34 | 34 | | |
35 | 35 | | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
36 | 73 | | |
37 | 74 | | |
38 | 75 | | |
| |||
126 | 163 | | |
127 | 164 | | |
128 | 165 | | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
129 | 172 | | |
130 | 173 | | |
131 | 174 | | |
| |||
160 | 203 | | |
161 | 204 | | |
162 | 205 | | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
163 | 246 | | |
164 | | - | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
165 | 253 | | |
166 | 254 | | |
167 | 255 | | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
168 | 277 | | |
169 | 278 | | |
170 | 279 | | |
171 | 280 | | |
172 | | - | |
173 | | - | |
174 | | - | |
175 | | - | |
176 | | - | |
177 | | - | |
178 | | - | |
179 | | - | |
180 | | - | |
181 | | - | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
182 | 286 | | |
183 | | - | |
184 | | - | |
185 | | - | |
186 | | - | |
187 | | - | |
188 | | - | |
189 | | - | |
190 | | - | |
191 | | - | |
192 | | - | |
193 | | - | |
194 | | - | |
195 | | - | |
196 | | - | |
197 | | - | |
198 | | - | |
199 | | - | |
200 | | - | |
201 | | - | |
202 | | - | |
203 | | - | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
204 | 293 | | |
205 | 294 | | |
206 | | - | |
207 | | - | |
208 | | - | |
209 | | - | |
210 | 295 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1101 | 1101 | | |
1102 | 1102 | | |
1103 | 1103 | | |
| 1104 | + | |
| 1105 | + | |
| 1106 | + | |
| 1107 | + | |
| 1108 | + | |
| 1109 | + | |
| 1110 | + | |
| 1111 | + | |
| 1112 | + | |
| 1113 | + | |
| 1114 | + | |
| 1115 | + | |
| 1116 | + | |
| 1117 | + | |
| 1118 | + | |
| 1119 | + | |
| 1120 | + | |
| 1121 | + | |
| 1122 | + | |
1104 | 1123 | | |
1105 | 1124 | | |
1106 | 1125 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
31 | 31 | | |
32 | 32 | | |
33 | 33 | | |
34 | | - | |
35 | 34 | | |
36 | 35 | | |
37 | 36 | | |
38 | 37 | | |
39 | | - | |
40 | 38 | | |
41 | 39 | | |
42 | 40 | | |
43 | | - | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
44 | 51 | | |
45 | | - | |
46 | | - | |
47 | | - | |
48 | | - | |
49 | | - | |
50 | | - | |
51 | | - | |
52 | | - | |
53 | | - | |
54 | | - | |
55 | | - | |
56 | | - | |
57 | | - | |
58 | | - | |
59 | | - | |
60 | | - | |
61 | | - | |
62 | | - | |
63 | | - | |
| 52 | + | |
64 | 53 | | |
65 | 54 | | |
66 | 55 | | |
67 | 56 | | |
68 | | - | |
69 | 57 | | |
70 | 58 | | |
71 | 59 | | |
72 | 60 | | |
73 | | - | |
74 | | - | |
75 | | - | |
76 | | - | |
77 | | - | |
78 | 61 | | |
79 | 62 | | |
80 | 63 | | |
| |||
311 | 294 | | |
312 | 295 | | |
313 | 296 | | |
| 297 | + | |
314 | 298 | | |
315 | 299 | | |
316 | 300 | | |
| |||
0 commit comments