From 09f17652d5b4a12f86b8cf72dfe3eb82ff2841ec Mon Sep 17 00:00:00 2001 From: Chris Anderson Date: Fri, 29 May 2026 23:33:08 -0700 Subject: [PATCH 1/2] fix(treeview): typed data-driven TreeView with per-container hosting (#447) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A node-mode WinUI TreeView stringifies TreeViewNode.Content and cannot host a pre-built UIElement, so TreeViewNodeData.ContentElement rendered blank rows (#447). Add a typed, data-driven TreeView — the hierarchical peer of ListView — that renders each node from a data -> Element viewBuilder (the WinUI ItemTemplate equivalent); heterogeneous nodes use a switch in the viewBuilder (the ItemTemplateSelector pattern). TreeViewNodeData.ContentElement is marked [Obsolete] pointing at TreeView; the legacy path stays functional (CS0618 suppressed at internal use sites). Hosting mirrors the typed ListView: the ItemTemplate is an empty ContentControl shell and each node view is mounted imperatively into the realized container via the internal TreeViewList's ContainerContentChanging (fresh mount on realize, unmount on recycle). This keeps expand/collapse robust under container recycling — fixing the "every other expand/collapse blanks the first child row(s)" issue a declarative {Binding Content} approach suffered (a recycled container retained the element's visual parent). node.Content holds the data item; the ItemInvoked / Expanding trampolines read T back from it. The internal TreeViewList only exists once the template applies (in-tree), so we subscribe its ContainerContentChanging on Loaded. Initial containers can realize before that subscription, and under NativeAOT they may not be ready yet (ContentTemplateRoot null; the realized container is even still the base ListViewItem) — so the initial population filters on ContentControl (not TreeViewItem) and re-attempts on the list's LayoutUpdated (bounded) until each realized container is ready. Update reconciles the realized containers' views via the flattened ListView.Items by index (ContainerFromItem does not resolve under AOT), decoupled from the keyed node-structure diff. - Element.cs: TemplatedTreeViewElementBase (object-erased base) + TemplatedTreeViewElement; OwnPropsEqual arm. - Dsl.cs: TreeView factory overloads (explicit + IReactorKeyed). - ElementExtensions.Events.cs: .ItemInvoked / .Expanding fluents. - Reconciler: empty-shell ItemTemplate, ContainerContentChanging hosting on the internal TreeViewList, LayoutUpdated initial-host catch-up, index-based Update reconcile, full-tree unmount walk. - Samples: DataTemplateDemo section 4 migrated to a discriminated PetNode model. - Tests: TemplatedTreeViewFixtures (TTV_) — render, heterogeneous templates, keyed update, expand/collapse cycles, events, value-type T, unmount; render assertions pump via WaitFor for async realization. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Reactor.TestApp/Demos/DataTemplateDemo.cs | 105 +++-- src/Reactor/Core/Element.cs | 137 ++++++ src/Reactor/Core/Reconciler.Mount.cs | 187 ++++++++ src/Reactor/Core/Reconciler.Update.cs | 151 +++++++ src/Reactor/Core/Reconciler.cs | 83 ++++ .../Core/V1Protocol/ChildrenStrategy.cs | 4 + src/Reactor/Elements/Dsl.cs | 32 ++ .../Elements/ElementExtensions.Events.cs | 8 + .../Fixtures/TemplatedTreeViewFixtures.cs | 406 ++++++++++++++++++ .../SelfTest/SelfTestFixtureRegistry.cs | 19 + 10 files changed, 1093 insertions(+), 39 deletions(-) create mode 100644 tests/Reactor.AppTests.Host/SelfTest/Fixtures/TemplatedTreeViewFixtures.cs diff --git a/samples/Reactor.TestApp/Demos/DataTemplateDemo.cs b/samples/Reactor.TestApp/Demos/DataTemplateDemo.cs index 1e79cdf4c..5e430a996 100644 --- a/samples/Reactor.TestApp/Demos/DataTemplateDemo.cs +++ b/samples/Reactor.TestApp/Demos/DataTemplateDemo.cs @@ -44,7 +44,7 @@ public override Element Render() return ScrollView(VStack(16, Heading("DataTemplate Demo"), - TextBlock("Typed ListView, GridView, FlipView with viewBuilder, plus TreeView ContentElement."), + TextBlock("Typed ListView, GridView, FlipView and TreeView — all data-driven with a viewBuilder."), // Filter + add/remove controls HStack(12, @@ -154,48 +154,75 @@ public override Element Render() TextBlock($"Showing {flipIndex + 1} of {filtered.Count}").Foreground(SecondaryText), - // 4. TreeView with ContentElement - SubHeading("4. TreeView with ContentElement"), - TextBlock("Tree nodes render custom Reactor elements instead of plain text."), + // 4. Typed TreeView — hierarchical peer of ListView + SubHeading("4. Typed TreeView with viewBuilder"), + TextBlock("Heterogeneous nodes render distinct templates via a switch in the viewBuilder (the ItemTemplateSelector pattern)."), Border( - TreeView( - new TreeViewNodeData("Pets") { IsExpanded = true, - ContentElement = HStack(8, + TreeView(BuildPetTree(filtered), + keySelector: n => n.Key, + childrenSelector: n => n.Children.Length > 0 ? n.Children : null, + viewBuilder: n => n switch + { + PetRoot => HStack(8, TextBlock("\U0001F3E0").FontSize(16), TextBlock("All Pets").SemiBold() ), - Children = new[] { "Cat", "Dog", "Rabbit", "Hamster", "Parrot" } - .Where(species => filtered.Any(a => a.Species == species)) - .Select(species => new TreeViewNodeData(species) - { - IsExpanded = true, - ContentElement = HStack(8, - TextBlock(species switch - { - "Cat" => "\U0001F431", - "Dog" => "\U0001F436", - "Rabbit" => "\U0001F430", - "Hamster" => "\U0001F439", - "Parrot" => "\U0001F99C", - _ => "\U0001F43E" - }), - TextBlock(species).SemiBold(), - TextBlock($"({filtered.Count(a => a.Species == species)})").Foreground(TertiaryText) - ), - Children = filtered - .Where(a => a.Species == species) - .Select(a => new TreeViewNodeData(a.Name) - { - ContentElement = HStack(8, - TextBlock(a.Emoji), - TextBlock(a.Name), - Caption($"#{a.Id}").Foreground(TertiaryText) - ) - }).ToArray() - }).ToArray() - } - ) - ).CornerRadius(8).Height(300) + PetSpecies s => HStack(8, + TextBlock(s.Emoji), + TextBlock(s.Species).SemiBold(), + TextBlock($"({s.Count})").Foreground(TertiaryText) + ), + PetLeaf l => HStack(8, + TextBlock(l.Animal.Emoji), + TextBlock(l.Animal.Name), + Caption($"#{l.Animal.Id}").Foreground(TertiaryText) + ), + _ => TextBlock("?") + }) + // Expand every group; leaves have no children to expand. + with { IsExpanded = n => n is not PetLeaf } + ).CornerRadius(8).Height(300).Margin(10) )); } + + // ── §4 typed-tree model: a discriminated pet hierarchy ──────────────── + // (root group → species groups → animal leaves). Distinct record shapes + // drive distinct per-node templates in the viewBuilder switch above. + abstract record PetNode(string Key) + { + public PetNode[] Children { get; init; } = []; + } + record PetRoot(string Key) : PetNode(Key); + record PetSpecies(string Key, string Species, string Emoji, int Count) : PetNode(Key); + record PetLeaf(string Key, Animal Animal) : PetNode(Key); + + static string EmojiForSpecies(string species) => species switch + { + "Cat" => "\U0001F431", + "Dog" => "\U0001F436", + "Rabbit" => "\U0001F430", + "Hamster" => "\U0001F439", + "Parrot" => "\U0001F99C", + _ => "\U0001F43E" + }; + + static PetNode[] BuildPetTree(List animals) + { + var speciesGroups = new[] { "Cat", "Dog", "Rabbit", "Hamster", "Parrot" } + .Where(species => animals.Any(a => a.Species == species)) + .Select(species => (PetNode)new PetSpecies( + $"species:{species}", + species, + EmojiForSpecies(species), + animals.Count(a => a.Species == species)) + { + Children = animals + .Where(a => a.Species == species) + .Select(a => (PetNode)new PetLeaf($"animal:{a.Id}", a)) + .ToArray() + }) + .ToArray(); + + return [new PetRoot("root") { Children = speciesGroups }]; + } } diff --git a/src/Reactor/Core/Element.cs b/src/Reactor/Core/Element.cs index 6af11b461..33b2d64dd 100644 --- a/src/Reactor/Core/Element.cs +++ b/src/Reactor/Core/Element.cs @@ -814,6 +814,16 @@ internal static bool OwnPropsEqual(Element a, Element b) && ta.GetIsItemClickEnabled() == tb.GetIsItemClickEnabled() && !ta.HasSetters && !tb.HasSetters, + // Templated hierarchical TreeView — same rationale as the + // templated lists above: Items/selectors/ViewBuilder are factory + // inputs that drive child reconcile, not parent-control props. + (TemplatedTreeViewElementBase tta, TemplatedTreeViewElementBase ttb) => + tta.GetSelectionMode() == ttb.GetSelectionMode() + && tta.GetCanDragItems() == ttb.GetCanDragItems() + && tta.GetAllowDrop() == ttb.GetAllowDrop() + && tta.GetCanReorderItems() == ttb.GetCanReorderItems() + && !tta.HasSetters && !ttb.HasSetters, + // Lazy (virtualized) stacks: same rationale — Items/ViewBuilder // are factory inputs, not control properties. (LazyStackElementBase la, LazyStackElementBase lb) => @@ -1999,6 +2009,17 @@ public record TreeViewNodeData(string Content, TreeViewNodeData[]? Children = nu /// Optional Reactor element to render as the node's visual content. /// When null, a TextBlock showing Content is rendered. /// + /// + /// Deprecated. WinUI's node-mode TreeView stringifies node content + /// and cannot host a pre-built UIElement, so rich per-node visuals + /// must come from a template (a data → Element function), never an + /// element instance. Use the typed, data-driven + /// UI.TreeView<T>(items, keySelector, childrenSelector, viewBuilder) + /// — the hierarchical peer of ListView<T> — instead. The legacy + /// path stays functional for back-compat but renders blank under + /// virtualization recycling. + /// + [Obsolete("Use the typed UI.TreeView(items, keySelector, childrenSelector, viewBuilder) overload (the hierarchical peer of ListView); a pre-built Element cannot be hosted in a node-mode TreeViewNode. See issue #447.")] public Element? ContentElement { get; init; } } @@ -3455,6 +3476,122 @@ public override void ApplyControlSetters(object control) => internal override bool HasSetters => Setters.Length > 0; } +// ════════════════════════════════════════════════════════════════════════ +// Templated (data-driven) hierarchical TreeView +// ════════════════════════════════════════════════════════════════════════ + +/// +/// Abstract non-generic base for the typed, data-driven TreeView. +/// Non-generic so the reconciler can match a single type in its switch +/// expression (same type-erasure pattern as ). +/// +/// This is the hierarchical peer of : +/// the developer supplies their own data items, a key selector, a children +/// selector (the hierarchy), and a viewBuilder (data → Element, +/// the WinUI ItemTemplate equivalent). It exists because WinUI's +/// node-mode TreeView stringifies TreeViewNode.Content and +/// cannot host a pre-built UIElement — rich per-node visuals must come +/// from a template, never an element instance (the root cause of issue #447). +/// +/// The base exposes object-erased accessors; the generic leaf casts back +/// to T. Reference-type T flows through the covariant +/// IReadOnlyList<object> +/// conversion; value-type T is boxed once via the leaf's projection +/// helper. +/// +public abstract record TemplatedTreeViewElementBase : Element +{ + /// The root data items (object-erased), in document order. + public abstract IReadOnlyList GetRoots(); + /// The children of , or null for a leaf. + public abstract IReadOnlyList? GetChildren(object item); + /// The stable identity string for (the keyed-diff key). + public abstract string GetKey(object item); + /// Builds the per-node view (the ItemTemplate equivalent). + public abstract Element BuildView(object item); + /// Whether 's node should start expanded. + public abstract bool GetIsExpanded(object item); + /// Dispatches OnItemInvoked with the developer's own T. + public abstract void InvokeItemInvoked(object item); + /// Dispatches OnExpanding with the developer's own T. + public abstract void InvokeExpanding(object item); + + public abstract TreeViewSelectionMode GetSelectionMode(); + public abstract bool GetCanDragItems(); + public abstract bool GetAllowDrop(); + public abstract bool GetCanReorderItems(); + public abstract void ApplyControlSetters(object control); + + /// + /// True when programmatic setter actions (.Set(...)) are attached. Used by + /// to suppress the reconcile-highlight + /// short-circuit (same rationale as ). + /// + internal virtual bool HasSetters => false; +} + +/// +/// Typed, data-driven TreeView. The hierarchical peer of +/// . See +/// . +/// +public record TemplatedTreeViewElement( + IReadOnlyList Items, + Func KeySelector, + Func?> ChildrenSelector, + Func ViewBuilder +) : TemplatedTreeViewElementBase +{ + /// Invoked with the developer's T when a node is clicked/invoked. + public Action? OnItemInvoked { get; init; } + /// Invoked with the developer's T just before a node expands. + public Action? OnExpanding { get; init; } + /// Per-item initial-expansion selector. Defaults to collapsed. + public Func? IsExpanded { get; init; } + public TreeViewSelectionMode SelectionMode { get; init; } = TreeViewSelectionMode.Single; + public bool CanDragItems { get; init; } + public bool AllowDrop { get; init; } + public bool CanReorderItems { get; init; } + internal Action[] Setters { get; init; } = []; + + public override IReadOnlyList GetRoots() => Project(Items); + public override IReadOnlyList? GetChildren(object item) + { + var children = ChildrenSelector((T)item); + return children is null ? null : Project(children); + } + public override string GetKey(object item) => KeySelector((T)item); + public override Element BuildView(object item) => ViewBuilder((T)item); + public override bool GetIsExpanded(object item) => IsExpanded?.Invoke((T)item) ?? false; + public override void InvokeItemInvoked(object item) => OnItemInvoked?.Invoke((T)item); + public override void InvokeExpanding(object item) => OnExpanding?.Invoke((T)item); + + public override TreeViewSelectionMode GetSelectionMode() => SelectionMode; + public override bool GetCanDragItems() => CanDragItems; + public override bool GetAllowDrop() => AllowDrop; + public override bool GetCanReorderItems() => CanReorderItems; + public override void ApplyControlSetters(object control) => + Reconciler.ApplySetters(Setters, (WinUI.TreeView)control); + + internal override bool HasCallbacks => OnItemInvoked is not null || OnExpanding is not null; + internal override bool HasSetters => Setters.Length > 0; + + /// + /// Object-erases the source list. Reference-type T reuses the same + /// instance through covariance (no copy); value-type T is boxed into + /// a fresh object[]. Identity-stable mapping back to T is via + /// (a string), not object reference, so the per-call + /// boxing of value types is harmless. + /// + private static IReadOnlyList Project(IReadOnlyList source) + { + if (source is IReadOnlyList covariant) return covariant; + var boxed = new object[source.Count]; + for (int i = 0; i < source.Count; i++) boxed[i] = source[i]!; + return boxed; + } +} + // ════════════════════════════════════════════════════════════════════════ // Virtualized collection elements (backed by ItemsRepeater) // ════════════════════════════════════════════════════════════════════════ diff --git a/src/Reactor/Core/Reconciler.Mount.cs b/src/Reactor/Core/Reconciler.Mount.cs index 7629de99b..29291eb3e 100644 --- a/src/Reactor/Core/Reconciler.Mount.cs +++ b/src/Reactor/Core/Reconciler.Mount.cs @@ -76,6 +76,11 @@ public sealed partial class Reconciler { control = element switch { + // Typed, data-driven TreeView uses hand-coded per-container + // hosting (ContainerContentChanging on the internal TreeViewList), + // so it stays on the composition-primitive switch rather than the + // V1 descriptor registry. (#447) + TemplatedTreeViewElementBase ttv => MountTemplatedTreeView(ttv, requestRerender), CommandHostElement ch => MountCommandHost(ch, requestRerender), ErrorBoundaryElement eb => MountErrorBoundary(eb, requestRerender), Validation.FormFieldElement ff => MountFormField(ff, requestRerender), @@ -1126,6 +1131,10 @@ private WinUI.TreeView MountTreeView(TreeViewElement tv, Action requestRerender) return treeView; } + // Legacy TreeViewNodeData.ContentElement reads — the property is [Obsolete] + // in favor of typed TreeView (issue #447) but the path stays functional + // for back-compat, so suppress CS0618 at the internal use sites. +#pragma warning disable CS0618 private static bool HasAnyContentElement(TreeViewNodeData[] nodes) { foreach (var node in nodes) @@ -1157,6 +1166,7 @@ private WinUI.TreeViewNode CreateTreeNode(TreeViewNodeData data, bool mountEleme node.Children.Add(CreateTreeNode(child, mountElements, requestRerender)); return node; } +#pragma warning restore CS0618 /// Backward-compatible overload for non-ContentElement code paths. private static WinUI.TreeViewNode CreateTreeNode(TreeViewNodeData data) @@ -1167,6 +1177,183 @@ private static WinUI.TreeViewNode CreateTreeNode(TreeViewNodeData data) return node; } + // ── Typed, data-driven TreeView ─────────────────────────────────── + + /// + /// Mounts a typed . Builds a WinUI + /// node-mode TreeView whose ItemTemplate is an empty + /// ContentControl shell; each node's view is mounted imperatively + /// into the realized container on demand (see + /// ) — the same + /// realize/recycle hosting as the typed ListView, which keeps + /// expand/collapse robust under container recycling. node.Content + /// holds the developer's data item. + /// + private WinUI.TreeView MountTemplatedTreeView(TemplatedTreeViewElementBase el, Action requestRerender) + { + var treeView = new WinUI.TreeView + { + SelectionMode = el.GetSelectionMode(), + CanDragItems = el.GetCanDragItems(), + AllowDrop = el.GetAllowDrop(), + CanReorderItems = el.GetCanReorderItems(), + ItemTemplate = SharedContentControlTemplate.Value, + }; + + foreach (var root in el.GetRoots()) + treeView.RootNodes.Add(BuildTemplatedTreeNode(el, root)); + + SetElementTag(treeView, el); + + // Trampolines resolve the live element + the data item (node.Content) + // on dispatch, so they're wired unconditionally and never need + // re-subscribing on Update (a no-op when the user supplied no callback). + treeView.ItemInvoked += TemplatedTreeItemInvoked; + treeView.Expanding += TemplatedTreeExpanding; + + // Hook the internal TreeViewList ("ListControl") ContainerContentChanging + // so node views are mounted into their realized containers. The + // ListControl only exists once the template applies (after the control + // loads in-tree), so we subscribe on Loaded and populate the + // already-realized initial containers there. Loaded also re-attaches + // after an Unloaded/Loaded cycle; the attach is idempotent. + treeView.Loaded += (s, _) => AttachTypedTreeHosting((WinUI.TreeView)s!, requestRerender); + + el.ApplyControlSetters(treeView); + return treeView; + } + + /// + /// Recursively materializes a for + /// . node.Content holds the data item; the + /// view itself is mounted lazily per realized container, so nothing is + /// mounted here. + /// + private static WinUI.TreeViewNode BuildTemplatedTreeNode(TemplatedTreeViewElementBase el, object item) + { + var node = new WinUI.TreeViewNode { Content = item, IsExpanded = el.GetIsExpanded(item) }; + var children = el.GetChildren(item); + if (children is not null) + foreach (var child in children) + node.Children.Add(BuildTemplatedTreeNode(el, child)); + return node; + } + + /// + /// Subscribes the typed TreeView's internal list to ContainerContentChanging + /// (once) and populates any containers that realized before the subscription. + /// Idempotent — presence in marks + /// "already subscribed", so it's safe to call on every Loaded. + /// + private void AttachTypedTreeHosting(WinUI.TreeView treeView, Action requestRerender) + { + if (_typedTreeListControls.TryGetValue(treeView, out _)) return; // already subscribed + + var list = FindDescendantListView(treeView); + if (list is null) return; // template not applied yet — a later Loaded will retry + + _typedTreeListControls.Add(treeView, list); // mark subscribed + cache for Update + list.ContainerContentChanging += (_, args) => + OnTypedTreeContainerContentChanging(treeView, args, requestRerender); + + // Host the containers that realized before we subscribed — their + // ContainerContentChanging already fired and won't fire again. They may + // not be ready yet (their ContentTemplateRoot is generated a layout pass + // later — observed under NativeAOT, where the realized container is even + // still the base ListViewItem at Loaded time), so re-attempt on + // LayoutUpdated until every realized container is hosted, then detach. + // Everything realized AFTER this point flows through CCC. The pass count + // is bounded so the handler always detaches (no dangling subscription), + // and CCC still covers anything not hosted by then. + if (!PopulateRealizedTreeContainers(treeView, list, requestRerender)) + { + int passesLeft = 8; + EventHandler? onLayout = null; + onLayout = (_, _) => + { + if (PopulateRealizedTreeContainers(treeView, list, requestRerender) || --passesLeft <= 0) + list.LayoutUpdated -= onLayout; + }; + list.LayoutUpdated += onLayout; + } + } + + /// + /// Hosts every currently-realized container that's ready and not yet hosted. + /// Returns true when no realized container remains unhosted (so the caller + /// can stop re-attempting). Virtualized-out indices (null container) are not + /// counted — ContainerContentChanging hosts them when they realize. + /// + private bool PopulateRealizedTreeContainers(WinUI.TreeView treeView, WinUI.ListView list, Action requestRerender) + { + bool complete = true; + for (int i = 0; i < list.Items.Count; i++) + { + // The container is a ListViewItem/TreeViewItem (both ContentControl). + // Don't filter on TreeViewItem — under AOT it can still be the base + // ListViewItem when first realized. + if (list.ContainerFromIndex(i) is not ContentControl container) continue; + if (container.ContentTemplateRoot is null) { complete = false; continue; } // not ready yet + PopulateTypedTreeContainer(treeView, container, list.Items[i], requestRerender); + } + return complete; + } + + /// + /// Realize/recycle handler for the typed TreeView's containers. On realize, + /// mounts the node's view into the container's ContentControl; on recycle, + /// unmounts it. Does not set args.Handled — the internal + /// TreeViewList runs its own ContainerContentChanging handler (indentation / + /// selection visuals) and must keep doing so. + /// + private void OnTypedTreeContainerContentChanging( + WinUI.TreeView treeView, ContainerContentChangingEventArgs args, Action requestRerender) + { + if (args.ItemContainer?.ContentTemplateRoot is not ContentControl cc) return; + + if (args.InRecycleQueue) + { + if (cc.Content is UIElement old) UnmountChild(old); + cc.Content = null; + ClearElementTag(cc); + return; + } + + PopulateTypedTreeContainer(treeView, args.ItemContainer, args.Item, requestRerender); + } + + /// + /// Mounts the node's view into a realized container's ContentControl, unless + /// it's already populated. The developer's data item is node.Content. + /// + private void PopulateTypedTreeContainer( + WinUI.TreeView treeView, WinUI.Control container, object? item, Action requestRerender) + { + if (GetElementTag(treeView) is not TemplatedTreeViewElementBase el) return; + if ((container as ContentControl)?.ContentTemplateRoot is not ContentControl cc) return; + if (cc.Content is not null) return; // already hosted for this realization + if (item is not WinUI.TreeViewNode node || node.Content is not { } data) return; + + var view = el.BuildView(data); + cc.Content = Mount(view, requestRerender); + SetElementTag(cc, view); + } + + private static void TemplatedTreeItemInvoked(WinUI.TreeView sender, WinUI.TreeViewItemInvokedEventArgs args) + { + if (args.InvokedItem is WinUI.TreeViewNode node + && node.Content is { } item + && GetElementTag(sender) is TemplatedTreeViewElementBase el) + el.InvokeItemInvoked(item); + } + + private static void TemplatedTreeExpanding(WinUI.TreeView sender, WinUI.TreeViewExpandingEventArgs args) + { + if (args.Node.Content is { } item + && GetElementTag(sender) is TemplatedTreeViewElementBase el) + el.InvokeExpanding(item); + } + private WinUI.FlipView MountFlipView(FlipViewElement fv, Action requestRerender) { var flipView = new WinUI.FlipView { SelectedIndex = fv.SelectedIndex }; diff --git a/src/Reactor/Core/Reconciler.Update.cs b/src/Reactor/Core/Reconciler.Update.cs index a51ead1aa..b61d7c27e 100644 --- a/src/Reactor/Core/Reconciler.Update.cs +++ b/src/Reactor/Core/Reconciler.Update.cs @@ -128,6 +128,10 @@ public sealed partial class Reconciler { result = (oldEl, newEl, control) switch { + // Typed, data-driven TreeView — hand-coded escape-hatch path + // (per-container hosting), so it stays on the switch. (#447) + (TemplatedTreeViewElementBase o, TemplatedTreeViewElementBase n, WinUI.TreeView ttv) + => UpdateTemplatedTreeView(o, n, ttv, requestRerender), (CommandHostElement o, CommandHostElement n, WinUI.Grid chGrid) => UpdateCommandHost(o, n, chGrid, requestRerender), (ErrorBoundaryElement oldEb, ErrorBoundaryElement newEb, Border) @@ -2359,6 +2363,7 @@ private void DiffTreeViewNodes( /// Reconciles ContentElement changes on a TreeViewNode. /// When ContentElement is used, node.Content holds a mounted UIElement. /// +#pragma warning disable CS0618 // legacy TreeViewNodeData.ContentElement path (see issue #447) private void ReconcileTreeNodeContent( WinUI.TreeViewNode liveNode, TreeViewNodeData? oldData, @@ -2394,6 +2399,152 @@ private void ReconcileTreeNodeContent( liveNode.Content = newData; } } +#pragma warning restore CS0618 + + // ── Typed, data-driven TreeView ─────────────────────────────────── + + /// + /// Updates a typed in place: + /// keyed-diffs the node hierarchy and writes back the parent-control props. + /// The ItemInvoked / Expanding trampolines resolve the live element via + /// GetElementTag, so refreshing the element tag is all that's needed + /// to pick up new callbacks — no re-subscription. + /// + private UIElement? UpdateTemplatedTreeView(TemplatedTreeViewElementBase o, TemplatedTreeViewElementBase n, WinUI.TreeView tv, Action requestRerender) + { + // Diff the node hierarchy (structure + each node's data item). The view + // reconcile is a separate flat pass over the realized containers below — + // keeping the two concerns decoupled, and using the index-based container + // lookup that works under NativeAOT (ContainerFromItem does not resolve + // there, and a freshly-realized container can still be the base + // ListViewItem rather than TreeViewItem). + DiffTemplatedTreeNodes(tv.RootNodes, o, o.GetRoots(), n, n.GetRoots()); + + tv.SelectionMode = n.GetSelectionMode(); + tv.CanDragItems = n.GetCanDragItems(); + tv.AllowDrop = n.GetAllowDrop(); + tv.CanReorderItems = n.GetCanReorderItems(); + + SetElementTag(tv, n); + n.ApplyControlSetters(tv); + + // Reconcile the view of every currently-realized container against its + // node's (now-updated) data. Unrealized nodes need no work — their view + // is (re)built fresh from node.Content when they next realize via CCC. + RefreshRealizedTreeContainers(tv, FindTypedTreeListControl(tv), n, requestRerender); + return null; + } + + /// + /// Reconciles the hosted view of every realized container against its node's + /// current node.Content data. Iterates the flattened + /// via index (the AOT-robust lookup), so + /// it covers visible nodes at every depth in one pass. + /// + private void RefreshRealizedTreeContainers(WinUI.TreeView tv, WinUI.ListView? list, TemplatedTreeViewElementBase n, Action requestRerender) + { + if (list is null) return; + for (int i = 0; i < list.Items.Count; i++) + { + if (list.ContainerFromIndex(i) is not ContentControl container) continue; + if (container.ContentTemplateRoot is not ContentControl cc) continue; + if (list.Items[i] is not WinUI.TreeViewNode node || node.Content is not { } data) continue; + + var newView = n.BuildView(data); + if (cc.Content is UIElement existing && GetElementTag(cc) is Element oldView && CanUpdate(oldView, newView)) + { + var replacement = Update(oldView, newView, existing, requestRerender); + if (replacement is not null && !ReferenceEquals(cc.Content, replacement)) + cc.Content = replacement; + } + else + { + if (cc.Content is UIElement old) UnmountChild(old); + cc.Content = Mount(newView, requestRerender); + } + SetElementTag(cc, newView); + } + } + + private static readonly object[] s_emptyTreeItems = []; + + /// + /// Keyed, in-place hierarchical reconcile of a typed TreeView node list. + /// Keys on the element's KeySelector. Matched nodes are reused (their + /// data item and any realized container view reconciled in place); the live + /// collection is then reordered with minimal + /// insert/move/remove ops so that unchanged-order updates touch the + /// collection not at all — avoiding the container churn that a + /// clear-and-rebuild would force. + /// + private void DiffTemplatedTreeNodes( + IList liveNodes, + TemplatedTreeViewElementBase oldEl, IReadOnlyList oldItems, + TemplatedTreeViewElementBase newEl, IReadOnlyList newItems) + { + // Snapshot: map old key → (live node, old item). Live nodes correspond + // 1:1 to oldItems in order. + var oldByKey = new Dictionary(oldItems.Count); + for (int i = 0; i < oldItems.Count && i < liveNodes.Count; i++) + oldByKey.TryAdd(oldEl.GetKey(oldItems[i]), (liveNodes[i], oldItems[i])); + + // Resolve the target node sequence: reuse-and-reconcile matched nodes, + // build fresh ones for new keys. + var target = new List(newItems.Count); + for (int i = 0; i < newItems.Count; i++) + { + var newItem = newItems[i]; + if (oldByKey.Remove(newEl.GetKey(newItem), out var match)) + { + var node = match.Node; + // node.Content is the data item; refresh it so the trampolines + // hand back the current T (value-type T is boxed fresh). + node.Content = newItem; + + bool expanded = newEl.GetIsExpanded(newItem); + if (node.IsExpanded != expanded) node.IsExpanded = expanded; + + DiffTemplatedTreeNodes( + node.Children, + oldEl, oldEl.GetChildren(match.OldItem) ?? s_emptyTreeItems, + newEl, newEl.GetChildren(newItem) ?? s_emptyTreeItems); + + target.Add(node); + } + else + { + target.Add(BuildTemplatedTreeNode(newEl, newItem)); + } + } + + // Removed nodes (unmatched old keys): drop them. Their realized + // containers recycle → the ContainerContentChanging recycle path tears + // their views down. + foreach (var leftover in oldByKey.Values) + liveNodes.Remove(leftover.Node); + + // Reorder/insert so liveNodes matches `target`. Reused nodes already in + // the right slot mean zero collection mutations (the common case). + for (int j = 0; j < target.Count; j++) + { + if (j < liveNodes.Count && ReferenceEquals(liveNodes[j], target[j])) continue; + + int current = IndexOfNode(liveNodes, target[j], j); + if (current >= 0) liveNodes.RemoveAt(current); + liveNodes.Insert(j, target[j]); + } + // Trim any stragglers beyond the target length (defensive — removals + // above should already have handled these). + while (liveNodes.Count > target.Count) + liveNodes.RemoveAt(liveNodes.Count - 1); + } + + private static int IndexOfNode(IList nodes, WinUI.TreeViewNode target, int startAt) + { + for (int i = startAt; i < nodes.Count; i++) + if (ReferenceEquals(nodes[i], target)) return i; + return -1; + } private UIElement? UpdateRectangle(RectangleElement n, WinShapes.Rectangle r) { diff --git a/src/Reactor/Core/Reconciler.cs b/src/Reactor/Core/Reconciler.cs index a4291d276..77da675bb 100644 --- a/src/Reactor/Core/Reconciler.cs +++ b/src/Reactor/Core/Reconciler.cs @@ -670,6 +670,25 @@ internal static ReactorState GetOrCreateReactorState(FrameworkElement fe) return state; } + // ── Typed TreeView container hosting ────────────────────────────── + // + // The typed TreeView hosts each node's view imperatively (the WinUI + // ItemTemplate-equivalent), exactly like the typed ListView: the + // ItemTemplate is an empty ContentControl shell, and we populate each + // realized container's ContentControl from the node's data item on + // realization (and tear it down on recycle). This is what makes + // expand/collapse robust — a fresh view is mounted into whichever pooled + // container WinUI realizes the node into, so no element is ever stuck + // parented to a stale (recycled) container. node.Content holds the + // developer's data item (the boxed T) — WinUI-aligned, and read back by + // the ItemInvoked / Expanding trampolines. + // + // Caches the internal TreeViewList (template part "ListControl", a public + // ListView subclass) per TreeView so Update can reconcile realized + // containers, and so the ContainerContentChanging subscription is attached + // exactly once. + private readonly global::System.Runtime.CompilerServices.ConditionalWeakTable _typedTreeListControls = new(); + /// /// Spec 047 §14 Phase 1 (1.3) — promoted from internal. Associates a /// control with its current Reactor element via the @@ -1967,6 +1986,70 @@ private void UnmountRecursive(UIElement control) { UnmountRecursive(ccChild); } + else if (control is WinUI.TreeView typedTree && GetElementTag(typedTree) is TemplatedTreeViewElementBase) + { + // Typed TreeView hosts each node's view in a per-container + // ContentControl (populated on realization — see _typedTreeListControls). + // WinUI.TreeView is a Control (not a Panel / ContentControl), so the + // branches above don't recurse into it; walk its realized containers + // and tear down the mounted views, or their Components' cleanups + // (UseEffect, timers, subscriptions) would leak on unmount. + UnmountTypedTreeViewContainers(typedTree); + _typedTreeListControls.Remove(typedTree); + } + } + + /// + /// Walks a typed TreeView's visual subtree and unmounts every per-node view + /// hosted in a tagged container ContentControl. Used on full-tree unmount + /// (recycle of individual containers is handled by the + /// ContainerContentChanging recycle path). + /// + private void UnmountTypedTreeViewContainers(WinUI.TreeView treeView) + { + int count = Microsoft.UI.Xaml.Media.VisualTreeHelper.GetChildrenCount(treeView); + for (int i = 0; i < count; i++) + UnmountTypedTreeViewContainersRecursive(Microsoft.UI.Xaml.Media.VisualTreeHelper.GetChild(treeView, i)); + } + + private void UnmountTypedTreeViewContainersRecursive(DependencyObject node) + { + // Our node-view hosts are ContentControls tagged with the view Element. + if (node is WinUI.ContentControl cc && cc.Content is UIElement view && GetElementTag(cc) is not null) + { + UnmountChild(view); + cc.Content = null; + ClearElementTag(cc); + return; // the view's own subtree is handled by UnmountChild + } + int count = Microsoft.UI.Xaml.Media.VisualTreeHelper.GetChildrenCount(node); + for (int i = 0; i < count; i++) + UnmountTypedTreeViewContainersRecursive(Microsoft.UI.Xaml.Media.VisualTreeHelper.GetChild(node, i)); + } + + /// + /// Finds the typed TreeView's internal TreeViewList (template part + /// "ListControl", a public subclass). Returns + /// the cached instance when hosting is already attached; otherwise walks the + /// visual subtree without caching — only AttachTypedTreeHosting writes + /// (its presence marks "subscribed", so + /// a read-side cache write here would suppress the real subscription). + /// + internal WinUI.ListView? FindTypedTreeListControl(WinUI.TreeView treeView) => + _typedTreeListControls.TryGetValue(treeView, out var cached) + ? cached + : FindDescendantListView(treeView); + + private static WinUI.ListView? FindDescendantListView(DependencyObject root) + { + int count = Microsoft.UI.Xaml.Media.VisualTreeHelper.GetChildrenCount(root); + for (int i = 0; i < count; i++) + { + var child = Microsoft.UI.Xaml.Media.VisualTreeHelper.GetChild(root, i); + if (child is WinUI.ListView lv) return lv; + if (FindDescendantListView(child) is { } nested) return nested; + } + return null; } /// diff --git a/src/Reactor/Core/V1Protocol/ChildrenStrategy.cs b/src/Reactor/Core/V1Protocol/ChildrenStrategy.cs index 85a69464a..25a151617 100644 --- a/src/Reactor/Core/V1Protocol/ChildrenStrategy.cs +++ b/src/Reactor/Core/V1Protocol/ChildrenStrategy.cs @@ -372,6 +372,9 @@ void IItemsBinderStrategy.Bind(FrameworkElement control, Element? oldElement, El tree.RootNodes.Add(CreateTreeNode(nodes[i], hasContentElements, reconciler, requestRerender)); } + // Legacy TreeViewNodeData.ContentElement reads — [Obsolete] in favor of + // typed TreeView (issue #447); suppress CS0618 at the internal use sites. +#pragma warning disable CS0618 private static bool HasAnyContentElement(IReadOnlyList nodes) { for (int i = 0; i < nodes.Count; i++) @@ -395,6 +398,7 @@ private static WinUI.TreeViewNode CreateTreeNode(TreeViewNodeData data, bool mou node.Children.Add(CreateTreeNode(data.Children[i], mountElements, reconciler, requestRerender)); return node; } +#pragma warning restore CS0618 private static void UnmountTreeContent(IList nodes, Reconciler reconciler) { diff --git a/src/Reactor/Elements/Dsl.cs b/src/Reactor/Elements/Dsl.cs index 20002523e..71fb77cb8 100644 --- a/src/Reactor/Elements/Dsl.cs +++ b/src/Reactor/Elements/Dsl.cs @@ -646,6 +646,38 @@ public static BreadcrumbBarElement BreadcrumbBar(BreadcrumbBarItemData[] items, public static TreeViewNodeData TreeNode(string content, params TreeViewNodeData[] children) => new(content, children.Length > 0 ? children : null); + /// + /// Creates a typed, data-driven — + /// the hierarchical peer of . + /// The reconciler builds a WinUI TreeView from , + /// walks the hierarchy via , and renders + /// each node via (the ItemTemplate + /// equivalent — a data → Element function). + /// + /// + /// Heterogeneous nodes with per-shape visuals are expressed as a + /// switch inside (the C# equivalent of + /// WinUI's ItemTemplateSelector). This supersedes the deprecated + /// (issue #447). (spec 039 §0.3) + /// + public static TemplatedTreeViewElement TreeView( + IReadOnlyList items, + Func keySelector, + Func?> childrenSelector, + Func viewBuilder) => new(items, keySelector, childrenSelector, viewBuilder); + + /// + /// -typed overload of + /// ; + /// KeySelector defaults to t => t.Key so call sites can omit + /// it. (spec 042 §5) + /// + public static TemplatedTreeViewElement TreeView( + IReadOnlyList items, + Func?> childrenSelector, + Func viewBuilder) where T : IReactorKeyed => + new(items, static t => t.Key, childrenSelector, viewBuilder); + public static FlipViewElement FlipView(params Element[] items) => new(items); // ── Dialogs / Overlays ────────────────────────────────────────── diff --git a/src/Reactor/Elements/ElementExtensions.Events.cs b/src/Reactor/Elements/ElementExtensions.Events.cs index 637ca9804..a6c3c22b0 100644 --- a/src/Reactor/Elements/ElementExtensions.Events.cs +++ b/src/Reactor/Elements/ElementExtensions.Events.cs @@ -291,6 +291,14 @@ public static TreeViewElement ItemInvoked(this TreeViewElement el, Action? handler) => el with { OnExpanding = handler }; + /// Wires the item-invoked handler for the typed tree (receives the developer's T). Passing null clears. + public static TemplatedTreeViewElement ItemInvoked(this TemplatedTreeViewElement el, Action? handler) => + el with { OnItemInvoked = handler }; + + /// Wires the expanding handler for the typed tree (receives the developer's T). Passing null clears. + public static TemplatedTreeViewElement Expanding(this TemplatedTreeViewElement el, Action? handler) => + el with { OnExpanding = handler }; + /// Wires the selected-index-changed handler. Passing null clears. public static FlipViewElement SelectedIndexChanged(this FlipViewElement el, Action? handler) => el with { OnSelectedIndexChanged = handler }; diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/TemplatedTreeViewFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/TemplatedTreeViewFixtures.cs new file mode 100644 index 000000000..aa1dd6e1f --- /dev/null +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/TemplatedTreeViewFixtures.cs @@ -0,0 +1,406 @@ +using System.Linq; +using Microsoft.UI.Reactor; +using Microsoft.UI.Reactor.Core; +using Microsoft.UI.Reactor.AppTests.Host.SelfTest; +using static Microsoft.UI.Reactor.Factories; +using WinXC = Microsoft.UI.Xaml.Controls; + +namespace Microsoft.UI.Reactor.AppTests.Host.SelfTest.Fixtures; + +/// +/// Fixtures for the typed, data-driven TreeView<T> — the +/// hierarchical peer of ListView<T> that closes issue #447 +/// ("TreeViewNodeData.ContentElement renders blank"). The legacy node-mode +/// TreeView stringifies its content and cannot host a pre-built UIElement; +/// the typed peer renders each node from a data → Element viewBuilder +/// (the WinUI ItemTemplate equivalent), hosted via a ContentControl template. +/// +internal static class TemplatedTreeViewFixtures +{ + // A discriminated file-system model — folders nest, files are leaves. + private abstract record FsNode(string Id); + private record FsFolder(string Id, string Name, FsNode[] Children) : FsNode(Id); + private record FsFile(string Id, string Name, string Ext) : FsNode(Id); + + private static FsNode[] SampleTree() => + [ + new FsFolder("docs", "Documents", + [ + new FsFile("readme", "readme", "md"), + new FsFile("notes", "notes", "txt"), + ]), + new FsFolder("pics", "Pictures", + [ + new FsFile("logo", "logo", "png"), + ]), + ]; + + private static IReadOnlyList? ChildrenOf(FsNode n) => + n is FsFolder f ? f.Children : null; + + // viewBuilder = ItemTemplateSelector-as-a-switch. Folders and files get + // visibly distinct visuals (the "[D]" / "[F]" prefix is the tell). + private static Element BuildNodeView(FsNode n) => n switch + { + FsFolder f => HStack(TextBlock("[D]"), TextBlock(f.Name)), + FsFile file => HStack(TextBlock("[F]"), TextBlock($"{file.Name}.{file.Ext}")), + _ => TextBlock("?"), + }; + + // Per-node views host into their containers when the TreeView realizes them, + // which lands on a dispatcher cycle after mount — and the runtime decides how + // many pump cycles that takes (the NativeAOT host consistently needs one more + // than JIT). Pump render passes until the condition holds rather than + // asserting after a single Render(); returns false if it never does. + private static async Task WaitFor(Func condition, int maxPasses = 15) + { + for (int i = 0; i < maxPasses; i++) + { + if (condition()) return true; + await Harness.Render(); + } + return condition(); + } + + // ── 1. Rich content actually renders (the core #447 win) ────────────── + internal class RendersRichContent(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + var host = H.CreateHost(); + host.Mount(_ => + VStack( + TextBlock("File Explorer"), + TreeView(SampleTree(), + keySelector: n => n.Id, + childrenSelector: ChildrenOf, + viewBuilder: BuildNodeView) + // Expand folders so their child views realize. + with { IsExpanded = n => n is FsFolder } + ).Height(400) + ); + + await Harness.Render(); + + H.Check("TTV_RendersRichContent_TreeViewCreated", + H.FindControl(_ => true) is not null); + + // The node's view is a live HStack of TextBlocks — not a stringified + // blank row. Finding the folder name proves the content hosted. + H.Check("TTV_RendersRichContent_RootNodeVisible", + await WaitFor(() => H.FindTextContaining("Documents") is not null)); + + // The "[D]" prefix only exists inside the rich per-node template — + // a stringified node could never produce it. + H.Check("TTV_RendersRichContent_RichTemplateHosted", + await WaitFor(() => H.FindText("[D]") is not null)); + + // Expanded child leaf renders too. + H.Check("TTV_RendersRichContent_ChildLeafVisible", + await WaitFor(() => H.FindTextContaining("readme.md") is not null)); + } + } + + // ── 2. Heterogeneous nodes → per-shape templates ────────────────────── + internal class HeterogeneousTemplates(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + var host = H.CreateHost(); + host.Mount(_ => + TreeView(SampleTree(), + keySelector: n => n.Id, + childrenSelector: ChildrenOf, + viewBuilder: BuildNodeView) + with { IsExpanded = n => n is FsFolder } + ); + + await Harness.Render(); + + // Both the folder template ("[D]") and the file template ("[F]") + // are realized from the single switch-based viewBuilder. + H.Check("TTV_Heterogeneous_FolderTemplate", await WaitFor(() => H.FindText("[D]") is not null)); + H.Check("TTV_Heterogeneous_FileTemplate", await WaitFor(() => H.FindText("[F]") is not null)); + } + } + + // ── 3. Keyed update reconcile — in-place rename of a matched node ────── + // Structure is stable across the flip (same keys, same child count), so the + // matched node's view reconciles in place and stays hosted. (Structural + // add/remove that forces TreeView to re-realize containers is the separate + // §6 hosting tradeoff — see KeyedUpdateAddChild + the handoff doc.) + internal class KeyedUpdateReconcile(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + WinXC.TreeView? firstInstance = null; + + var host = H.CreateHost(); + host.Mount(ctx => + { + var (phase, set) = ctx.UseState(0); + FsNode[] tree = + [ + new FsFolder("root", "Root", + [ + new FsFile("a", phase == 0 ? "alpha" : "alpha-renamed", "txt"), + new FsFile("b", "beta", "txt"), + ]), + ]; + return VStack( + Button("Mutate", () => set(1)), + TreeView(tree, + keySelector: n => n.Id, + childrenSelector: ChildrenOf, + viewBuilder: BuildNodeView) + with { IsExpanded = _ => true } + ); + }); + + await Harness.Render(); + firstInstance = H.FindControl(_ => true); + H.Check("TTV_KeyedUpdate_InitialChild", + await WaitFor(() => H.FindTextContaining("alpha.txt") is not null)); + + H.ClickButton("Mutate"); + await Harness.Render(); + + // With per-container hosting the reused node's view reconciles in + // place and stays rendered in the visual tree. + H.Check("TTV_KeyedUpdate_RenamedChildReconciled", + await WaitFor(() => H.FindTextContaining("alpha-renamed.txt") is not null)); + // The rename has rendered, so the old text is gone ("alpha-renamed.txt" + // does not contain the substring "alpha.txt"). + H.Check("TTV_KeyedUpdate_OldTextGone", + H.FindTextContaining("alpha.txt") is null); + // The untouched sibling keeps rendering through the reconcile. + H.Check("TTV_KeyedUpdate_SiblingPreserved", + await WaitFor(() => H.FindTextContaining("beta.txt") is not null)); + + // The reconcile updated the existing control in place rather than + // remounting a fresh TreeView. + var secondInstance = H.FindControl(_ => true); + H.Check("TTV_KeyedUpdate_ControlIdentityPreserved", + firstInstance is not null && ReferenceEquals(firstInstance, secondInstance)); + } + } + + // ── 3b. Structural add — a freshly-keyed node appears and renders ───── + // Verifies the diff inserts a new node (built fresh, so it hosts cleanly) + // and the live TreeViewNode hierarchy reflects the new child count. The + // reused siblings' re-hosting under container recycle is the open §6 + // tradeoff, so this asserts the data/structure side, not their pixels. + internal class KeyedUpdateAddChild(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + var host = H.CreateHost(); + host.Mount(ctx => + { + var (phase, set) = ctx.UseState(0); + FsNode[] children = phase == 0 + ? [new FsFile("a", "alpha", "txt")] + : [new FsFile("a", "alpha", "txt"), new FsFile("b", "beta", "txt")]; + FsNode[] tree = [new FsFolder("root", "Root", children)]; + return VStack( + Button("Add", () => set(1)), + TreeView(tree, + keySelector: n => n.Id, + childrenSelector: ChildrenOf, + viewBuilder: BuildNodeView) + with { IsExpanded = _ => true } + ); + }); + + await Harness.Render(); + var tv = H.FindControl(_ => true); + H.Check("TTV_AddChild_InitialOneChild", + tv is not null && tv.RootNodes.Count == 1 && tv.RootNodes[0].Children.Count == 1); + + H.ClickButton("Add"); + await Harness.Render(); + + H.Check("TTV_AddChild_NodeInserted", + tv!.RootNodes[0].Children.Count == 2); + H.Check("TTV_AddChild_NewNodeRenders", + await WaitFor(() => H.FindTextContaining("beta.txt") is not null)); + } + } + + // ── 4. Event trampolines hand back the developer's own T (erasure) ──── + internal class EventErasureResolvesT(Harness h) : SelfTestFixtureBase(h) + { + public override Task RunAsync() + { + var tree = SampleTree(); + FsNode? invoked = null; + FsNode? expanding = null; + + var el = TreeView(tree, + keySelector: n => n.Id, + childrenSelector: ChildrenOf, + viewBuilder: BuildNodeView) + with + { + OnItemInvoked = n => invoked = n, + OnExpanding = n => expanding = n, + }; + + // The reconciler dispatches through the object-erased base; verify + // the cast back to T round-trips the original reference. + var roots = el.GetRoots(); + H.Check("TTV_Erasure_RootCount", roots.Count == 2); + H.Check("TTV_Erasure_KeyResolves", el.GetKey(roots[0]) == "docs"); + H.Check("TTV_Erasure_ChildrenResolve", el.GetChildren(roots[0])?.Count == 2); + H.Check("TTV_Erasure_LeafHasNoChildren", el.GetChildren(roots[1]) is { } pics && el.GetChildren(pics[0]) is null); + + el.InvokeItemInvoked(roots[0]); + H.Check("TTV_Erasure_ItemInvokedResolvesT", ReferenceEquals(invoked, tree[0])); + + el.InvokeExpanding(roots[1]); + H.Check("TTV_Erasure_ExpandingResolvesT", ReferenceEquals(expanding, tree[1])); + + return Task.CompletedTask; + } + } + + // ── 5. Value-type T is boxed/projected correctly ────────────────────── + internal class ValueTypeT(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + int[] items = [1, 2, 3]; + int invoked = -1; + + var el = TreeView(items, + keySelector: i => $"n{i}", + childrenSelector: i => i == 1 ? new[] { 10, 11 } : null, + viewBuilder: i => TextBlock($"#{i}")) + with { OnItemInvoked = i => invoked = i, IsExpanded = _ => true }; + + var roots = el.GetRoots(); + H.Check("TTV_ValueType_RootCount", roots.Count == 3); + H.Check("TTV_ValueType_KeyResolves", el.GetKey(roots[0]) == "n1"); + H.Check("TTV_ValueType_ChildrenResolve", el.GetChildren(roots[0])?.Count == 2); + + el.InvokeItemInvoked(roots[2]); + H.Check("TTV_ValueType_InvokeUnboxesT", invoked == 3); + + // And it actually mounts + renders. + var host = H.CreateHost(); + host.Mount(_ => el); + await Harness.Render(); + H.Check("TTV_ValueType_Renders", await WaitFor(() => H.FindTextContaining("#1") is not null)); + H.Check("TTV_ValueType_ChildRenders", await WaitFor(() => H.FindTextContaining("#10") is not null)); + } + } + + // ── 6. IsExpanded selector drives the node's initial expansion ──────── + internal class IsExpandedApplied(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + var host = H.CreateHost(); + host.Mount(_ => + TreeView(SampleTree(), + keySelector: n => n.Id, + childrenSelector: ChildrenOf, + viewBuilder: BuildNodeView) + with { IsExpanded = n => n.Id == "docs" } + ); + + await Harness.Render(); + + var tv = H.FindControl(_ => true); + H.Check("TTV_IsExpanded_TreeFound", tv is not null); + // First root ("docs") is expanded; the second ("pics") is not. + H.Check("TTV_IsExpanded_SelectedNodeExpanded", + tv!.RootNodes.Count == 2 && tv.RootNodes[0].IsExpanded); + H.Check("TTV_IsExpanded_OtherNodeCollapsed", + !tv.RootNodes[1].IsExpanded); + } + } + + // ── 6b. Expand/collapse cycles keep every child rendered ────────────── + // Regression for the "every other expand/collapse blanks the first child + // row(s)" bug: per-container hosting must re-mount a fresh view into + // whichever pooled container WinUI realizes each node into, so no row goes + // blank after a collapse→expand cycle recycles containers. + internal class ExpandCollapseCycle(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + var host = H.CreateHost(); + host.Mount(_ => + TreeView(SampleTree(), + keySelector: n => n.Id, + childrenSelector: ChildrenOf, + viewBuilder: BuildNodeView) + // Start collapsed so we drive the expansions ourselves. + with { IsExpanded = _ => false } + ); + + await Harness.Render(); + var tv = H.FindControl(_ => true); + H.Check("TTV_Cycle_TreeFound", tv is not null); + + var docs = tv!.RootNodes[0]; // "Documents" → readme.md, notes.txt + bool allCyclesOk = true; + + // Several collapse→expand cycles; after each expand both children + // must be present (the bug blanked the first child every 2nd cycle). + // WaitFor tolerates the realization landing a pump-cycle later, but a + // genuinely blank row never appears within the budget → fails. + for (int cycle = 0; cycle < 4; cycle++) + { + docs.IsExpanded = true; + bool firstChild = await WaitFor(() => H.FindTextContaining("readme.md") is not null); + bool secondChild = await WaitFor(() => H.FindTextContaining("notes.txt") is not null); + if (!firstChild || !secondChild) allCyclesOk = false; + + docs.IsExpanded = false; + await Harness.Render(); + } + + H.Check("TTV_Cycle_NoBlankRowsAcrossCycles", allCyclesOk); + + // Leave it expanded and confirm a final realization renders. + docs.IsExpanded = true; + H.Check("TTV_Cycle_FinalExpandRenders", + await WaitFor(() => H.FindTextContaining("readme.md") is not null) + && await WaitFor(() => H.FindTextContaining("notes.txt") is not null)); + } + } + + // ── 7. Unmount tears the tree down without leaking / throwing ───────── + internal class UnmountTearsDown(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + var host = H.CreateHost(); + host.Mount(ctx => + { + var (show, set) = ctx.UseState(true); + return VStack( + Button("Hide", () => set(false)), + show + ? TreeView(SampleTree(), + keySelector: n => n.Id, + childrenSelector: ChildrenOf, + viewBuilder: BuildNodeView) + with { IsExpanded = _ => true } + : TextBlock("gone") + ); + }); + + await Harness.Render(); + H.Check("TTV_Unmount_TreeMounted", H.FindControl(_ => true) is not null); + + H.ClickButton("Hide"); + await Harness.Render(); + + H.Check("TTV_Unmount_TreeRemoved", H.FindControl(_ => true) is null); + H.Check("TTV_Unmount_ReplacementShown", H.FindText("gone") is not null); + } + } +} diff --git a/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs b/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs index 963fb7a6a..53ebab5c0 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs @@ -142,6 +142,16 @@ internal static class SelfTestFixtureRegistry "MdHtml_HtmlGeneration", "MdHtml_HtmlInWebView2", "ListView_TypedRendering", + // Typed, data-driven TreeView — issue #447 (rich content renders). + "TTV_RendersRichContent", + "TTV_HeterogeneousTemplates", + "TTV_KeyedUpdateReconcile", + "TTV_KeyedUpdateAddChild", + "TTV_EventErasureResolvesT", + "TTV_ValueTypeT", + "TTV_IsExpandedApplied", + "TTV_ExpandCollapseCycle", + "TTV_UnmountTearsDown", // ItemsView reconciler arm — mount / update / layout-kind / selection. "ItemsView_Mount", "ItemsView_Layout_UniformGrid", @@ -1290,6 +1300,15 @@ internal static class SelfTestFixtureRegistry "MdHtml_HtmlGeneration" => new MarkdownHtmlFixtures.HtmlGeneration(harness), "MdHtml_HtmlInWebView2" => new MarkdownHtmlFixtures.HtmlInWebView2(harness), "ListView_TypedRendering" => new CollectionFixtures.ListViewTyped(harness), + "TTV_RendersRichContent" => new TemplatedTreeViewFixtures.RendersRichContent(harness), + "TTV_HeterogeneousTemplates" => new TemplatedTreeViewFixtures.HeterogeneousTemplates(harness), + "TTV_KeyedUpdateReconcile" => new TemplatedTreeViewFixtures.KeyedUpdateReconcile(harness), + "TTV_KeyedUpdateAddChild" => new TemplatedTreeViewFixtures.KeyedUpdateAddChild(harness), + "TTV_EventErasureResolvesT" => new TemplatedTreeViewFixtures.EventErasureResolvesT(harness), + "TTV_ValueTypeT" => new TemplatedTreeViewFixtures.ValueTypeT(harness), + "TTV_IsExpandedApplied" => new TemplatedTreeViewFixtures.IsExpandedApplied(harness), + "TTV_ExpandCollapseCycle" => new TemplatedTreeViewFixtures.ExpandCollapseCycle(harness), + "TTV_UnmountTearsDown" => new TemplatedTreeViewFixtures.UnmountTearsDown(harness), "ItemsView_Mount" => new ItemsViewFixtures.ItemsView_BasicMount(harness), "ItemsView_Layout_UniformGrid" => new ItemsViewFixtures.ItemsView_LayoutKind_AppliesUniformGrid(harness), "ItemsView_Layout_LinedFlow" => new ItemsViewFixtures.ItemsView_LayoutKind_AppliesLinedFlow(harness), From bca52935604e0788c04ed5e97bde4bee25718b19 Mon Sep 17 00:00:00 2001 From: Chris Anderson Date: Sat, 30 May 2026 00:28:52 -0700 Subject: [PATCH 2/2] ci(stress): add AOT selftest stress target (publish once, loop the exe) Adds an 'aot' target (and 'all' coverage) to the CI Stress workflow that publishes the NativeAOT selftest host once per shard and runs --self-test N times, flagging any iteration with a nonzero exit or a 'not ok' TAP line and uploading the failing TAP. Optional 'filter' input scopes to a fixture prefix (e.g. TTV_). Used to hunt intermittent AOT-only failures. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci-stress.yml | 85 ++++++++++++++++++++++++++++++++- 1 file changed, 83 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-stress.yml b/.github/workflows/ci-stress.yml index ecf633044..e20776960 100644 --- a/.github/workflows/ci-stress.yml +++ b/.github/workflows/ci-stress.yml @@ -16,6 +16,7 @@ on: options: - unit - selftests + - aot - integration - all shards: @@ -23,6 +24,11 @@ on: required: true default: "20" type: string + filter: + description: "Optional --self-test --filter prefix for the aot target (e.g. TTV_). Empty = full suite." + required: false + default: "" + type: string jobs: plan: @@ -217,9 +223,82 @@ jobs: } Write-Host "Shard ${idx}: $count iterations passed." + aot-selftests: + name: AOT Selftests (shard ${{ matrix.shard }}) + needs: plan + if: ${{ inputs.target == 'aot' || inputs.target == 'all' }} + runs-on: windows-latest + timeout-minutes: 350 + strategy: + fail-fast: false + matrix: + shard: ${{ fromJSON(needs.plan.outputs.shard-list) }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup .NET + uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0 + with: + dotnet-version: 10.0.x + + # Publish the NativeAOT selftest host once per shard (same knobs as ci.yml). + - name: Publish AOT host (once) + run: > + dotnet publish tests/Reactor.AppTests.Host + -p:PublishAotInternal=true + -p:Platform=x64 + -r win-x64 + -c Release + -o ${{ runner.temp }}/aot-publish + --nologo + + - name: Stress loop + shell: pwsh + env: + PER_SHARD: ${{ needs.plan.outputs.per-shard }} + REMAINDER: ${{ needs.plan.outputs.remainder }} + SHARD_INDEX: ${{ matrix.shard }} + FILTER: ${{ inputs.filter }} + run: | + $exe = Join-Path '${{ runner.temp }}/aot-publish' 'Reactor.AppTests.Host.exe' + if (-not (Test-Path $exe)) { Write-Host "::error::AOT host not found at $exe"; exit 1 } + $per = [int]$env:PER_SHARD + $rem = [int]$env:REMAINDER + $idx = [int]$env:SHARD_INDEX + $count = if ($idx -le $rem) { $per + 1 } else { $per } + if ($count -lt 1) { Write-Host "Shard $idx has no work."; exit 0 } + $args = @('--self-test') + if ($env:FILTER) { $args += @('--filter', $env:FILTER) } + $failures = New-Object System.Collections.Generic.List[int] + for ($i = 1; $i -le $count; $i++) { + Write-Host "::group::AOT selftest iteration $i / $count (shard $idx)" + & $exe @args 2>&1 | Tee-Object -FilePath "aot-$idx-$i.tap" + $code = $LASTEXITCODE + # A nonzero exit OR any 'not ok' line is a failure for this iteration. + $notOk = Select-String -Path "aot-$idx-$i.tap" -Pattern '^not ok ' -SimpleMatch -Quiet + Write-Host "::endgroup::" + if ($code -ne 0 -or $notOk) { + Write-Host "::warning::AOT iteration $i (shard $idx) failed (exit $code, notOk=$notOk)" + $failures.Add($i) | Out-Null + } + } + if ($failures.Count -gt 0) { + Write-Host "::error::Shard $idx had $($failures.Count) failed iteration(s): $($failures -join ', ')" + exit 1 + } + Write-Host "Shard ${idx}: $count AOT iterations passed." + + - name: Upload failing TAP + if: ${{ failure() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: aot-stress-tap-shard-${{ matrix.shard }} + path: aot-*.tap + if-no-files-found: ignore + summary: name: Stress summary - needs: [plan, unit-tests, selftests, integration-tests] + needs: [plan, unit-tests, selftests, aot-selftests, integration-tests] if: ${{ always() }} runs-on: ubuntu-latest steps: @@ -227,14 +306,16 @@ jobs: env: UNIT_RESULT: ${{ needs.unit-tests.result }} SELFTESTS_RESULT: ${{ needs.selftests.result }} + AOT_RESULT: ${{ needs.aot-selftests.result }} INTEGRATION_RESULT: ${{ needs.integration-tests.result }} run: | echo "Unit shards: $UNIT_RESULT" echo "Selftest shards: $SELFTESTS_RESULT" + echo "AOT selftest shards: $AOT_RESULT" echo "Integration shards: $INTEGRATION_RESULT" fail=0 # 'skipped' is OK (target filter); 'success' is OK; anything else is a fail. - for r in "$UNIT_RESULT" "$SELFTESTS_RESULT" "$INTEGRATION_RESULT"; do + for r in "$UNIT_RESULT" "$SELFTESTS_RESULT" "$AOT_RESULT" "$INTEGRATION_RESULT"; do case "$r" in success|skipped|"") ;; *) fail=1 ;;