diff --git a/.github/workflows/ci-stress.yml b/.github/workflows/ci-stress.yml index ecf63304..e2077696 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 ;; diff --git a/samples/Reactor.TestApp/Demos/DataTemplateDemo.cs b/samples/Reactor.TestApp/Demos/DataTemplateDemo.cs index 1e79cdf4..5e430a99 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 6af11b46..33b2d64d 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 7629de99..29291eb3 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 a51ead1a..b61d7c27 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 a4291d27..77da675b 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 85a69464..25a15161 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 20002523..71fb77cb 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 637ca980..a6c3c22b 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 00000000..aa1dd6e1 --- /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 963fb7a6..53ebab5c 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),