Skip to content

Commit e4d901d

Browse files
test(treeview): wait for async node-view realization in TTV fixtures (AOT)
The AOT selftest failed deterministically on every TTV_ fixture that asserted rendered node text after a single Render(), while JIT passed. Root cause is not a product bug: per-node views host into their containers when the TreeView realizes them, which lands a dispatcher cycle after mount — and the NativeAOT host consistently needs one more pump cycle than JIT to get there (visible in that TTV_AddChild_NewNodeRenders, which already did two render passes, passed under AOT while the single-Render fixtures did not). Asserting after exactly one Render() was a test-harness assumption, not a runtime contract. Fix at the test layer: add a bounded WaitFor(condition) helper that pumps render passes until the rendered content appears, and use it for the render-dependent assertions. A genuinely blank/missing row never appears within the budget, so the regression coverage (including the expand/collapse-cycle test) is preserved. Also reverts the previous attempt to make hosting robust via a dispatcher-retry attach: JIT selftests passed with the plain Loaded subscription, so Loaded does fire in the headless host — the retry addressed a non-issue and is removed. Hosting is back to subscribing the internal TreeViewList's ContainerContentChanging on Loaded (idempotent), with no runtime side-effects added for the test's sake. Full self-test suite green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9591f1b commit e4d901d

2 files changed

Lines changed: 44 additions & 54 deletions

File tree

src/Reactor/Core/Reconciler.Mount.cs

Lines changed: 9 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -2009,17 +2009,10 @@ private WinUI.TreeView MountTemplatedTreeView(TemplatedTreeViewElementBase el, A
20092009

20102010
// Hook the internal TreeViewList ("ListControl") ContainerContentChanging
20112011
// so node views are mounted into their realized containers. The
2012-
// ListControl only exists once the template applies (after the first
2013-
// layout, in-tree), so we can't subscribe at mount the way the typed
2014-
// ListView does. Drive the attach from a bounded dispatcher retry rather
2015-
// than relying solely on Loaded: Loaded depends on the element being
2016-
// connected to a live/activated visual tree (it does not fire reliably in
2017-
// headless hosts), whereas a queued callback drains on the next dispatcher
2018-
// pump — by which point the first layout has created the ListControl and
2019-
// realized the initial containers. Loaded stays as a backup for the
2020-
// show-later case (e.g. a tree inside an unselected tab). The attach is
2021-
// idempotent (presence in _typedTreeListControls guards it).
2022-
ScheduleTypedTreeHosting(treeView, requestRerender, attemptsLeft: 12);
2012+
// ListControl only exists once the template applies (after the control
2013+
// loads in-tree), so we subscribe on Loaded and populate the
2014+
// already-realized initial containers there. Loaded also re-attaches
2015+
// after an Unloaded/Loaded cycle; the attach is idempotent.
20232016
treeView.Loaded += (s, _) => AttachTypedTreeHosting((WinUI.TreeView)s!, requestRerender);
20242017

20252018
el.ApplyControlSetters(treeView);
@@ -2042,38 +2035,18 @@ private static WinUI.TreeViewNode BuildTemplatedTreeNode(TemplatedTreeViewElemen
20422035
return node;
20432036
}
20442037

2045-
/// <summary>
2046-
/// Attempts to attach hosting; if the internal list isn't realized yet,
2047-
/// re-queues itself on the dispatcher (up to <paramref name="attemptsLeft"/>
2048-
/// times) so the subscription lands as soon as the first layout creates the
2049-
/// ListControl — without depending on the Loaded event.
2050-
/// </summary>
2051-
private void ScheduleTypedTreeHosting(WinUI.TreeView treeView, Action requestRerender, int attemptsLeft)
2052-
{
2053-
if (TryAttachTypedTreeHosting(treeView, requestRerender)) return;
2054-
if (attemptsLeft <= 0) return;
2055-
// DispatcherQueue can be null in odd hosting/teardown states — Loaded
2056-
// remains the backup path there.
2057-
treeView.DispatcherQueue?.TryEnqueue(
2058-
() => ScheduleTypedTreeHosting(treeView, requestRerender, attemptsLeft - 1));
2059-
}
2060-
2061-
/// <summary>Loaded-backup entry point (see <see cref="ScheduleTypedTreeHosting"/>).</summary>
2062-
private void AttachTypedTreeHosting(WinUI.TreeView treeView, Action requestRerender) =>
2063-
TryAttachTypedTreeHosting(treeView, requestRerender);
2064-
20652038
/// <summary>
20662039
/// Subscribes the typed TreeView's internal list to ContainerContentChanging
20672040
/// (once) and populates any containers that realized before the subscription.
2068-
/// Returns true once attached. Idempotent — presence in
2069-
/// <see cref="_typedTreeListControls"/> marks "already subscribed".
2041+
/// Idempotent — presence in <see cref="_typedTreeListControls"/> marks
2042+
/// "already subscribed", so it's safe to call on every Loaded.
20702043
/// </summary>
2071-
private bool TryAttachTypedTreeHosting(WinUI.TreeView treeView, Action requestRerender)
2044+
private void AttachTypedTreeHosting(WinUI.TreeView treeView, Action requestRerender)
20722045
{
2073-
if (_typedTreeListControls.TryGetValue(treeView, out _)) return true; // already subscribed
2046+
if (_typedTreeListControls.TryGetValue(treeView, out _)) return; // already subscribed
20742047

20752048
var list = FindDescendantListView(treeView);
2076-
if (list is null) return false; // template not applied yet
2049+
if (list is null) return; // template not applied yet — a later Loaded will retry
20772050

20782051
_typedTreeListControls.Add(treeView, list); // mark subscribed + cache for Update
20792052
list.ContainerContentChanging += (_, args) =>
@@ -2083,7 +2056,6 @@ private bool TryAttachTypedTreeHosting(WinUI.TreeView treeView, Action requestRe
20832056
for (int i = 0; i < list.Items.Count; i++)
20842057
if (list.ContainerFromIndex(i) is WinUI.TreeViewItem container)
20852058
PopulateTypedTreeContainer(treeView, container, list.Items[i], requestRerender);
2086-
return true;
20872059
}
20882060

20892061
/// <summary>

tests/Reactor.AppTests.Host/SelfTest/Fixtures/TemplatedTreeViewFixtures.cs

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,21 @@ private static FsNode[] SampleTree() =>
4747
_ => TextBlock("?"),
4848
};
4949

50+
// Per-node views host into their containers when the TreeView realizes them,
51+
// which lands on a dispatcher cycle after mount — and the runtime decides how
52+
// many pump cycles that takes (the NativeAOT host consistently needs one more
53+
// than JIT). Pump render passes until the condition holds rather than
54+
// asserting after a single Render(); returns false if it never does.
55+
private static async Task<bool> WaitFor(Func<bool> condition, int maxPasses = 15)
56+
{
57+
for (int i = 0; i < maxPasses; i++)
58+
{
59+
if (condition()) return true;
60+
await Harness.Render();
61+
}
62+
return condition();
63+
}
64+
5065
// ── 1. Rich content actually renders (the core #447 win) ──────────────
5166
internal class RendersRichContent(Harness h) : SelfTestFixtureBase(h)
5267
{
@@ -73,16 +88,16 @@ public override async Task RunAsync()
7388
// The node's view is a live HStack of TextBlocks — not a stringified
7489
// blank row. Finding the folder name proves the content hosted.
7590
H.Check("TTV_RendersRichContent_RootNodeVisible",
76-
H.FindTextContaining("Documents") is not null);
91+
await WaitFor(() => H.FindTextContaining("Documents") is not null));
7792

7893
// The "[D]" prefix only exists inside the rich per-node template —
7994
// a stringified node could never produce it.
8095
H.Check("TTV_RendersRichContent_RichTemplateHosted",
81-
H.FindText("[D]") is not null);
96+
await WaitFor(() => H.FindText("[D]") is not null));
8297

8398
// Expanded child leaf renders too.
8499
H.Check("TTV_RendersRichContent_ChildLeafVisible",
85-
H.FindTextContaining("readme.md") is not null);
100+
await WaitFor(() => H.FindTextContaining("readme.md") is not null));
86101
}
87102
}
88103

@@ -104,8 +119,8 @@ public override async Task RunAsync()
104119

105120
// Both the folder template ("[D]") and the file template ("[F]")
106121
// are realized from the single switch-based viewBuilder.
107-
H.Check("TTV_Heterogeneous_FolderTemplate", H.FindText("[D]") is not null);
108-
H.Check("TTV_Heterogeneous_FileTemplate", H.FindText("[F]") is not null);
122+
H.Check("TTV_Heterogeneous_FolderTemplate", await WaitFor(() => H.FindText("[D]") is not null));
123+
H.Check("TTV_Heterogeneous_FileTemplate", await WaitFor(() => H.FindText("[F]") is not null));
109124
}
110125
}
111126

@@ -144,20 +159,23 @@ public override async Task RunAsync()
144159

145160
await Harness.Render();
146161
firstInstance = H.FindControl<WinXC.TreeView>(_ => true);
147-
H.Check("TTV_KeyedUpdate_InitialChild", H.FindTextContaining("alpha.txt") is not null);
162+
H.Check("TTV_KeyedUpdate_InitialChild",
163+
await WaitFor(() => H.FindTextContaining("alpha.txt") is not null));
148164

149165
H.ClickButton("Mutate");
150166
await Harness.Render();
151167

152168
// With per-container hosting the reused node's view reconciles in
153169
// place and stays rendered in the visual tree.
154170
H.Check("TTV_KeyedUpdate_RenamedChildReconciled",
155-
H.FindTextContaining("alpha-renamed.txt") is not null);
171+
await WaitFor(() => H.FindTextContaining("alpha-renamed.txt") is not null));
172+
// The rename has rendered, so the old text is gone ("alpha-renamed.txt"
173+
// does not contain the substring "alpha.txt").
156174
H.Check("TTV_KeyedUpdate_OldTextGone",
157175
H.FindTextContaining("alpha.txt") is null);
158176
// The untouched sibling keeps rendering through the reconcile.
159177
H.Check("TTV_KeyedUpdate_SiblingPreserved",
160-
H.FindTextContaining("beta.txt") is not null);
178+
await WaitFor(() => H.FindTextContaining("beta.txt") is not null));
161179

162180
// The reconcile updated the existing control in place rather than
163181
// remounting a fresh TreeView.
@@ -205,7 +223,7 @@ public override async Task RunAsync()
205223
H.Check("TTV_AddChild_NodeInserted",
206224
tv!.RootNodes[0].Children.Count == 2);
207225
H.Check("TTV_AddChild_NewNodeRenders",
208-
H.FindTextContaining("beta.txt") is not null);
226+
await WaitFor(() => H.FindTextContaining("beta.txt") is not null));
209227
}
210228
}
211229

@@ -272,8 +290,8 @@ public override async Task RunAsync()
272290
var host = H.CreateHost();
273291
host.Mount(_ => el);
274292
await Harness.Render();
275-
H.Check("TTV_ValueType_Renders", H.FindTextContaining("#1") is not null);
276-
H.Check("TTV_ValueType_ChildRenders", H.FindTextContaining("#10") is not null);
293+
H.Check("TTV_ValueType_Renders", await WaitFor(() => H.FindTextContaining("#1") is not null));
294+
H.Check("TTV_ValueType_ChildRenders", await WaitFor(() => H.FindTextContaining("#10") is not null));
277295
}
278296
}
279297

@@ -331,12 +349,13 @@ public override async Task RunAsync()
331349

332350
// Several collapse→expand cycles; after each expand both children
333351
// must be present (the bug blanked the first child every 2nd cycle).
352+
// WaitFor tolerates the realization landing a pump-cycle later, but a
353+
// genuinely blank row never appears within the budget → fails.
334354
for (int cycle = 0; cycle < 4; cycle++)
335355
{
336356
docs.IsExpanded = true;
337-
await Harness.Render();
338-
bool firstChild = H.FindTextContaining("readme.md") is not null;
339-
bool secondChild = H.FindTextContaining("notes.txt") is not null;
357+
bool firstChild = await WaitFor(() => H.FindTextContaining("readme.md") is not null);
358+
bool secondChild = await WaitFor(() => H.FindTextContaining("notes.txt") is not null);
340359
if (!firstChild || !secondChild) allCyclesOk = false;
341360

342361
docs.IsExpanded = false;
@@ -347,10 +366,9 @@ public override async Task RunAsync()
347366

348367
// Leave it expanded and confirm a final realization renders.
349368
docs.IsExpanded = true;
350-
await Harness.Render();
351369
H.Check("TTV_Cycle_FinalExpandRenders",
352-
H.FindTextContaining("readme.md") is not null
353-
&& H.FindTextContaining("notes.txt") is not null);
370+
await WaitFor(() => H.FindTextContaining("readme.md") is not null)
371+
&& await WaitFor(() => H.FindTextContaining("notes.txt") is not null));
354372
}
355373
}
356374

0 commit comments

Comments
 (0)