From 7ae71e9d3dd2ea704e7128830cbbdcab2a4aefeb Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Mon, 19 Oct 2026 11:14:35 -0700 Subject: [PATCH 1/4] Simplify the resource-refresh pass: drop mounted-output snapshots Theme.NotifyResourcesChanged refreshes Theme.Ref bindings, ThemeRef resource overrides and values resolved during Render. #1329 also tried to refresh brushes a memo factory or item builder captured eagerly with ThemeRef.Resolve, by recording what every memo layer and FlipView item mounted and diffing against that. That needed per-layer snapshot state, same-key layer ordering, legacy-wrapper handling, fresh-control markers, a wrapper-value replay and snapshot cleanup - most of the reconciler change, for a pattern with no uses in samples, docs or the agent kit. Drop it and document the contract instead: an eagerly captured brush is a fixed value; bind it with Theme.Ref. A keyed memo now re-runs its factory against itself during a refresh, through the existing dirty-descendant path. Also move the hosted-slot unmount fix (UpdateHostedChild / UpdateChildTracked) out; it fixes a leak on main independent of refresh and goes in its own PR. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 4 +- docs/_pipeline/templates/styling.md.dt | 6 + docs/guide/styling.md | 6 + src/Reactor/Core/ChildReconciler.cs | 22 +- src/Reactor/Core/ElementPool.cs | 2 - src/Reactor/Core/Reconciler.Mount.cs | 82 +-- src/Reactor/Core/Reconciler.Update.cs | 262 +------- src/Reactor/Core/Reconciler.cs | 96 +-- .../Core/V1Protocol/CompositeLifecycle.cs | 8 +- .../V1Protocol/Handlers/ExpanderHandler.cs | 2 +- .../V1Protocol/NavigationHostLifecycle.cs | 5 +- .../Core/V1Protocol/OverlayLifecycle.cs | 6 +- .../V1Protocol/Reconciler.TemplatedTree.cs | 2 +- .../Core/V1Protocol/TemplatedListLifecycle.cs | 54 +- .../HostIdleAndThemeResourceFixtures.cs | 570 +++--------------- 15 files changed, 157 insertions(+), 970 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 94164ccf8..05560e1d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,7 +91,9 @@ Conventions for contributors: that cache only on a theme or palette change, so such an edit was invisible to `ThemeRef` modifiers, overrides and `ThemeRef.Resolve`. The call clears the cache and re-renders every live host past memoization and the skip of unchanged elements. Callable from - any thread (issue #1327). + any thread (issue #1327). A brush resolved eagerly and kept (`ThemeRef.Resolve` inside a + memo factory, or stored in state) is a fixed value and is not refreshed; bind it with + `Theme.Ref` instead. - **Getting Started documents the single-file path.** A Reactor app does not need a `.csproj`: .NET 10 runs a lone `.cs` file whose `#:package` / `#:property` diff --git a/docs/_pipeline/templates/styling.md.dt b/docs/_pipeline/templates/styling.md.dt index 340c93a8a..d930642f0 100644 --- a/docs/_pipeline/templates/styling.md.dt +++ b/docs/_pipeline/templates/styling.md.dt @@ -277,6 +277,12 @@ new value, memoized subtrees included. It is safe to call from any thread; await a host's `WaitForIdleAsync()` to observe the result. Mutating an existing brush's `Color` in place does not need it. +A brush you resolve *eagerly* and keep is not refreshed: a +`ThemeRef.Resolve(...)` inside a `UseMemo` or `Memo(key, ...)` factory, or a +resolved brush stored in state, is a fixed value Reactor can't trace back +to its key. Bind it instead (`.Foreground(Theme.Ref(key))`), or resolve it +in `Render` outside the memo. + ### Per-element theme override scope Pin one panel to a different scheme while the rest of the app follows diff --git a/docs/guide/styling.md b/docs/guide/styling.md index 9030fe2fe..d157ed882 100644 --- a/docs/guide/styling.md +++ b/docs/guide/styling.md @@ -460,6 +460,12 @@ new value, memoized subtrees included. It is safe to call from any thread; await a host's `WaitForIdleAsync()` to observe the result. Mutating an existing brush's `Color` in place does not need it. +A brush you resolve *eagerly* and keep is not refreshed: a +`ThemeRef.Resolve(...)` inside a `UseMemo` or `Memo(key, ...)` factory, or a +resolved brush stored in state, is a fixed value Reactor can't trace back +to its key. Bind it instead (`.Foreground(Theme.Ref(key))`), or resolve it +in `Render` outside the memo. + ### Per-element theme override scope Pin one panel to a different scheme while the rest of the app follows diff --git a/src/Reactor/Core/ChildReconciler.cs b/src/Reactor/Core/ChildReconciler.cs index accbf0e2f..a6c1745cf 100644 --- a/src/Reactor/Core/ChildReconciler.cs +++ b/src/Reactor/Core/ChildReconciler.cs @@ -252,11 +252,11 @@ private static void UpdateCommonChild( if (reconciler.CanUpdate(oldEl, newEl)) { existingControl ??= children.Get(i); - var replacement = reconciler.UpdateChildTracked(oldEl, newEl, existingControl, requestRerender, out var unmountedByUpdate); + var replacement = reconciler.UpdateChild(oldEl, newEl, existingControl, requestRerender); if (replacement is not null) { // Child type changed at runtime — replace in place - if (!unmountedByUpdate) reconciler.UnmountChild(existingControl); + reconciler.UnmountChild(existingControl); children.Replace(i, replacement); } } @@ -346,10 +346,10 @@ private static void ReconcileKeyed( if (prefixLen < childCount) { existingControl ??= children.Get(prefixLen); - var replacement = reconciler.UpdateChildTracked(oldEl, newEl, existingControl, requestRerender, out var unmountedByUpdate); + var replacement = reconciler.UpdateChild(oldEl, newEl, existingControl, requestRerender); if (replacement is not null) { - if (!unmountedByUpdate) reconciler.UnmountChild(existingControl); + reconciler.UnmountChild(existingControl); children.Replace(prefixLen, replacement); } } @@ -401,10 +401,10 @@ private static void ReconcileKeyed( if (panelIdx >= 0 && panelIdx < childCount) { existingControl ??= children.Get(panelIdx); - var replacement = reconciler.UpdateChildTracked(oldEl, newEl, existingControl, requestRerender, out var unmountedByUpdate); + var replacement = reconciler.UpdateChild(oldEl, newEl, existingControl, requestRerender); if (replacement is not null) { - if (!unmountedByUpdate) reconciler.UnmountChild(existingControl); + reconciler.UnmountChild(existingControl); children.Replace(panelIdx, replacement); } } @@ -771,16 +771,14 @@ public void Patch(int oldRelIdx, int newIdx, int panelIdx) // that adds a CanSkipUpdate fast-path here MUST mirror Reconciler.Update's skip arm // (ApplyThemeBindings + ApplyResourceOverrides) or it will re-introduce the #701/#675 // staleness — see Issue675ResourceOverrideSkipFixtures.KeyedMiddleReorderReResolves. - var existing = _children.Get(panelIdx); - var replacement = _reconciler.UpdateChildTracked( + var replacement = _reconciler.UpdateChild( _oldChildren[_oldStart + oldRelIdx], _newChildren[_newStart + newIdx], - existing, - _requestRerender, - out var unmountedByUpdate); + _children.Get(panelIdx), + _requestRerender); if (replacement is not null) { - if (!unmountedByUpdate) _reconciler.UnmountChild(existing); + _reconciler.UnmountChild(_children.Get(panelIdx)); _children.Replace(panelIdx, replacement); } } diff --git a/src/Reactor/Core/ElementPool.cs b/src/Reactor/Core/ElementPool.cs index 2684387ae..e7fc37bf5 100644 --- a/src/Reactor/Core/ElementPool.cs +++ b/src/Reactor/Core/ElementPool.cs @@ -226,8 +226,6 @@ internal static void CleanElement(FrameworkElement fe) { // Common properties Reconciler.ClearElementTag(fe); - // Refresh snapshots hold the previous renter's element graphs and describe its mount. - Reconciler.ClearRefreshSnapshots(fe); // SECURITY (TASK-060): clear the Current* user-handler delegates on // pool return so a pooled control can't fire the previous component's // captured rerender closure into the next mount. The underlying diff --git a/src/Reactor/Core/Reconciler.Mount.cs b/src/Reactor/Core/Reconciler.Mount.cs index f73a8e765..607cc93d1 100644 --- a/src/Reactor/Core/Reconciler.Mount.cs +++ b/src/Reactor/Core/Reconciler.Mount.cs @@ -119,7 +119,7 @@ public sealed partial class Reconciler // factory output) when it changes, so the mounted inner is never re-derived from // the old factory. Any modifiers on the wrapper itself are applied by the // post-dispatch ApplyModifiers below, exactly like any other element. - KeyedMemoElement km => MountKeyedMemo(km, requestRerender), + KeyedMemoElement km => Mount(WithWrapperKey(km.Factory() ?? EmptyElement.Instance, km.Key), requestRerender), // EmptyElement is a no-op sentinel — callers (Reconcile, panel // children loops, ChildReconciler) already filter it before // reaching Mount, but MountContext.MountChild does not, so a V1 @@ -277,86 +277,6 @@ private static Element WithWrapperKey(Element inner, string? wrapperKey) => ? inner : inner with { Key = wrapperKey }; - private UIElement? MountKeyedMemo(KeyedMemoElement km, Action requestRerender) - { - var inner = WithWrapperKey(km.Factory() ?? EmptyElement.Instance, km.Key); - var control = Mount(inner, requestRerender); - // Nested memos share the realized control, so each layer records its own output. On a - // fresh control the layers inside this one have already recorded; this one goes on top. - if (control is FrameworkElement fe) - { - var state = GetOrCreateReactorState(fe); - SetKeyedMemoOutput(state, km.MemoKey, CountKeyedMemoOutputs(state, km.MemoKey), inner); - } - return control; - } - - // The outputs recorded on a control are kept in the order their layers mounted, innermost - // first; layers with an equal MemoKey are told apart by that order (nth among the key's - // entries). RefreshKeyedMemo walks them outermost first. - - internal static int CountKeyedMemoOutputs(ReactorState state, object memoKey) - { - int count = state.KeyedMemoKey is { } first && first.Equals(memoKey) ? 1 : 0; - if (state.MoreKeyedMemoOutputs is { } more) - count += more.Count(entry => entry.Key.Equals(memoKey)); - return count; - } - - /// The output recorded for , or null. - internal static Element? GetKeyedMemoOutput(ReactorState state, object memoKey, int nth) - { - if (state.KeyedMemoKey is { } first && first.Equals(memoKey)) - { - if (nth == 0) return state.KeyedMemoOutput; - nth--; - } - if (state.MoreKeyedMemoOutputs is { } more) - { - foreach (var entry in more.Where(entry => entry.Key.Equals(memoKey))) - { - if (nth == 0) return entry.Value; - nth--; - } - } - return null; - } - - /// - /// Replaces the output recorded for , or - /// appends one when fewer are recorded. - /// - internal static void SetKeyedMemoOutput(ReactorState state, object memoKey, int nth, Element output) - { - if (state.KeyedMemoKey is null) - { - state.KeyedMemoKey = memoKey; - state.KeyedMemoOutput = output; - return; - } - if (state.KeyedMemoKey.Equals(memoKey)) - { - if (nth == 0) - { - state.KeyedMemoOutput = output; - return; - } - nth--; - } - var more = state.MoreKeyedMemoOutputs ??= new(); - for (int i = 0; i < more.Count; i++) - { - if (!more[i].Key.Equals(memoKey)) continue; - if (nth == 0) - { - more[i] = new(memoKey, output); - return; - } - nth--; - } - more.Add(new(memoKey, output)); - } - /// /// Final dispatch arm: the four resolution arms in /// (per-host _v1Handlers, per-host _typeRegistry, global diff --git a/src/Reactor/Core/Reconciler.Update.cs b/src/Reactor/Core/Reconciler.Update.cs index 1c899c995..8b9b74f31 100644 --- a/src/Reactor/Core/Reconciler.Update.cs +++ b/src/Reactor/Core/Reconciler.Update.cs @@ -31,9 +31,6 @@ public sealed partial class Reconciler internal UIElement? Update(Element oldEl, Element newEl, UIElement control, Action requestRerender) { DebugElementsDiffed++; - // As received, legacy ModifiedElement layers included: a keyed Memo that remounts during a - // resource refresh mounts this, so the layers' modifiers apply as on a first mount. - var receivedNewEl = newEl; // Unwrap all layers of ModifiedElement, accumulating modifiers. // Inner modifiers override outer ones (via Merge: other wins where non-null). ElementModifiers? oldModifiers = oldEl.Modifiers; @@ -236,44 +233,17 @@ public sealed partial class Reconciler // time to reconstruct the old tree (which would diff against the wrong "old" if the // factory reads mutable state by reference). Wrapper modifiers are still applied by // the post-dispatch ApplyModifiers below. - // The one exception is a component inside the subtree that updated its own state: - // returning null would leave it un-rendered, so the pass walks down to it instead. - // A resource-refresh pass (Theme.NotifyResourcesChanged) re-runs the factory and - // diffs it against the output realized last time, so theme values inside the - // memoized subtree are re-applied, including brushes the factory resolved eagerly. - (KeyedMemoElement, KeyedMemoElement memo, _) when _resourceRefreshActive - => RefreshKeyedMemo(memo, receivedNewEl, control, requestRerender), - (KeyedMemoElement, KeyedMemoElement memo, _) when IsOnDirtyAncestorPath(control) + // The exceptions are a component inside the subtree that updated its own state, and + // a resource-refresh pass (Theme.NotifyResourcesChanged), which must reach every + // Theme.Ref value inside: returning null would leave them stale, so the pass walks the + // subtree instead. + (KeyedMemoElement, KeyedMemoElement memo, _) when _resourceRefreshActive || IsOnDirtyAncestorPath(control) => UpdateKeyedMemoTowardDirtyDescendant(memo, control, requestRerender), (KeyedMemoElement, KeyedMemoElement, _) => null, _ => Mount(newEl, requestRerender), }; } - // A dispatch that fully mounted the new element (a keyed Memo remounted during a resource - // refresh) already applied its modifiers and lifecycle with mount semantics; diffing them - // old->new here would skip unchanged ones on the fresh control and fire OnUpdate. - if (result is not null && ReferenceEquals(result, _freshMountFromDispatch)) - { - _freshMountFromDispatch = null; - // Layers that hand this control up as their own (an outer memo, a flyout whose target - // this is) are fresh to it too. - _freshForWrapperMount = result; - return result; - } - // A transparent layer (an outer keyed Memo, a decorator whose target it is) whose inner - // control is fresh: this layer's own modifiers apply with mount semantics (all written, - // OnMount runs) rather than as an old->new diff. The marker stays set for the layers - // above, and is dropped when the pass ends. - if (result is not null && !ReferenceEquals(result, control) && ReferenceEquals(result, _freshForWrapperMount)) - oldModifiers = null; - // A flyout that wraps its target hands the target's control up as its own; when the target - // was replaced (its type changed), that control is new to the flyout too, so the flyout's - // modifiers apply with mount semantics. The flyout updaters never remount the flyout itself. - else if (result is not null && !ReferenceEquals(result, control) - && newEl is FlyoutElement or MenuFlyoutElement or CommandBarFlyoutElement) - oldModifiers = null; - // Apply inline modifiers after update. When old modifiers existed but new // modifiers are null, pass an empty instance so ApplyModifiers can clear // stale values (same principle as the flex attached-property fix). @@ -297,17 +267,6 @@ public sealed partial class Reconciler if ((modifiers is not null || oldModifiers is not null) && target is FrameworkElement fe) ApplyModifiers(fe, oldModifiers, modifiers ?? new ElementModifiers(), requestRerender); - // A layer that shares its inner element's control (a keyed memo, a flyout wrapping its target) - // refreshed in place: the inner diff may have rewritten a property this layer also sets, and - // this layer's value wins (it applies after the inner one's at mount). - // Legacy ModifiedElement layers were stripped from newEl above; their modifiers sit on the - // same control as the inner element's properties, so they replay too. - if (_resourceRefreshActive - && (newEl is KeyedMemoElement or FlyoutElement or MenuFlyoutElement or CommandBarFlyoutElement - || receivedNewEl is ModifiedElement) - && (result is null || ReferenceEquals(result, control)) - && modifiers is { } wrapperMods && target is FrameworkElement wrapperFe) - ReassertWrapperValues(wrapperFe, wrapperMods, requestRerender); if (target is FrameworkElement dragFe) ApplyDragAttached(dragFe, newEl.GetAttached()); // Issue #1262 — re-bind (or withdraw) the attached verdict. The old element is @@ -1218,7 +1177,7 @@ internal void ReconcileChild(Element? oldChild, Element? newChild, if (newChild is not null && oldChild is not null && getControl() is UIElement existing && CanUpdate(oldChild, newChild)) { - var replacement = UpdateHostedChild(oldChild, newChild, existing, requestRerender); + var replacement = Update(oldChild, newChild, existing, requestRerender); if (replacement is not null) setControl(replacement); } else if (newChild is not null) @@ -1273,12 +1232,18 @@ internal void RefreshRealizedContainers(WinUI.ListViewBase listViewBase, Interna var oldItemElement = GetElementTag(cc); var newItemElement = viewSource.BuildItemView(index); - // ReconcileV1Child updates in place when it can and remounts otherwise, and either way - // unmounts a control it replaced (e.g. a same-key Memo whose output changed shape - // during a resource refresh), so the old item's effects and refs are cleaned up. - var next = ReconcileV1Child(oldItemElement, newItemElement, cc.Content as UIElement, requestRerender); - if (!ReferenceEquals(cc.Content, next)) - cc.Content = next; + if (oldItemElement is not null && cc.Content is UIElement existingCtrl && CanUpdate(oldItemElement, newItemElement)) + { + var replacement = Update(oldItemElement, newItemElement, existingCtrl, requestRerender); + if (replacement is not null && !ReferenceEquals(cc.Content, replacement)) + cc.Content = replacement; + } + else + { + if (cc.Content is UIElement oldCtrl) + Unmount(oldCtrl); + cc.Content = Mount(newItemElement, requestRerender); + } SetElementTag(cc, newItemElement); // Issue #951 — the container survives re-renders, so its automation @@ -1504,7 +1469,7 @@ private void ReconcileTreeNodeContent( && CanUpdate(oldContentEl, newContentEl)) { // Reconcile in place - var replacement = UpdateHostedChild(oldContentEl, newContentEl, existingCtrl, requestRerender); + var replacement = Update(oldContentEl, newContentEl, existingCtrl, requestRerender); if (replacement is not null && !ReferenceEquals(liveNode.Content, replacement)) liveNode.Content = replacement; } @@ -1602,7 +1567,10 @@ private void ReconcileTreeNodeContent( /// /// A same-key Memo(key, …) whose subtree holds a component that updated its own - /// state. The mounted inner element is not kept, so re-run the factory — pure by contract, + /// state, or any same-key memo during a resource-refresh pass (where nothing skips, so the + /// whole subtree is walked and its theme values are applied again; a value the factory + /// resolved eagerly with ThemeRef.Resolve is equal on both sides and is not, which + /// the styling guide documents). The mounted inner element is not kept, so re-run the factory — pure by contract, /// so its output describes what is mounted — and reconcile that output against itself. /// Old and new are the same instances all the way down, so every branch skips by reference /// except the dirty-ancestor path, which descends to the component. Nothing is compared @@ -1614,190 +1582,6 @@ private void ReconcileTreeNodeContent( return inner is EmptyElement ? null : Update(inner, inner, control, requestRerender); } - /// - /// A same-key Memo(key, …) during a resource-refresh pass. Re-runs the factory and - /// diffs the output against what was realized last time (recorded at mount and here), not - /// against itself: a brush the factory resolved eagerly with ThemeRef.Resolve is - /// equal on both sides of a self-diff, and diff-based setters would skip writing it. - /// - /// Each memo layer on a control records its own output, so a nested memo (directly, - /// inside legacy layers, or as a flyout's target) is diffed - /// against its recorded element too: the wrapper's modifiers are diffed old to new, and the - /// nested memo's own refresh diffs against its own recorded output. - /// Output changed shape (root type or key, also inside legacy wrappers) from the recorded one: remount the memo and - /// return the new control; the caller unmounts the old one and swaps it in. - /// Nested memos with an equal key on one control are told apart by mount order. - /// Nothing recorded (a control that is not a FrameworkElement): self-diff. - /// Output is Empty: keep the realized control. Update can't express a removal, and - /// a same-key factory is pure by contract, so a resource edit that empties it is out of - /// contract. - /// - /// - private UIElement? RefreshKeyedMemo(KeyedMemoElement memo, Element received, UIElement control, Action requestRerender) - { - var inner = WithWrapperKey(memo.Factory() ?? EmptyElement.Instance, memo.Key); - if (inner is EmptyElement) return null; - - // Same-key layers sharing this control are recorded innermost first and refreshed - // outermost first, so the enclosing same-key refreshes on this control say which entry - // is this layer's. - var state = control is FrameworkElement fe && TryGetReactorState(fe, out var s) ? s : null; - int nth = -1; - Element? recorded = null; - if (state is not null) - { - int enclosing = 0; - if (_memoRefreshFrames is { } open) - { - foreach (var (key, layerControl) in open) - { - if (ReferenceEquals(layerControl, control) && key.Equals(memo.MemoKey)) enclosing++; - } - } - nth = CountKeyedMemoOutputs(state, memo.MemoKey) - 1 - enclosing; - if (nth >= 0) recorded = GetKeyedMemoOutput(state, memo.MemoKey, nth); - } - - if (recorded is not null && !CanUpdateThroughWrappers(recorded, inner)) - { - // The output changed shape: mount the memo element as Update received it, legacy - // ModifiedElement layers included, so its modifiers and lifecycle apply with mount - // semantics and MountKeyedMemo records the output. The caller unmounts the old - // control, as for any replacement Update returns. - var remounted = Mount(received, requestRerender); - if (remounted is not null) _freshMountFromDispatch = remounted; - return remounted; - } - - var frames = _memoRefreshFrames ??= new(); - frames.Add((memo.MemoKey, control)); - UIElement? replacement; - try - { - replacement = Update(recorded ?? inner, inner, control, requestRerender); - } - finally - { - frames.RemoveAt(frames.Count - 1); - } - - // A compatible output that still came back as a fresh control (a nested memo that - // remounted, or a handler that always remounts, such as ValidationVisualizer): the - // enclosing Update applies this memo's wrapper modifiers to it with mount semantics. The - // caller unmounts the old control. - bool kept = replacement is null || ReferenceEquals(replacement, control); - if (!kept) _freshForWrapperMount = replacement; - if ((replacement ?? control) is FrameworkElement realized) - { - // Kept: overwrite this layer's entry. Fresh: the layers inside it recorded on the new - // control as they mounted, so this one goes on top. - var realizedState = GetOrCreateReactorState(realized); - SetKeyedMemoOutput(realizedState, memo.MemoKey, - kept && nth >= 0 ? nth : CountKeyedMemoOutputs(realizedState, memo.MemoKey), inner); - } - return replacement; - } - - /// - /// Writes a wrapper's plain property values (layout, visual, typography, enabled state, string - /// tooltip, automation and accessibility values, focus, access-key and drag-region settings, and - /// reference targets) to - /// again, through - /// with only those values unset on the old side. Every other field is identical on both - /// sides, so ApplyModifiers leaves it alone (no lifecycle callback, handler or ref change); - /// hosted children (a rich tooltip, flyouts) are left out of both, so they are not - /// reconciled again, and OnUpdateAction is left out so it runs once per update. - /// - private void ReassertWrapperValues(FrameworkElement fe, ElementModifiers m, Action requestRerender) - { - var current = m with { RichToolTip = null, AttachedFlyout = null, ContextFlyout = null, OnUpdateAction = null }; - var unset = current with - { - Layout = null, - Visual = null, - // A string tooltip is a plain value; next to a rich one it would replace it. - ToolTip = m.RichToolTip is null ? null : current.ToolTip, - ToolTipPlacement = null, - IsEnabled = null, - AutomationName = null, - AutomationId = null, - ElementSoundMode = null, - FontFamily = null, - FontSize = null, - FontWeight = null, - HeadingLevel = null, - IsTabStop = null, - IsHitTestVisible = null, - TabIndex = null, - AccessKey = null, - XYFocusKeyboardNavigation = null, - IsDragRegion = null, - // Scalar accessibility values, including the LabeledBy automation id; the LabeledByRef - // and DescribedBy references stay equal. - Accessibility = current.Accessibility is { } a11y - ? a11y with - { - HelpText = null, - FullDescription = null, - LandmarkType = null, - AccessibilityView = null, - IsRequiredForForm = null, - LiveSetting = null, - PositionInSet = null, - SizeOfSet = null, - Level = null, - ItemStatus = null, - TabFocusNavigation = null, - LabeledBy = null, - } - : null, - }; - ApplyModifiers(fe, unset, current, requestRerender); - - // Reference-backed values (a LabeledBy ElementRef, XY-focus targets) are wired once per - // ref and skip an unchanged one, so write this wrapper's current targets again, keeping - // their subscriptions. A string LabeledBy the output parked for Loaded must not land later. - ReapplyWrapperReference(fe, ReferenceSlots.ModifierRef_LabeledBy, m.Accessibility?.LabeledByRef); - ReapplyWrapperReference(fe, ReferenceSlots.ModifierRef_XYFocusUp, m.XYFocusUpRef); - ReapplyWrapperReference(fe, ReferenceSlots.ModifierRef_XYFocusDown, m.XYFocusDownRef); - ReapplyWrapperReference(fe, ReferenceSlots.ModifierRef_XYFocusLeft, m.XYFocusLeftRef); - ReapplyWrapperReference(fe, ReferenceSlots.ModifierRef_XYFocusRight, m.XYFocusRightRef); - if (m.Accessibility?.LabeledByRef is not null && TryGetReactorState(fe, out var labelState)) - labelState.PendingLabeledBy = null; - } - - private static void ReapplyWrapperReference(FrameworkElement fe, int slot, Microsoft.UI.Reactor.Input.ElementRef? cell) - { - if (cell is not null && TryGetReactorState(fe, out var state) - && state.ReferenceEdges?.Edges.TryGetValue(slot, out var edge) == true - && ReferenceEquals(edge.Cell, cell)) - edge.Apply?.Invoke(fe, cell.Current); - } - // The keyed memo layers whose refresh is in progress, with the control each was refreshing. - private List<(object Key, UIElement Control)>? _memoRefreshFrames; - - // CanUpdate, also through legacy ModifiedElement layers on both sides, which Update unwraps - // before dispatching to the inner elements' handlers. - private bool CanUpdateThroughWrappers(Element oldEl, Element newEl) - { - if (!CanUpdate(oldEl, newEl)) return false; - while (oldEl is ModifiedElement oldMod && newEl is ModifiedElement newMod) - { - oldEl = oldMod.Inner; - newEl = newMod.Inner; - if (!CanUpdate(oldEl, newEl)) return false; - } - return true; - } - // Set by a dispatch arm that returned a fully mounted control; consumed by Update right after - // the dispatch so its post-dispatch modifier diff is skipped for that control. - private UIElement? _freshMountFromDispatch; - - // A fresh control a resource refresh handed up through transparent layers (a remounted memo, - // or a kept memo whose output came back fresh); each enclosing Update that returns it applies - // its own modifiers with mount semantics. Cleared when the top-level pass ends. - private UIElement? _freshForWrapperMount; - private static string Truncate(string s, int maxLen) => s.Length <= maxLen ? s : s[..maxLen] + "…"; diff --git a/src/Reactor/Core/Reconciler.cs b/src/Reactor/Core/Reconciler.cs index 35b1830f5..fef83e6f2 100644 --- a/src/Reactor/Core/Reconciler.cs +++ b/src/Reactor/Core/Reconciler.cs @@ -539,10 +539,10 @@ internal void RefreshRealizedItemContainers( if (i < 0 || i >= items.Length) continue; var next = ReconcileV1Child(items[i], items[i], existing, requestRerender); if (next is not null && !ReferenceEquals(next, existing)) + { cc.Content = next; - // The item's own automation name can change in place too (a keyed memo whose output - // reads a resource), so forward it after every reconcile, as RefreshRealizedContainers does. - PropagateItemAutomationName(container, cc.Content as UIElement); + PropagateItemAutomationName(container, next); + } } } @@ -928,31 +928,6 @@ internal sealed class ReactorState /// public HashSet? ManagedResourceKeys; - /// - /// The factory output each layer realized on this control - /// last, keyed by its , so a resource-refresh pass - /// can diff a fresh output against what is mounted (RefreshKeyedMemo). Nested memos, - /// including ones separated by a transparent decorator such as a flyout's target, share - /// one control, so each layer keeps its own entry: the first inline, any others in - /// . Stored here, not in a table keyed by the managed - /// wrapper, for the same reason as . Cleared on pool - /// return; read only for a keyed memo's control, and guarded by CanUpdate. - /// - public object? KeyedMemoKey; - - /// The output recorded for . - public Element? KeyedMemoOutput; - - /// Outputs of further memo layers sharing this control. - public List>? MoreKeyedMemoOutputs; - - /// - /// For a templated FlipView: per logical item, the element it mounted or was last updated to and whether it realized a control, - /// so an update diffs against what is realized rather than a view rebuilt from the old - /// element (a rebuild re-resolves eagerly read theme brushes to their current values). - /// - public List<(Element Element, bool HasControl)>? MountedItemElements; - /// /// For an ItemsHost control: the collection entry each logical item realized /// (-1 when it mounted nothing), recorded when the items are filled, so a resource @@ -1290,26 +1265,6 @@ internal static void ClearElementTag(FrameworkElement fe) state.Element = null; } - /// - /// Drops the resource-refresh snapshots on a control (each keyed memo layer's recorded output, - /// a FlipView's mounted items, an ItemsHost's entry map). They hold element graphs and describe - /// one mount, so a control leaving the tree, to the pool or for good, must not keep them. - /// - internal static void ClearRefreshSnapshots(FrameworkElement fe) - { - if (fe.GetValue(ReactorAttached.StateProperty) is ReactorState state) - ClearRefreshSnapshots(state); - } - - internal static void ClearRefreshSnapshots(ReactorState state) - { - state.KeyedMemoKey = null; - state.KeyedMemoOutput = null; - state.MoreKeyedMemoOutputs = null; - state.MountedItemElements = null; - state.ItemsHostEntries = null; - } - /// /// Clears the Current* user-handler delegates from the per-element /// ModifierEventHandlerState while leaving the trampoline subscription on the @@ -1361,9 +1316,8 @@ public static void DetachReactorState(FrameworkElement fe) state.EchoSuppressScopeDepth = 0; state.PendingEchoMatch = null; state.PendingLabeledBy = null; - // The refresh snapshots can root an element graph (a keyed memo's realized output holds - // its callbacks and setters); a detached, app-held control must not keep it alive. - ClearRefreshSnapshots(state); + // Describes the items this control showed; a detached control has none. + state.ItemsHostEntries = null; // Issue #1262 — a retired control must not keep a ValidationContext (or a // pending async rule) alive through a binding the normal FormField / // ValidationRule unmount path never got to clear. Neutralize before dropping @@ -1731,7 +1685,7 @@ public void ReturnControl(T control, PoolPolicy? policy = null) where T : rs.EchoSuppressScopeDepth = 0; rs.PendingEchoMatch = null; rs.PendingLabeledBy = null; - ClearRefreshSnapshots(rs); + rs.ItemsHostEntries = null; rs.Element = null; } // Clear Reactor-set DataContext (FrameworkElement-only DP). @@ -2275,8 +2229,6 @@ private void TagControl(UIElement control, Element element) { _forceFullRenderActive = false; _resourceRefreshActive = false; - _freshForWrapperMount = null; - _freshMountFromDispatch = null; _hotReloadRetryActive = false; _dirtyAncestorPath?.Clear(); _dirtyPathChildren?.Clear(); @@ -2987,30 +2939,6 @@ internal void ReconcilePanelChildrenInto( return Update(oldEl, newEl, control, requestRerender); } - /// - /// for a hosted child slot (dialog/popup/flyout/tooltip content, tree and - /// list item containers) whose owner swaps a replacement in. When the update hands back a - /// different control, the old one is unmounted here (unless the update already did) so its - /// effects, refs and unmount callbacks don't outlive it. Returns the replacement, or null - /// when the existing control was kept: a drop-in for at those sites. - /// - internal UIElement? UpdateHostedChild(Element oldEl, Element newEl, UIElement existing, Action requestRerender) - { - var replacement = UpdateSlotChild(oldEl, newEl, existing, requestRerender, out var unmountedByUpdate); - if (replacement is null || IsSameControl(replacement, existing)) return null; - if (!unmountedByUpdate) Unmount(existing); - return replacement; - } - - /// - /// for a caller that unmounts a replaced control: reports whether - /// the update already unmounted it (e.g. a same-key Memo remounting its output during a - /// resource refresh), so the caller doesn't unmount it twice. - /// - internal UIElement? UpdateChildTracked(Element oldEl, Element newEl, UIElement control, Action requestRerender, - out bool unmountedByUpdate) - => UpdateSlotChild(oldEl, newEl, control, requestRerender, out unmountedByUpdate); - /// /// Unmounts a child control. Public so registered type handlers can unmount children. /// @@ -3052,9 +2980,6 @@ internal void CleanupNavigationHostNode(UIElement control) private void UnmountRecursive(UIElement control) { NoteUnmountDuringSlotUpdate(control); - // Before any early exit: an unmounted control an app still holds must not keep the - // refresh snapshots' element graphs alive. - if (control is FrameworkElement snapshotFe) ClearRefreshSnapshots(snapshotFe); // Capture connected animation snapshot while element is still in the visual tree if (control is FrameworkElement caFe && GetElementTag(caFe) is Element caEl @@ -3486,9 +3411,6 @@ internal void UnmountAndPool(UIElement control) private void UnmountAndCollect(UIElement control, List toPool) { NoteUnmountDuringSlotUpdate(control); - // Before any early exit: an unmounted control an app still holds must not keep the - // refresh snapshots' element graphs alive. - if (control is FrameworkElement snapshotFe) ClearRefreshSnapshots(snapshotFe); // Capture connected animation snapshot while element is still in the visual tree if (control is FrameworkElement caFe && GetElementTag(caFe) is Element caEl @@ -5124,7 +5046,7 @@ internal void ApplyModifiers(FrameworkElement fe, ElementModifiers? oldM, Elemen var existingTip = WinUI.ToolTipService.GetToolTip(fe) as UIElement; if (oldTipEl is not null && existingTip is not null && CanUpdate(oldTipEl, m.RichToolTip)) { - var replacement = UpdateHostedChild(oldTipEl, m.RichToolTip, existingTip, requestRerender); + var replacement = Update(oldTipEl, m.RichToolTip, existingTip, requestRerender); if (replacement is not null) WinUI.ToolTipService.SetToolTip(fe, replacement); } @@ -6508,7 +6430,7 @@ private void UpdateFlyoutInPlace(WinPrim.FlyoutBase existingFlyout, Element oldE var oldContent = oldEl is ContentFlyoutElement oldCf ? oldCf.Content : null; if (oldContent is not null && flyout.Content is UIElement existingContent && CanUpdate(oldContent, newCf.Content)) { - var replacement = UpdateHostedChild(oldContent, newCf.Content, existingContent, requestRerender); + var replacement = Update(oldContent, newCf.Content, existingContent, requestRerender); if (replacement is not null && !ReferenceEquals(flyout.Content, replacement)) flyout.Content = replacement; } @@ -6535,7 +6457,7 @@ private void UpdateFlyoutInPlace(WinPrim.FlyoutBase existingFlyout, Element oldE { if (CanUpdate(oldEl, newEl)) { - var replacement = UpdateHostedChild(oldEl, newEl, existingCtrl, requestRerender); + var replacement = Update(oldEl, newEl, existingCtrl, requestRerender); if (replacement is not null && !ReferenceEquals(plainFlyout.Content, replacement)) plainFlyout.Content = replacement; } diff --git a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs index 23fff1225..121496f9a 100644 --- a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs +++ b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs @@ -27,10 +27,10 @@ internal static WinUI.Grid MountCommandHost(Reconciler reconciler, CommandHostEl // Update child element if (host.Children.Count > 0 && host.Children[0] is UIElement existingChild) { - var replacement = reconciler.UpdateChildTracked(o.Child, n.Child, existingChild, requestRerender, out var unmountedByUpdate); + var replacement = reconciler.UpdateChild(o.Child, n.Child, existingChild, requestRerender); if (replacement is not null) { - if (!unmountedByUpdate) reconciler.UnmountChild(existingChild); + reconciler.UnmountChild(existingChild); // Explicit RemoveAt+Insert instead of indexer assignment — WinUI's // Children[i] = x can leave the old element's internal parent state // attached, causing a COMException when it is later reused from the @@ -323,12 +323,12 @@ private static void ApplyAttachedValidation( var outgoingContent = existingContent; if (reconciler.CanUpdate(oldFf.Content, newFf.Content)) { - var replacement = reconciler.UpdateChildTracked(oldFf.Content, newFf.Content, existingContent, requestRerender, out var unmountedByUpdate); + var replacement = reconciler.Update(oldFf.Content, newFf.Content, existingContent, requestRerender); if (replacement is not null) { // WinUI indexer assignment doesn't fully disconnect the old element's // parent state — use RemoveAt+Insert (see ChildCollection.Replace). - if (!unmountedByUpdate) reconciler.UnmountChild(existingContent); + reconciler.UnmountChild(existingContent); panel.Children.RemoveAt(1); panel.Children.Insert(1, replacement); existingContent = replacement; diff --git a/src/Reactor/Core/V1Protocol/Handlers/ExpanderHandler.cs b/src/Reactor/Core/V1Protocol/Handlers/ExpanderHandler.cs index 9a2f76cc4..52e3b33b3 100644 --- a/src/Reactor/Core/V1Protocol/Handlers/ExpanderHandler.cs +++ b/src/Reactor/Core/V1Protocol/Handlers/ExpanderHandler.cs @@ -73,7 +73,7 @@ public UIElement Update(UpdateContext ctx, ExpanderElement oldEl, ExpanderElemen // Reconcile content child if (exp.Content is UIElement existingContent && reconciler.CanUpdate(oldEl.Content, newEl.Content)) { - var replacement = reconciler.UpdateHostedChild(oldEl.Content, newEl.Content, existingContent, requestRerender); + var replacement = reconciler.UpdateChild(oldEl.Content, newEl.Content, existingContent, requestRerender); if (replacement is not null && !ReferenceEquals(exp.Content, replacement)) exp.Content = replacement; } diff --git a/src/Reactor/Core/V1Protocol/NavigationHostLifecycle.cs b/src/Reactor/Core/V1Protocol/NavigationHostLifecycle.cs index 38aaafe0c..cd6ddd120 100644 --- a/src/Reactor/Core/V1Protocol/NavigationHostLifecycle.cs +++ b/src/Reactor/Core/V1Protocol/NavigationHostLifecycle.cs @@ -127,9 +127,8 @@ public static WinUI.Grid Mount(Reconciler reconciler, NavigationHostElement elem { // Route unchanged — reconcile the existing child element in place var newChildElement = node.RouteMap(currentRoute); - bool unmountedByUpdate = false; var replacement = node.CurrentChildControl is not null - ? reconciler.UpdateChildTracked(node.CurrentChildElement, newChildElement, node.CurrentChildControl, requestRerender, out unmountedByUpdate) + ? reconciler.UpdateChild(node.CurrentChildElement, newChildElement, node.CurrentChildControl, requestRerender) : reconciler.Mount(newChildElement, requestRerender); if (replacement is not null && node.CurrentChildControl is not null) @@ -140,7 +139,7 @@ public static WinUI.Grid Mount(Reconciler reconciler, NavigationHostElement elem grid.Children[idx] = replacement; else grid.Children.Add(replacement); - if (!unmountedByUpdate) reconciler.UnmountChild(node.CurrentChildControl); + reconciler.UnmountChild(node.CurrentChildControl); node.CurrentChildControl = replacement; } else if (replacement is not null) diff --git a/src/Reactor/Core/V1Protocol/OverlayLifecycle.cs b/src/Reactor/Core/V1Protocol/OverlayLifecycle.cs index f27f02636..2605fa310 100644 --- a/src/Reactor/Core/V1Protocol/OverlayLifecycle.cs +++ b/src/Reactor/Core/V1Protocol/OverlayLifecycle.cs @@ -95,7 +95,7 @@ public static UIElement MountContentDialog(Reconciler reconciler, ContentDialogE // the dialog (focus, scroll, caret) survives the owner's re-renders. if (dialog.Content is UIElement existing && reconciler.CanUpdate(o.Content, n.Content)) { - var replacement = reconciler.UpdateHostedChild(o.Content, n.Content, existing, requestRerender); + var replacement = reconciler.Update(o.Content, n.Content, existing, requestRerender); if (replacement is not null && !ReferenceEquals(dialog.Content, replacement)) dialog.Content = replacement; } @@ -320,7 +320,7 @@ private static async void ShowContentDialogCore(Reconciler reconciler, ContentDi { if (flyout.Content is UIElement existingContent && reconciler.CanUpdate(o.FlyoutContent, n.FlyoutContent)) { - var contentRepl = reconciler.UpdateHostedChild(o.FlyoutContent, n.FlyoutContent, existingContent, requestRerender); + var contentRepl = reconciler.Update(o.FlyoutContent, n.FlyoutContent, existingContent, requestRerender); if (contentRepl is not null) flyout.Content = contentRepl; } else @@ -553,7 +553,7 @@ public static UIElement MountPopup(Reconciler reconciler, PopupElement popup, Ac if (popup.Child is UIElement existing && reconciler.CanUpdate(o.Child, n.Child)) { - var replacement = reconciler.UpdateHostedChild(o.Child, n.Child, existing, requestRerender); + var replacement = reconciler.Update(o.Child, n.Child, existing, requestRerender); if (replacement is not null && !ReferenceEquals(popup.Child, replacement)) popup.Child = replacement; } diff --git a/src/Reactor/Core/V1Protocol/Reconciler.TemplatedTree.cs b/src/Reactor/Core/V1Protocol/Reconciler.TemplatedTree.cs index 70e588587..4e84b2b1f 100644 --- a/src/Reactor/Core/V1Protocol/Reconciler.TemplatedTree.cs +++ b/src/Reactor/Core/V1Protocol/Reconciler.TemplatedTree.cs @@ -244,7 +244,7 @@ private void RefreshRealizedTreeContainers(WinUI.TreeView tv, WinUI.ListView? li var newView = n.BuildView(data); if (cc.Content is UIElement existing && GetElementTag(cc) is Element oldView && CanUpdate(oldView, newView)) { - var replacement = UpdateHostedChild(oldView, newView, existing, requestRerender); + var replacement = Update(oldView, newView, existing, requestRerender); if (replacement is not null && !ReferenceEquals(cc.Content, replacement)) cc.Content = replacement; } diff --git a/src/Reactor/Core/V1Protocol/TemplatedListLifecycle.cs b/src/Reactor/Core/V1Protocol/TemplatedListLifecycle.cs index d1c394533..53c575fc7 100644 --- a/src/Reactor/Core/V1Protocol/TemplatedListLifecycle.cs +++ b/src/Reactor/Core/V1Protocol/TemplatedListLifecycle.cs @@ -194,17 +194,12 @@ internal static WinUI.FlipView MountFlipView(Reconciler reconciler, TemplatedLis // FlipView doesn't support ContainerContentChanging (not a ListViewBase). // Pre-mount all items — FlipView typically has few items so this is fine. - // An item whose view mounts nothing (Empty) has no entry in Items, so the record keeps, - // per logical item, whether it did. - var mounted = new List<(Element Element, bool HasControl)>(el.ItemCount); for (int i = 0; i < el.ItemCount; i++) { var itemElement = el.BuildItemView(i); var ctrl = reconciler.Mount(itemElement, requestRerender); if (ctrl is not null) flipView.Items.Add(ctrl); - mounted.Add((itemElement, ctrl is not null)); } - Reconciler.GetOrCreateReactorState(flipView).MountedItemElements = mounted; flipView.SelectionChanged += (s, _) => { @@ -357,62 +352,43 @@ public IEnumerator GetEnumerator() internal static UIElement? UpdateFlipView(Reconciler reconciler, TemplatedListElementBase o, TemplatedListElementBase n, WinUI.FlipView fv, Action requestRerender) { - // FlipView items are pre-mounted directly (no ContainerContentChanging). Diff each item - // against the element it mounted or was last updated to; rebuilding it from o would - // re-resolve an eagerly read theme brush to its current value, so a resource refresh - // would see no change. An item whose view is Empty has no control in Items, so walk the - // controls with their own index. Without a usable record, rebuild from o and treat the - // first Items.Count items as the ones holding a control. + // FlipView items are pre-mounted directly (no ContainerContentChanging). + // Build old element array from o, then reconcile like regular items. int oldCount = o.ItemCount; int newCount = n.ItemCount; int shared = Math.Min(oldCount, newCount); - var fvState = Reconciler.GetOrCreateReactorState(fv); - var recorded = fvState.MountedItemElements is { } r && r.Count == oldCount ? r : null; - var nextMounted = new List<(Element Element, bool HasControl)>(newCount); - int ci = 0; for (int i = 0; i < shared; i++) { - var (oldItemElement, oldHasControl) = recorded?[i] ?? (o.BuildItemView(i), i < fv.Items.Count); + var oldItemElement = o.BuildItemView(i); var newItemElement = n.BuildItemView(i); - if (oldHasControl && ci < fv.Items.Count && fv.Items[ci] is UIElement existingCtrl - && reconciler.CanUpdate(oldItemElement, newItemElement)) + if (fv.Items[i] is UIElement existingCtrl && reconciler.CanUpdate(oldItemElement, newItemElement)) { - var replacement = reconciler.UpdateHostedChild(oldItemElement, newItemElement, existingCtrl, requestRerender); + var replacement = reconciler.Update(oldItemElement, newItemElement, existingCtrl, requestRerender); if (replacement is not null && replacement != existingCtrl) - fv.Items[ci] = replacement; - ci++; - nextMounted.Add((newItemElement, true)); - continue; + fv.Items[i] = replacement; } - - if (oldHasControl && ci < fv.Items.Count) + else { - if (fv.Items[ci] is UIElement oldCtrl) reconciler.UnmountChild(oldCtrl); - fv.Items.RemoveAt(ci); + if (fv.Items[i] is UIElement oldCtrl) reconciler.UnmountChild(oldCtrl); + fv.Items[i] = reconciler.Mount(newItemElement, requestRerender)!; } - var ctrl = reconciler.Mount(newItemElement, requestRerender); - if (ctrl is not null) fv.Items.Insert(ci++, ctrl); - nextMounted.Add((newItemElement, ctrl is not null)); } - // Remove excess: every control past the shared items belongs to a removed item. - while (fv.Items.Count > ci) + // Remove excess + for (int i = oldCount - 1; i >= shared; i--) { - int last = fv.Items.Count - 1; - if (fv.Items[last] is UIElement oldCtrl) reconciler.UnmountChild(oldCtrl); - fv.Items.RemoveAt(last); + if (fv.Items[i] is UIElement oldCtrl) reconciler.UnmountChild(oldCtrl); + fv.Items.RemoveAt(i); } // Add new for (int i = shared; i < newCount; i++) { - var itemElement = n.BuildItemView(i); - var ctrl = reconciler.Mount(itemElement, requestRerender); + var ctrl = reconciler.Mount(n.BuildItemView(i), requestRerender); if (ctrl is not null) fv.Items.Add(ctrl); - nextMounted.Add((itemElement, ctrl is not null)); } - fvState.MountedItemElements = nextMounted; + // Issue #495 — drift-guarded, suppressed write. Previously // unconditional, which would write -1 when n.GetSelectedIndex() == -1 // (the default for TemplatedFlipViewElement is 0, so the prior diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/HostIdleAndThemeResourceFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/HostIdleAndThemeResourceFixtures.cs index 739fa47a7..06353e6ca 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/HostIdleAndThemeResourceFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/HostIdleAndThemeResourceFixtures.cs @@ -1,5 +1,4 @@ using Microsoft.UI; -using Microsoft.UI.Reactor.Controls.Validation; using Microsoft.UI.Reactor.Core; using Microsoft.UI.Reactor.Hosting; using Microsoft.UI.Xaml; @@ -224,14 +223,13 @@ public override async Task RunAsync() } } /// - /// The window path, a reference-equal (UseMemo'd) subtree, an - /// ordinary .Foreground(Theme.Ref(...)) modifier and a background-thread call. - /// A memoized subtree is skipped wholesale by a plain re-render — and by a hot-reload - /// style force pass, which only declines wrapper skips — so its {ThemeResource} - /// style setter is never re-applied. The resource-refresh pass must walk into it, and into - /// a keyed Memo(key, ...) with an unchanged key, ListView/GridView items whose array is kept, and an ItemsHost (ComboBox item elements) whose items - /// are kept, which skip their children by other routes. - /// "RerenderAloneIsStale" is the negative control. + /// The window path and a background-thread call. A memoized subtree + /// is skipped wholesale by a plain re-render (a reference-equal UseMemo'd element, a keyed + /// Memo(key, ...) with an unchanged key, ListView/GridView/ComboBox items whose list is + /// kept, an unchanged TreeView), so its Theme.Ref values are never applied again. The + /// resource-refresh pass must walk into each of them, without remounting anything (component + /// state, list selection and tree expansion survive). "RerenderAloneIsStale" is the negative + /// control: an ordinary re-render does not pick the edit up. /// internal class NotifyResourcesChangedRefreshesMemoizedThemeModifiers(Harness h) : SelfTestFixtureBase(h) { @@ -248,29 +246,11 @@ public override async Task RunAsync() var previousActiveHost = ReactorApp.ActiveHostInternal; var target = new Border(); H.SetContent(target); - int shapeItemCleanedUp = 0; - int templatedItemCleanedUp = 0; - int popupContentCleanedUp = 0; - int visualizerContentMounts = 0; - int visualizerContentCleanups = 0; - int visualizerWrapperMounts = 0; - int commandHostChildCleanups = 0; - int legacyNestedMounts = 0; - int legacyNestedCleanups = 0; - int flyoutTargetMounts = 0; - int flyoutTargetCleanups = 0; - int sameKeyMounts = 0; - int sameKeyCleanups = 0; - int flyoutShapeMounts = 0; - int precedenceMounts = 0; - int tipContentMounts = 0; - int tipContentCleanups = 0; - var labelRef = new global::Microsoft.UI.Reactor.Input.ElementRef(); - int flyoutTargetShapeMounts = 0; - var fixedBrush = new SolidColorBrush(Colors.Orange); - int wrapperMounts = 0; - int nestedInnerMounts = 0; - int nestedOuterMounts = 0; + // Mount/cleanup counts of stateful components inside nested memos: a refresh that + // remounted them would lose their state. + var sameKey = new Counter(); + var legacyNested = new Counter(); + var flyoutTarget = new Counter(); try { // Disposed at the end of this block, before finally restores the active host. @@ -283,52 +263,35 @@ public override async Task RunAsync() setTick = set; var memo = ctx.UseMemo( () => Border(TextBlock("ThemeMemoProbe").Foreground(Theme.Ref(AppKey)))); - // An ItemsHost (ComboBox item elements) whose items are kept, and a keyed Memo with an - // unchanged key: both skip their children outside a resource refresh. var listItems = ctx.UseMemo( () => [TextBlock("ListItemProbe").Foreground(Theme.Ref(AppKey))]); - // Virtualized lists whose item array is kept: their handlers realize items in - // containers and skip work when the array reference is unchanged. + // Items that realize no ComboBox entry (Empty, a memo of Empty), ahead of a themed one. + var gapItems = ctx.UseMemo( + () => [Empty(), Memo("emptyItem", () => Empty()), TextBlock("GapItemProbe").Foreground(Theme.Ref(AppKey))]); var lvItems = ctx.UseMemo( () => [TextBlock("LvItemProbe").Foreground(Theme.Ref(AppKey)), TextBlock("LvSecond")]); + var gvItems = ctx.UseMemo( + () => [TextBlock("GvItemProbe").Foreground(Theme.Ref(AppKey))]); var lazyItems = ctx.UseMemo>(() => ["lazy"]); - var templatedItems = ctx.UseMemo>(() => ["templated"]); - var flipItems = ctx.UseMemo>(() => ["flip"]); - var flipGapItems = ctx.UseMemo>(() => ["gap", "shown"]); - var lvNameItems = ctx.UseMemo(() => [Memo("lvName", () => TextBlock("LvNameProbe") - .AutomationName(ThemeRef.Resolve(AppKey, isDark: false) is SolidColorBrush { Color: var c } && c == Colors.Red ? "name-red" : "name-blue"))]); var treeNodes = ctx.UseMemo( () => [new TreeViewNodeData("TreeRoot", [new TreeViewNodeData("TreeChild")])]); - var gvItems = ctx.UseMemo( - () => [TextBlock("GvItemProbe").Foreground(Theme.Ref(AppKey))]); - var gapItems = ctx.UseMemo( - () => [ - Empty(), - Memo("emptyItem", () => Empty()), - TextBlock("GapItemProbe").Foreground(Theme.Ref(AppKey)), - // Changes shape with the resource: the replaced item must be unmounted. - Memo("shapeItem", () => ThemeRef.Resolve(AppKey, isDark: false) is SolidColorBrush { Color: var c } && c == Colors.Red - ? RenderEachTime(fctx => - { - fctx.UseEffect(() => () => shapeItemCleanedUp++); - return TextBlock("ShapeItemBefore"); - }) - : TextBlock("ShapeItemAfter")), - ]); return VStack( TextBlock($"tick:{tick}"), memo, + // Resolved during Render, which the refresh re-runs: picks the edit up too. + TextBlock("RenderResolvedProbe").Foreground(ThemeRef.Resolve(AppKey, isDark: false)!), Memo("keyed", () => TextBlock("KeyedMemoProbe").Foreground(Theme.Ref(AppKey))), - // A concrete brush resolved eagerly inside the factory: equal on both - // sides of a self-diff, so only a diff against the mounted output writes it. - Memo("resolved", () => TextBlock("ResolvedMemoProbe").Foreground(ThemeRef.Resolve(AppKey, isDark: false)!)), - // Nested keyed memos share one realized control. - // A factory whose output changes shape with the resource. - Memo("shape", () => ThemeRef.Resolve(AppKey, isDark: false) is SolidColorBrush { Color: var c } && c == Colors.Red - ? TextBlock("ShapeProbeBefore") - : Border(TextBlock("ShapeProbeAfter"))), - // Items that realize no ComboBox entry (Empty, a memo of Empty), ahead of a themed one. + Memo("outer", () => Memo("inner", () => TextBlock("NestedMemoProbe").Foreground(Theme.Ref(AppKey)))), + // Nested memos share one control. Their components keep their state. + Memo("sameKey", () => Memo("sameKey", () => sameKey.Probe("SameKeyProbe", AppKey))), + Memo("legacyNestedOuter", () => new ModifiedElement( + Memo("legacyNestedInner", () => legacyNested.Probe("LegacyNestedProbe", AppKey)), + new ElementModifiers { Margin = new Thickness(13) })), + Memo("flyoutOuter", () => Flyout( + Memo("flyoutInner", () => flyoutTarget.Probe("FlyoutTargetProbe", AppKey)), + TextBlock("FlyoutTip"))), ComboBox(gapItems, default, null), + ComboBox(listItems, default, null), ListView(lvItems), GridView(gvItems), TreeView(treeNodes), @@ -336,215 +299,15 @@ public override async Task RunAsync() TreeView(new TreeViewNodeData("FreshRoot", [new TreeViewNodeData("FreshChild")])), // Node content that switches to and from Empty with the resource. ContentTogglingTree(AppKey), - // Wrapper modifiers on a memo whose output changes shape: the remounted control - // must get them with mount semantics. - Memo("wrapperShape", () => ThemeRef.Resolve(AppKey, isDark: false) is SolidColorBrush { Color: var c } && c == Colors.Red - ? TextBlock("WrapperBefore") - : Border(TextBlock("WrapperAfter"))) - .Margin(7) - .OnMount(_ => wrapperMounts++), - // The same through a nested chain: both wrappers' modifiers apply to the fresh - // control with mount semantics. - Memo("nestedWrapOuter", () => Memo("nestedWrapInner", - () => ThemeRef.Resolve(AppKey, isDark: false) is SolidColorBrush { Color: var c } && c == Colors.Red - ? TextBlock("NestedWrapBefore") - : Border(TextBlock("NestedWrapAfter"))) - .Opacity(0.5) - .OnMount(_ => nestedInnerMounts++)) - .Margin(5) - .OnMount(_ => nestedOuterMounts++), - // A hosted slot that swaps an update's replacement in without unmounting the - // old control: the refresh must unmount it itself. - Popup(Memo("popupShape", - () => ThemeRef.Resolve(AppKey, isDark: false) is SolidColorBrush { Color: var c } && c == Colors.Red - ? RenderEachTime(fctx => - { - fctx.UseEffect(() => () => popupContentCleanedUp++); - return TextBlock("PopupBefore"); - }) - : TextBlock("PopupAfter"))), - // A popup child whose update always hands back a fresh control: each outgoing - // subtree must be unmounted, so exactly one copy of its content stays live. - Popup(ValidationVisualizerDsl.ValidationVisualizer(VisualizerStyle.Inline, - RenderEachTime(fctx => - { - fctx.UseEffect(() => - { - visualizerContentMounts++; - return () => visualizerContentCleanups++; - }); - return TextBlock("VisualizerProbe"); - }))), - // A same-shape memo output whose handler still builds a fresh control: the memo's - // wrapper modifiers and OnMount must apply to it with mount semantics. - Memo("visualizerWrapper", () => ValidationVisualizerDsl.ValidationVisualizer(VisualizerStyle.Inline, - TextBlock("VisualizerWrapProbe"))) - .Margin(9) - .OnMount(_ => visualizerWrapperMounts++), - // A CommandHost child that changes shape: the host unmounts the replaced child, - // exactly once. - CommandHost([], Memo("commandHostShape", - () => ThemeRef.Resolve(AppKey, isDark: false) is SolidColorBrush { Color: var c } && c == Colors.Red - ? RenderEachTime(fctx => - { - fctx.UseEffect(() => () => commandHostChildCleanups++); - return TextBlock("CommandHostBefore"); - }) - : TextBlock("CommandHostAfter"))), - // A legacy ModifiedElement around a memo that changes shape: its modifiers must - // reach the remounted control. - new ModifiedElement(Memo("legacyWrap", - () => ThemeRef.Resolve(AppKey, isDark: false) is SolidColorBrush { Color: var c } && c == Colors.Red - ? TextBlock("LegacyWrapBefore") - : Border(TextBlock("LegacyWrapAfter"))), - new ElementModifiers { Margin = new Thickness(11) }), - // A memo whose output is a legacy ModifiedElement around a nested memo: the layers - // share one control, so the inner memo's recorded output must survive and a - // same-shape refresh must keep its subtree (and state) instead of remounting it. - Memo("legacyNestedOuter", () => new ModifiedElement( - Memo("legacyNestedInner", () => RenderEachTime(fctx => - { - fctx.UseEffect(() => - { - legacyNestedMounts++; - return () => legacyNestedCleanups++; - }); - return TextBlock("LegacyNestedProbe").Foreground(Theme.Ref(AppKey)); - })), - new ElementModifiers { Margin = new Thickness(13) })), - // A nested memo whose wrapper carries an eagerly resolved brush: the outer layer - // must diff it against the mounted wrapper, not against itself. - Memo("eagerOuter", () => Memo("eagerInner", () => Border(TextBlock("EagerWrapProbe"))) - .Background(ThemeRef.Resolve(AppKey, isDark: false)!)), - // A memo under a flyout's target shares the control with the outer memo: its - // component must keep its state through the refresh. - Memo("flyoutOuter", () => Flyout( - Memo("flyoutInner", () => RenderEachTime(fctx => - { - fctx.UseEffect(() => - { - flyoutTargetMounts++; - return () => flyoutTargetCleanups++; - }); - return TextBlock("FlyoutTargetProbe").Foreground(Theme.Ref(AppKey)); - })), - TextBlock("FlyoutTip"))), - // Nested memos with one key share a control: each must keep its own record, so - // the component inside keeps its state. - Memo("sameKey", () => Memo("sameKey", () => RenderEachTime(fctx => - { - fctx.UseEffect(() => - { - sameKeyMounts++; - return () => sameKeyCleanups++; - }); - return TextBlock("SameKeyProbe").Foreground(Theme.Ref(AppKey)); - }))), - // A factory returning a legacy wrapper whose wrapped type changes: remount, with - // the wrapper's modifiers. - Memo("legacyOutput", () => new ModifiedElement( - ThemeRef.Resolve(AppKey, isDark: false) is SolidColorBrush { Color: var c } && c == Colors.Red - ? TextBlock("LegacyOutBefore") - : Border(TextBlock("LegacyOutAfter")), - new ElementModifiers { Margin = new Thickness(17) })), - // A shape-changing memo as a flyout's target: the flyout's own modifiers and - // OnMount must reach the remounted control. - Flyout(Memo("flyoutShape", () => ThemeRef.Resolve(AppKey, isDark: false) is SolidColorBrush { Color: var c } && c == Colors.Red - ? TextBlock("FlyShapeBefore") - : Border(TextBlock("FlyShapeAfter"))), - TextBlock("FlyShapeTip")) - .Margin(19) - .OnMount(_ => flyoutShapeMounts++), - // A wrapper's own value beats its output's after an in-place refresh, as at mount. - Memo("precedence", () => Border(TextBlock("PrecedenceProbe")).Background(ThemeRef.Resolve(AppKey, isDark: false)!)) - .Background(fixedBrush) - .OnMount(_ => precedenceMounts++), - // The wrapper's typography beats the output's too. - Memo("fontPrecedence", () => TextBlock("FontPrecedenceProbe") - .FontSize(ThemeRef.Resolve(AppKey, isDark: false) is SolidColorBrush { Color: var c } && c == Colors.Red ? 12 : 14)) - .FontSize(30), - // A wrapper with a rich tooltip: the refresh reconciles the tooltip once, like any - // update (a ValidationVisualizer remounts its content on each update). - Memo("tipOnce", () => Border(TextBlock("TipOnceProbe")).Background(ThemeRef.Resolve(AppKey, isDark: false)!)) - .Margin(7) - .WithToolTip(ValidationVisualizerDsl.ValidationVisualizer(VisualizerStyle.Inline, - RenderEachTime(fctx => - { - fctx.UseEffect(() => - { - tipContentMounts++; - return () => tipContentCleanups++; - }); - return TextBlock("TipContent"); - }))), - // A flyout wrapping a memo target: the flyout's value beats the target's. - Flyout(Memo("flyoutPrecedence", () => TextBlock("FlyPrecedenceProbe").FontSize(ThemeRef.Resolve(AppKey, isDark: false) is SolidColorBrush { Color: var c } && c == Colors.Red ? 12 : 14)), - TextBlock("FlyPrecedenceTip")) - .FontSize(30), - // Accessibility values on the wrapper beat the output's too. - Memo("helpPrecedence", () => TextBlock("HelpPrecedenceProbe").HelpText(ThemeRef.Resolve(AppKey, isDark: false) is SolidColorBrush { Color: var c } && c == Colors.Red ? "inner-red" : "inner-blue")) - .HelpText("wrapper-help"), - Memo("hiddenPrecedence", () => TextBlock("HiddenPrecedenceProbe") - .AccessibilityView(ThemeRef.Resolve(AppKey, isDark: false) is SolidColorBrush { Color: var c } && c == Colors.Red - ? global::Microsoft.UI.Xaml.Automation.Peers.AccessibilityView.Content - : global::Microsoft.UI.Xaml.Automation.Peers.AccessibilityView.Control)) - .AccessibilityHidden(), - // The wrapper's tooltip placement and LabeledBy beat the output's too. - Memo("placementPrecedence", () => TextBlock("PlacementProbe").ToolTip("inner", ThemeRef.Resolve(AppKey, isDark: false) is SolidColorBrush { Color: var c } && c == Colors.Red ? global::Microsoft.UI.Xaml.Controls.Primitives.PlacementMode.Top : global::Microsoft.UI.Xaml.Controls.Primitives.PlacementMode.Bottom)) - .ToolTip("wrapper", global::Microsoft.UI.Xaml.Controls.Primitives.PlacementMode.Right), - TextBlock("LabelA").AutomationId("SelfTestLabelA"), - TextBlock("LabelB").AutomationId("SelfTestLabelB"), - TextBlock("LabelW").AutomationId("SelfTestLabelW"), - Memo("labeledPrecedence", () => TextBlock("LabeledProbe").LabeledBy(ThemeRef.Resolve(AppKey, isDark: false) is SolidColorBrush { Color: var c } && c == Colors.Red ? "SelfTestLabelA" : "SelfTestLabelB")) - .LabeledBy("SelfTestLabelW"), - // A memo returning a legacy wrapper over a property-sized TextBlock: the wrapper wins. - Memo("legacyFont", () => new ModifiedElement( - new TextBlockElement("LegacyFontProbe") { FontSize = ThemeRef.Resolve(AppKey, isDark: false) is SolidColorBrush { Color: var c } && c == Colors.Red ? 12 : 14 }, - new ElementModifiers { FontSize = 30 })), - // A memo whose output is a flyout with its own modifiers and a target that changes - // type: the flyout's modifiers and OnMount reach the new target. - Memo("flyoutTargetShape", () => Flyout( - ThemeRef.Resolve(AppKey, isDark: false) is SolidColorBrush { Color: var c } && c == Colors.Red ? TextBlock("FtsBefore") : Border(TextBlock("FtsAfter")), - TextBlock("FtsTip")) - .Margin(23) - .OnMount(_ => flyoutTargetShapeMounts++)), - Memo("dragPrecedence", () => TextBlock("DragProbe").IsDragRegion(!(ThemeRef.Resolve(AppKey, isDark: false) is SolidColorBrush { Color: var c } && c == Colors.Red))) - .IsDragRegion(false), - // A wrapper labelled through an ElementRef beats an output's string LabeledBy. - TextBlock("LabelR").Ref(labelRef), - Memo("refLabeled", () => TextBlock("RefLabeledProbe").LabeledBy(ThemeRef.Resolve(AppKey, isDark: false) is SolidColorBrush { Color: var c } && c == Colors.Red ? "SelfTestLabelA" : "SelfTestLabelB")) - .LabeledBy(labelRef), - // A kept ListView item whose automation name changes in place: the container forwards it. - ListView(lvNameItems), - // A FlipView whose first item mounts nothing. - FlipView(flipGapItems, static s => s, static (s, _) => s == "gap" - ? Empty() - : TextBlock("FlipGapProbe").Foreground(ThemeRef.Resolve(AppKey, isDark: false)!)), - // A typed FlipView item with an eagerly resolved brush. - FlipView(flipItems, static s => s, - static (_, _) => TextBlock("FlipProbe").Foreground(ThemeRef.Resolve(AppKey, isDark: false)!)), - // A templated-list item that changes shape with the resource: the replaced - // item must be unmounted. - ListView(templatedItems, static s => s, (_, _) => Memo("templatedShape", - () => ThemeRef.Resolve(AppKey, isDark: false) is SolidColorBrush { Color: var c } && c == Colors.Red - ? RenderEachTime(fctx => - { - fctx.UseEffect(() => () => templatedItemCleanedUp++); - return TextBlock("TemplatedBefore"); - }) - : TextBlock("TemplatedAfter"))), LazyVStack(lazyItems, static s => s, - static (_, _) => TextBlock("LazyProbe").Foreground(Theme.Ref(AppKey))), - Memo("outer", () => Memo("inner", - () => TextBlock("NestedMemoProbe").Foreground(ThemeRef.Resolve(AppKey, isDark: false)!))), - ComboBox(listItems, default, null)); + static (_, _) => TextBlock("LazyProbe").Foreground(Theme.Ref(AppKey)))); }); await host.WaitForIdleAsync(); H.Check("ThemeMemo_InitialRed", ProbeColor(target) == Colors.Red, $"color={ProbeColor(target)}"); // List containers realize during layout; wait for them before editing the resource. bool listsRealized = await Harness.WaitFor( () => ProbeColor(target, "LvItemProbe") == Colors.Red && ProbeColor(target, "GvItemProbe") == Colors.Red - && ProbeColor(target, "LazyProbe") == Colors.Red && FindText(target, "TemplatedBefore") is not null, + && ProbeColor(target, "LazyProbe") == Colors.Red, maxPasses: 20, perPassMs: 20); H.Check("ThemeMemo_ListsInitialRed", listsRealized, $"lv={ProbeColor(target, "LvItemProbe")} gv={ProbeColor(target, "GvItemProbe")}"); @@ -557,180 +320,45 @@ public override async Task RunAsync() setTick!(1); await host.WaitForIdleAsync(); H.Check("ThemeMemo_Rerendered", FindText(target, "tick:1") is not null); - H.Check("ThemeMemo_RerenderAloneIsStale", ProbeColor(target) == Colors.Red, $"color={ProbeColor(target)}"); - H.Check("ThemeMemo_ListViewItemRerenderAloneIsStale", ProbeColor(target, "LvItemProbe") == Colors.Red, $"color={ProbeColor(target, "LvItemProbe")}"); - H.Check("ThemeMemo_GridViewItemRerenderAloneIsStale", ProbeColor(target, "GvItemProbe") == Colors.Red, $"color={ProbeColor(target, "GvItemProbe")}"); - H.Check("ThemeMemo_LazyRowRerenderAloneIsStale", ProbeColor(target, "LazyProbe") == Colors.Red, $"color={ProbeColor(target, "LazyProbe")}"); - // A user expansion, which rebuilding the TreeView's nodes would reset. + // A user expansion and selection, which rebuilding the TreeView or swapping the + // ListView's ItemsSource would reset. var treeView = FindTreeView(target, "TreeRoot"); var rootNode = treeView?.RootNodes[0]; if (rootNode is not null) rootNode.IsExpanded = true; var freshTreeView = FindTreeView(target, "FreshRoot"); var freshRoot = freshTreeView?.RootNodes[0]; if (freshRoot is not null) freshRoot.IsExpanded = true; - // A user selection, which an ItemsSource swap would clear. var listView = FindDescendant(target); if (listView is not null) listView.SelectedIndex = 1; - H.Check("ThemeMemo_ShapeRerenderAloneIsStale", FindText(target, "ShapeProbeBefore") is not null); - H.Check("ThemeMemo_WrapperRerenderAloneMountedOnce", wrapperMounts == 1, $"mounts={wrapperMounts}"); - H.Check("ThemeMemo_NestedWrapperRerenderAloneMountedOnce", nestedInnerMounts == 1 && nestedOuterMounts == 1, - $"inner={nestedInnerMounts} outer={nestedOuterMounts}"); - H.Check("ThemeMemo_PopupContentRerenderAloneKept", popupContentCleanedUp == 0); - H.Check("ThemeMemo_EagerWrapperRerenderAloneIsStale", EagerWrapColor(target) == Colors.Red, $"color={EagerWrapColor(target)}"); - H.Check("ThemeMemo_FlyoutShapeRerenderAloneMountedOnce", flyoutShapeMounts == 1, $"mounts={flyoutShapeMounts}"); - H.Check("ThemeMemo_PrecedenceRerenderAloneKeepsWrapper", PrecedenceColor(target) == Colors.Orange, $"color={PrecedenceColor(target)}"); - H.Check("ThemeMemo_FlipGapRerenderAloneIsStale", FlipViewItemColor(target, "FlipGapProbe") == Colors.Red, - $"color={FlipViewItemColor(target, "FlipGapProbe")}"); - H.Check("ThemeMemo_FlipItemRerenderAloneIsStale", FlipViewItemColor(target, "FlipProbe") == Colors.Red, - $"color={FlipViewItemColor(target, "FlipProbe")}"); - H.Check("ThemeMemo_FontPrecedenceRerenderAloneKeepsWrapper", FindText(target, "FontPrecedenceProbe")?.FontSize == 30, - $"size={FindText(target, "FontPrecedenceProbe")?.FontSize}"); - int tipMountsBeforeNotify = tipContentMounts; - H.Check("ThemeMemo_FlyoutPrecedenceRerenderAloneKeepsWrapper", FindText(target, "FlyPrecedenceProbe")?.FontSize == 30, - $"size={FindText(target, "FlyPrecedenceProbe")?.FontSize}"); - H.Check("ThemeMemo_LegacyFontRerenderAloneKeepsWrapper", FindText(target, "LegacyFontProbe")?.FontSize == 30, - $"size={FindText(target, "LegacyFontProbe")?.FontSize}"); - H.Check("ThemeMemo_FlyoutOutputRerenderAloneMountedOnce", flyoutTargetShapeMounts == 1, $"mounts={flyoutTargetShapeMounts}"); - H.Check("ThemeMemo_DragRegionRerenderAloneKeepsWrapper", DragRegionOf(target, "DragProbe") == false, - $"drag={DragRegionOf(target, "DragProbe")}"); - H.Check("ThemeMemo_PlacementRerenderAloneKeepsWrapper", PlacementOf(target, "PlacementProbe") == global::Microsoft.UI.Xaml.Controls.Primitives.PlacementMode.Right, - $"placement={PlacementOf(target, "PlacementProbe")}"); - H.Check("ThemeMemo_LabeledByRerenderAloneKeepsWrapper", LabelTextOf(target, "LabeledProbe") == "LabelW", - $"label={LabelTextOf(target, "LabeledProbe")}"); - H.Check("ThemeMemo_HelpPrecedenceRerenderAloneKeepsWrapper", HelpTextOf(target, "HelpPrecedenceProbe") == "wrapper-help", - $"help={HelpTextOf(target, "HelpPrecedenceProbe")}"); - H.Check("ThemeMemo_HiddenRerenderAloneStaysHidden", ViewOf(target, "HiddenPrecedenceProbe") == global::Microsoft.UI.Xaml.Automation.Peers.AccessibilityView.Raw, - $"view={ViewOf(target, "HiddenPrecedenceProbe")}"); - bool lvNameRealized = await Harness.WaitFor(() => ContainerName(target, "LvNameProbe") == "name-red", maxPasses: 20, perPassMs: 20); - H.Check("ThemeMemo_ContainerNameInitial", lvNameRealized, $"name={ContainerName(target, "LvNameProbe")}"); - // The shape memo's outgoing control, held after the refresh replaces it. - var replacedShape = FindText(target, "ShapeProbeBefore"); - int replacedSnapshotsBefore = replacedShape is not null && Reconciler.TryGetReactorState(replacedShape, out var rsBefore) - ? Reconciler.CountKeyedMemoOutputs(rsBefore, "shape") : -1; - H.Check("ThemeMemo_LegacyNestedRerenderAloneIsStale", ProbeColor(target, "LegacyNestedProbe") == Colors.Red, - $"color={ProbeColor(target, "LegacyNestedProbe")}"); - H.Check("ThemeMemo_CommandHostChildRerenderAloneKept", commandHostChildCleanups == 0 && FindText(target, "CommandHostBefore") is not null, - $"cleanups={commandHostChildCleanups}"); - H.Check("ThemeMemo_LegacyWrapperRerenderAloneKept", (FindText(target, "LegacyWrapBefore")?.Margin ?? default) == new Thickness(11)); - H.Check("ThemeMemo_VisualizerWrapperRerenderAloneMountedOnce", visualizerWrapperMounts == 1, $"mounts={visualizerWrapperMounts}"); - H.Check("ThemeMemo_VisualizerRerenderOneLiveCopy", visualizerContentMounts >= 2 && visualizerContentMounts - visualizerContentCleanups == 1, - $"mounts={visualizerContentMounts} cleanups={visualizerContentCleanups}"); - H.Check("ThemeMemo_TreeContentRerenderAloneUnchanged", ToggleTree(target) is { } toggleBefore - && toggleBefore.RootNodes[0].Content is UIElement && toggleBefore.RootNodes[1].Content is null, - $"first={ToggleTree(target)?.RootNodes[0].Content?.GetType().Name ?? "null"} second={ToggleTree(target)?.RootNodes[1].Content?.GetType().Name ?? "null"}"); - H.Check("ThemeMemo_TemplatedItemRerenderAloneKeepsIt", templatedItemCleanedUp == 0 && FindText(target, "TemplatedBefore") is not null, - $"cleanedUp={templatedItemCleanedUp} before={FindText(target, "TemplatedBefore") is not null}"); - H.Check("ThemeMemo_ShapeItemRerenderAloneKeepsIt", shapeItemCleanedUp == 0 && !HasComboText(target, "ShapeItemAfter")); - H.Check("ThemeMemo_GapItemRerenderAloneIsStale", ListItemColor(target, "GapItemProbe") == Colors.Red, $"color={ListItemColor(target, "GapItemProbe")}"); - H.Check("ThemeMemo_NestedMemoRerenderAloneIsStale", ProbeColor(target, "NestedMemoProbe") == Colors.Red, $"color={ProbeColor(target, "NestedMemoProbe")}"); - H.Check("ThemeMemo_ResolvedMemoRerenderAloneIsStale", ProbeColor(target, "ResolvedMemoProbe") == Colors.Red, $"color={ProbeColor(target, "ResolvedMemoProbe")}"); - H.Check("ThemeMemo_KeyedMemoRerenderAloneIsStale", ProbeColor(target, "KeyedMemoProbe") == Colors.Red, $"color={ProbeColor(target, "KeyedMemoProbe")}"); + + var stale = new[] + { + "ThemeMemoProbe", "KeyedMemoProbe", "NestedMemoProbe", "SameKeyProbe", "LegacyNestedProbe", + "FlyoutTargetProbe", "LvItemProbe", "GvItemProbe", "LazyProbe", + }; + foreach (var probe in stale) + H.Check($"ThemeMemo_{probe}RerenderAloneIsStale", ProbeColor(target, probe) == Colors.Red, $"color={ProbeColor(target, probe)}"); H.Check("ThemeMemo_ListItemRerenderAloneIsStale", ListItemColor(target) == Colors.Red, $"color={ListItemColor(target)}"); + H.Check("ThemeMemo_GapItemRerenderAloneIsStale", ListItemColor(target, "GapItemProbe") == Colors.Red); + H.Check("ThemeMemo_TreeContentRerenderAloneUnchanged", ToggleTree(target) is { } toggleBefore + && toggleBefore.RootNodes[0].Content is UIElement && toggleBefore.RootNodes[1].Content is null); await Task.Run(Theme.NotifyResourcesChanged); await host.WaitForIdleAsync(); H.Check("ThemeMemo_NotifiedFromBackgroundIsBlue", ProbeColor(target) == Colors.Blue, $"color={ProbeColor(target)}"); - H.Check("ThemeMemo_ListViewItemNotifiedIsBlue", ProbeColor(target, "LvItemProbe") == Colors.Blue, $"color={ProbeColor(target, "LvItemProbe")}"); - H.Check("ThemeMemo_GridViewItemNotifiedIsBlue", ProbeColor(target, "GvItemProbe") == Colors.Blue, $"color={ProbeColor(target, "GvItemProbe")}"); - H.Check("ThemeMemo_LazyRowNotifiedMidScrollIsBlue", ProbeColor(target, "LazyProbe") == Colors.Blue, $"color={ProbeColor(target, "LazyProbe")}"); - H.Check("ThemeMemo_TreeViewNodesKept", NodeKept(treeView, rootNode), DescribeNode(treeView, rootNode)); - var wrapperBorder = FindText(target, "WrapperAfter")?.Parent as Border; - H.Check("ThemeMemo_WrapperRemountKeepsModifiers", wrapperBorder?.Margin == new Thickness(7), - $"margin={wrapperBorder?.Margin.ToString() ?? "(not found)"}"); - H.Check("ThemeMemo_WrapperRemountRunsOnMount", wrapperMounts == 2, $"mounts={wrapperMounts}"); - var nestedBorder = FindText(target, "NestedWrapAfter")?.Parent as Border; - H.Check("ThemeMemo_NestedWrapperRemountKeepsModifiers", - nestedBorder is not null && nestedBorder.Margin == new Thickness(5) && Math.Abs(nestedBorder.Opacity - 0.5) < 0.001, - $"margin={nestedBorder?.Margin.ToString() ?? "(not found)"} opacity={nestedBorder?.Opacity}"); - H.Check("ThemeMemo_NestedWrapperRemountRunsOnMount", nestedInnerMounts == 2 && nestedOuterMounts == 2, - $"inner={nestedInnerMounts} outer={nestedOuterMounts}"); - H.Check("ThemeMemo_PopupContentReplacedAndUnmounted", popupContentCleanedUp == 1, $"cleanups={popupContentCleanedUp}"); - H.Check("ThemeMemo_VisualizerWrapperRemountRunsOnMount", visualizerWrapperMounts == 2, $"mounts={visualizerWrapperMounts}"); - var visualizerWrapMargin = AncestorMargin(FindText(target, "VisualizerWrapProbe"), new Thickness(9)); - H.Check("ThemeMemo_VisualizerWrapperRemountKeepsModifiers", visualizerWrapMargin, $"found={FindText(target, "VisualizerWrapProbe") is not null}"); - H.Check("ThemeMemo_CommandHostChildReplacedAndUnmountedOnce", commandHostChildCleanups == 1 && FindText(target, "CommandHostAfter") is not null, - $"cleanups={commandHostChildCleanups} after={FindText(target, "CommandHostAfter") is not null}"); - var legacyBorder = FindText(target, "LegacyWrapAfter")?.Parent as Border; - H.Check("ThemeMemo_LegacyWrapperRemountKeepsModifiers", legacyBorder?.Margin == new Thickness(11), - $"margin={legacyBorder?.Margin.ToString() ?? "(not found)"}"); - H.Check("ThemeMemo_LegacyNestedNotifiedIsBlue", ProbeColor(target, "LegacyNestedProbe") == Colors.Blue, - $"color={ProbeColor(target, "LegacyNestedProbe")}"); - H.Check("ThemeMemo_LegacyNestedKeptNotRemounted", legacyNestedMounts == 1 && legacyNestedCleanups == 0, - $"mounts={legacyNestedMounts} cleanups={legacyNestedCleanups}"); + foreach (var probe in stale.Append("RenderResolvedProbe")) + H.Check($"ThemeMemo_{probe}NotifiedIsBlue", ProbeColor(target, probe) == Colors.Blue, $"color={ProbeColor(target, probe)}"); + H.Check("ThemeMemo_ListItemNotifiedIsBlue", ListItemColor(target) == Colors.Blue, $"color={ListItemColor(target)}"); + H.Check("ThemeMemo_GapItemNotifiedIsBlue", ListItemColor(target, "GapItemProbe") == Colors.Blue); + foreach (var (name, counter) in new[] { ("SameKey", sameKey), ("LegacyNested", legacyNested), ("FlyoutTarget", flyoutTarget) }) + H.Check($"ThemeMemo_{name}KeptNotRemounted", counter.Mounts == 1 && counter.Cleanups == 0, + $"mounts={counter.Mounts} cleanups={counter.Cleanups}"); H.Check("ThemeMemo_LegacyNestedKeepsModifiers", AncestorMargin(FindText(target, "LegacyNestedProbe"), new Thickness(13))); - H.Check("ThemeMemo_EagerWrapperNotifiedIsBlue", EagerWrapColor(target) == Colors.Blue, $"color={EagerWrapColor(target)}"); - var flyShapeBorder = FindText(target, "FlyShapeAfter")?.Parent as Border; - H.Check("ThemeMemo_FlyoutTargetRemountKeepsFlyoutModifiers", flyShapeBorder?.Margin == new Thickness(19) && flyoutShapeMounts == 2, - $"margin={flyShapeBorder?.Margin.ToString() ?? "(not found)"} mounts={flyoutShapeMounts}"); - H.Check("ThemeMemo_PrecedenceRefreshKeepsWrapper", PrecedenceColor(target) == Colors.Orange && precedenceMounts == 1, - $"color={PrecedenceColor(target)} mounts={precedenceMounts}"); - H.Check("ThemeMemo_FlipGapNotifiedIsBlue", FlipViewItemColor(target, "FlipGapProbe") == Colors.Blue, - $"color={FlipViewItemColor(target, "FlipGapProbe")}"); - H.Check("ThemeMemo_FlipItemNotifiedIsBlue", FlipViewItemColor(target, "FlipProbe") == Colors.Blue, - $"color={FlipViewItemColor(target, "FlipProbe")}"); - H.Check("ThemeMemo_FlyoutPrecedenceRefreshKeepsWrapper", FindText(target, "FlyPrecedenceProbe")?.FontSize == 30, - $"size={FindText(target, "FlyPrecedenceProbe")?.FontSize}"); - H.Check("ThemeMemo_LegacyFontRefreshKeepsWrapper", FindText(target, "LegacyFontProbe")?.FontSize == 30, - $"size={FindText(target, "LegacyFontProbe")?.FontSize}"); - var ftsBorder = FindText(target, "FtsAfter")?.Parent as Border; - H.Check("ThemeMemo_FlyoutOutputTargetReplacedKeepsFlyoutModifiers", ftsBorder?.Margin == new Thickness(23) - && flyoutTargetShapeMounts == 2 && FindText(target, "FtsBefore") is null, - $"margin={ftsBorder?.Margin.ToString() ?? "(not found)"} mounts={flyoutTargetShapeMounts}"); - H.Check("ThemeMemo_DragRegionRefreshKeepsWrapper", DragRegionOf(target, "DragProbe") == false, - $"drag={DragRegionOf(target, "DragProbe")}"); - H.Check("ThemeMemo_RefLabelRefreshKeepsWrapper", LabelTextOf(target, "RefLabeledProbe") == "LabelR", - $"label={LabelTextOf(target, "RefLabeledProbe")}"); - H.Check("ThemeMemo_PlacementRefreshKeepsWrapper", PlacementOf(target, "PlacementProbe") == global::Microsoft.UI.Xaml.Controls.Primitives.PlacementMode.Right, - $"placement={PlacementOf(target, "PlacementProbe")}"); - H.Check("ThemeMemo_LabeledByRefreshKeepsWrapper", LabelTextOf(target, "LabeledProbe") == "LabelW", - $"label={LabelTextOf(target, "LabeledProbe")}"); - H.Check("ThemeMemo_HelpPrecedenceRefreshKeepsWrapper", HelpTextOf(target, "HelpPrecedenceProbe") == "wrapper-help", - $"help={HelpTextOf(target, "HelpPrecedenceProbe")}"); - H.Check("ThemeMemo_HiddenRefreshStaysHidden", ViewOf(target, "HiddenPrecedenceProbe") == global::Microsoft.UI.Xaml.Automation.Peers.AccessibilityView.Raw, - $"view={ViewOf(target, "HiddenPrecedenceProbe")}"); - H.Check("ThemeMemo_ContainerNameForwardedOnRefresh", ContainerName(target, "LvNameProbe") == "name-blue", - $"name={ContainerName(target, "LvNameProbe")}"); - int replacedSnapshotsAfter = replacedShape is not null && Reconciler.TryGetReactorState(replacedShape, out var rsAfter) - ? Reconciler.CountKeyedMemoOutputs(rsAfter, "shape") : -1; - H.Check("ThemeMemo_UnmountedControlDropsSnapshots", replacedSnapshotsBefore == 1 && replacedSnapshotsAfter == 0 - && FindText(target, "ShapeProbeBefore") is null, - $"before={replacedSnapshotsBefore} after={replacedSnapshotsAfter}"); - H.Check("ThemeMemo_FontPrecedenceRefreshKeepsWrapper", FindText(target, "FontPrecedenceProbe")?.FontSize == 30, - $"size={FindText(target, "FontPrecedenceProbe")?.FontSize}"); - H.Check("ThemeMemo_TooltipReconciledOncePerRefresh", tipContentMounts - tipMountsBeforeNotify == 1 - && tipContentMounts - tipContentCleanups == 1, - $"mountsDuringNotify={tipContentMounts - tipMountsBeforeNotify} live={tipContentMounts - tipContentCleanups}"); - H.Check("ThemeMemo_SameKeyNotifiedIsBlue", ProbeColor(target, "SameKeyProbe") == Colors.Blue, - $"color={ProbeColor(target, "SameKeyProbe")}"); - H.Check("ThemeMemo_SameKeyKeptNotRemounted", sameKeyMounts == 1 && sameKeyCleanups == 0, - $"mounts={sameKeyMounts} cleanups={sameKeyCleanups}"); - var legacyOutBorder = FindText(target, "LegacyOutAfter")?.Parent as Border; - H.Check("ThemeMemo_LegacyOutputShapeChangeRemounts", legacyOutBorder?.Margin == new Thickness(17) - && FindText(target, "LegacyOutBefore") is null, - $"margin={legacyOutBorder?.Margin.ToString() ?? "(not found)"} before={FindText(target, "LegacyOutBefore") is not null}"); - H.Check("ThemeMemo_FlyoutTargetNotifiedIsBlue", ProbeColor(target, "FlyoutTargetProbe") == Colors.Blue, - $"color={ProbeColor(target, "FlyoutTargetProbe")}"); - H.Check("ThemeMemo_FlyoutTargetKeptNotRemounted", flyoutTargetMounts == 1 && flyoutTargetCleanups == 0, - $"mounts={flyoutTargetMounts} cleanups={flyoutTargetCleanups}"); - H.Check("ThemeMemo_VisualizerNotifiedOneLiveCopy", visualizerContentMounts >= 3 && visualizerContentMounts - visualizerContentCleanups == 1, - $"mounts={visualizerContentMounts} cleanups={visualizerContentCleanups}"); - H.Check("ThemeMemo_TreeContentVisibleToEmpty", ToggleTree(target)?.RootNodes[0].Content is null, - $"content={ToggleTree(target)?.RootNodes[0].Content?.GetType().Name ?? "null"}"); - H.Check("ThemeMemo_TreeContentEmptyToVisible", ToggleTree(target)?.RootNodes[1].Content is UIElement, - $"content={ToggleTree(target)?.RootNodes[1].Content?.GetType().Name ?? "null"}"); + H.Check("ThemeMemo_TreeViewNodesKept", NodeKept(treeView, rootNode), DescribeNode(treeView, rootNode)); H.Check("ThemeMemo_FreshTreeViewNodesKept", NodeKept(freshTreeView, freshRoot), DescribeNode(freshTreeView, freshRoot)); - bool templatedAfterShown = await Harness.WaitFor(() => FindText(target, "TemplatedAfter") is not null, maxPasses: 20, perPassMs: 20); - H.Check("ThemeMemo_TemplatedItemReplacedAndUnmounted", templatedItemCleanedUp == 1 && templatedAfterShown, - $"cleanedUp={templatedItemCleanedUp} after={FindText(target, "TemplatedAfter") is not null}"); + H.Check("ThemeMemo_TreeContentVisibleToEmpty", ToggleTree(target)?.RootNodes[0].Content is null); + H.Check("ThemeMemo_TreeContentEmptyToVisible", ToggleTree(target)?.RootNodes[1].Content is UIElement); H.Check("ThemeMemo_ListViewSelectionKept", listView?.SelectedIndex == 1, $"selected={listView?.SelectedIndex}"); - H.Check("ThemeMemo_ShapeChangeRemounts", FindText(target, "ShapeProbeAfter") is not null && FindText(target, "ShapeProbeBefore") is null); - H.Check("ThemeMemo_ShapeItemReplacedAndUnmounted", shapeItemCleanedUp == 1 && HasComboText(target, "ShapeItemAfter"), - $"cleanedUp={shapeItemCleanedUp} after={HasComboText(target, "ShapeItemAfter")}"); - H.Check("ThemeMemo_GapItemNotifiedIsBlue", ListItemColor(target, "GapItemProbe") == Colors.Blue, $"color={ListItemColor(target, "GapItemProbe")}"); - H.Check("ThemeMemo_NestedMemoNotifiedIsBlue", ProbeColor(target, "NestedMemoProbe") == Colors.Blue, $"color={ProbeColor(target, "NestedMemoProbe")}"); - H.Check("ThemeMemo_ResolvedMemoNotifiedIsBlue", ProbeColor(target, "ResolvedMemoProbe") == Colors.Blue, $"color={ProbeColor(target, "ResolvedMemoProbe")}"); - H.Check("ThemeMemo_KeyedMemoNotifiedIsBlue", ProbeColor(target, "KeyedMemoProbe") == Colors.Blue, $"color={ProbeColor(target, "KeyedMemoProbe")}"); - H.Check("ThemeMemo_ListItemNotifiedIsBlue", ListItemColor(target) == Colors.Blue, $"color={ListItemColor(target)}"); // A second edit, notified from the UI thread: the call is repeatable. // @@ -739,21 +367,6 @@ public override async Task RunAsync() // await host.WaitForIdleAsync(); H.Check("ThemeMemo_SecondEditIsGreen", ProbeColor(target) == Colors.Green, $"color={ProbeColor(target)}"); - - // Every pool return cleans the control: a renter must not inherit the previous - // mount's refresh snapshots (each memo layer's output, a FlipView's items). - var pooledProbe = new TextBlock(); - var pooledState = Reconciler.GetOrCreateReactorState(pooledProbe); - Reconciler.SetKeyedMemoOutput(pooledState, "pooled", 0, TextBlock("a")); - Reconciler.SetKeyedMemoOutput(pooledState, "pooledOuter", 0, TextBlock("b")); - pooledState.MountedItemElements = [(TextBlock("c"), true)]; - bool seeded = Reconciler.CountKeyedMemoOutputs(pooledState, "pooledOuter") == 1; - ElementPool.CleanElement(pooledProbe); - H.Check("ThemeMemo_PoolCleanDropsSnapshots", seeded - && Reconciler.CountKeyedMemoOutputs(pooledState, "pooled") == 0 - && Reconciler.CountKeyedMemoOutputs(pooledState, "pooledOuter") == 0 - && pooledState.MountedItemElements is null, - $"seeded={seeded} pooled={Reconciler.CountKeyedMemoOutputs(pooledState, "pooled")} items={pooledState.MountedItemElements?.Count}"); } finally { @@ -764,6 +377,23 @@ public override async Task RunAsync() } } + // A stateful component (an effect that runs once per mount) showing a themed TextBlock. + private sealed class Counter + { + public int Mounts; + public int Cleanups; + + public Element Probe(string text, string resourceKey) => RenderEachTime(ctx => + { + ctx.UseEffect(() => + { + Mounts++; + return () => Cleanups++; + }); + return TextBlock(text).Foreground(Theme.Ref(resourceKey)); + }); + } + private static global::Windows.UI.Color? ProbeColor(Border target, string probe = "ThemeMemoProbe") => (FindText(target, probe)?.Foreground as SolidColorBrush)?.Color; @@ -784,8 +414,8 @@ public override async Task RunAsync() return null; } - // Legacy ContentElement nodes (deprecated, still supported): the first node has content - // while the resource is red and is Empty after; the second the other way round. + // Legacy ContentElement nodes (deprecated, still supported), read during Render: the first + // node has content while the resource is red and is Empty after; the second the other way round. #pragma warning disable CS0618 private static Element ContentTogglingTree(string resourceKey) { @@ -829,9 +459,6 @@ private static bool NodeKept(TreeView? tree, TreeViewNode? node) private static string DescribeNode(TreeView? tree, TreeViewNode? node) => $"tree={tree is not null} node={node is not null} same={tree is { RootNodes.Count: > 0 } && ReferenceEquals(tree.RootNodes[0], node)} expanded={node?.IsExpanded}"; - private static bool HasComboText(Border target, string text) - => FindComboBoxes(target).SelectMany(cb => cb.Items.OfType()).Any(t => t.Text == text); - private static IEnumerable FindComboBoxes(DependencyObject root) { if (root is ComboBox cb) yield return cb; @@ -854,57 +481,6 @@ private static IEnumerable FindComboBoxes(DependencyObject root) return null; } - private static global::Microsoft.UI.Xaml.Controls.Primitives.PlacementMode? PlacementOf(Border target, string text) - => FindText(target, text) is { } tb ? global::Microsoft.UI.Xaml.Controls.ToolTipService.GetPlacement(tb) : null; - - private static bool? DragRegionOf(Border target, string text) - => FindText(target, text) is { } tb ? global::Microsoft.UI.Xaml.Controls.TitleBar.GetIsDragRegion(tb) : null; - - private static string? LabelTextOf(Border target, string text) - => FindText(target, text) is { } tb - && global::Microsoft.UI.Xaml.Automation.AutomationProperties.GetLabeledBy(tb) is TextBlock label ? label.Text : null; - - private static string? HelpTextOf(Border target, string text) - => FindText(target, text) is { } tb ? global::Microsoft.UI.Xaml.Automation.AutomationProperties.GetHelpText(tb) : null; - - private static global::Microsoft.UI.Xaml.Automation.Peers.AccessibilityView? ViewOf(Border target, string text) - => FindText(target, text) is { } tb ? global::Microsoft.UI.Xaml.Automation.AutomationProperties.GetAccessibilityView(tb) : null; - - // The automation name on the list container that holds the TextBlock with this text. - private static string? ContainerName(Border target, string text) - { - for (DependencyObject? node = FindText(target, text); node is not null; node = VisualTreeHelper.GetParent(node)) - { - if (node is global::Microsoft.UI.Xaml.Controls.Primitives.SelectorItem item) - return global::Microsoft.UI.Xaml.Automation.AutomationProperties.GetName(item); - } - return null; - } - - private static global::Windows.UI.Color? PrecedenceColor(Border target) - => (FindText(target, "PrecedenceProbe")?.Parent as Border)?.Background is SolidColorBrush b ? b.Color : null; - - // The item TextBlock with this text, in any FlipView under target. - private static global::Windows.UI.Color? FlipViewItemColor(DependencyObject root, string text) - { - if (root is FlipView fv) - { - foreach (var item in fv.Items) - { - if (item is TextBlock { Foreground: SolidColorBrush b } tb && tb.Text == text) return b.Color; - } - } - int count = VisualTreeHelper.GetChildrenCount(root); - for (int i = 0; i < count; i++) - { - if (FlipViewItemColor(VisualTreeHelper.GetChild(root, i), text) is { } found) return found; - } - return null; - } - - private static global::Windows.UI.Color? EagerWrapColor(Border target) - => (FindText(target, "EagerWrapProbe")?.Parent as Border)?.Background is SolidColorBrush b ? b.Color : null; - // Whether an element within a few levels above `fe` carries `margin`. private static bool AncestorMargin(FrameworkElement? fe, Thickness margin) { From 805e9f6dbd1270bec1afe051576618407c5e1de0 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Mon, 19 Oct 2026 12:30:12 -0700 Subject: [PATCH 2/4] Keep main's hosted-slot fix and FlipView item map; mount semantics for transparent wrappers #1329 merged with the hosted-slot unmount fix, so it stays rather than being split out (removing it would reintroduce double teardown on replacement and the popup/ Expander leaks). The FlipView keeps a per-item 'realized a control' map (no element snapshot) so an Empty item can't misalign the update. A keyed memo or target-wrapping flyout whose inner update returns a different control applies its own modifiers with mount semantics - one rule instead of the fresh-control markers. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Reactor/Core/ChildReconciler.cs | 22 ++++--- src/Reactor/Core/ElementPool.cs | 2 + src/Reactor/Core/Reconciler.Update.cs | 39 ++++++----- src/Reactor/Core/Reconciler.cs | 66 ++++++++++++++++--- .../Core/V1Protocol/CompositeLifecycle.cs | 8 +-- .../V1Protocol/Handlers/ExpanderHandler.cs | 2 +- .../V1Protocol/NavigationHostLifecycle.cs | 5 +- .../Core/V1Protocol/OverlayLifecycle.cs | 6 +- .../V1Protocol/Reconciler.TemplatedTree.cs | 2 +- .../Core/V1Protocol/TemplatedListLifecycle.cs | 52 +++++++++++---- .../HostIdleAndThemeResourceFixtures.cs | 56 ++++++++++++++++ 11 files changed, 198 insertions(+), 62 deletions(-) diff --git a/src/Reactor/Core/ChildReconciler.cs b/src/Reactor/Core/ChildReconciler.cs index a6c1745cf..accbf0e2f 100644 --- a/src/Reactor/Core/ChildReconciler.cs +++ b/src/Reactor/Core/ChildReconciler.cs @@ -252,11 +252,11 @@ private static void UpdateCommonChild( if (reconciler.CanUpdate(oldEl, newEl)) { existingControl ??= children.Get(i); - var replacement = reconciler.UpdateChild(oldEl, newEl, existingControl, requestRerender); + var replacement = reconciler.UpdateChildTracked(oldEl, newEl, existingControl, requestRerender, out var unmountedByUpdate); if (replacement is not null) { // Child type changed at runtime — replace in place - reconciler.UnmountChild(existingControl); + if (!unmountedByUpdate) reconciler.UnmountChild(existingControl); children.Replace(i, replacement); } } @@ -346,10 +346,10 @@ private static void ReconcileKeyed( if (prefixLen < childCount) { existingControl ??= children.Get(prefixLen); - var replacement = reconciler.UpdateChild(oldEl, newEl, existingControl, requestRerender); + var replacement = reconciler.UpdateChildTracked(oldEl, newEl, existingControl, requestRerender, out var unmountedByUpdate); if (replacement is not null) { - reconciler.UnmountChild(existingControl); + if (!unmountedByUpdate) reconciler.UnmountChild(existingControl); children.Replace(prefixLen, replacement); } } @@ -401,10 +401,10 @@ private static void ReconcileKeyed( if (panelIdx >= 0 && panelIdx < childCount) { existingControl ??= children.Get(panelIdx); - var replacement = reconciler.UpdateChild(oldEl, newEl, existingControl, requestRerender); + var replacement = reconciler.UpdateChildTracked(oldEl, newEl, existingControl, requestRerender, out var unmountedByUpdate); if (replacement is not null) { - reconciler.UnmountChild(existingControl); + if (!unmountedByUpdate) reconciler.UnmountChild(existingControl); children.Replace(panelIdx, replacement); } } @@ -771,14 +771,16 @@ public void Patch(int oldRelIdx, int newIdx, int panelIdx) // that adds a CanSkipUpdate fast-path here MUST mirror Reconciler.Update's skip arm // (ApplyThemeBindings + ApplyResourceOverrides) or it will re-introduce the #701/#675 // staleness — see Issue675ResourceOverrideSkipFixtures.KeyedMiddleReorderReResolves. - var replacement = _reconciler.UpdateChild( + var existing = _children.Get(panelIdx); + var replacement = _reconciler.UpdateChildTracked( _oldChildren[_oldStart + oldRelIdx], _newChildren[_newStart + newIdx], - _children.Get(panelIdx), - _requestRerender); + existing, + _requestRerender, + out var unmountedByUpdate); if (replacement is not null) { - _reconciler.UnmountChild(_children.Get(panelIdx)); + if (!unmountedByUpdate) _reconciler.UnmountChild(existing); _children.Replace(panelIdx, replacement); } } diff --git a/src/Reactor/Core/ElementPool.cs b/src/Reactor/Core/ElementPool.cs index e7fc37bf5..2684387ae 100644 --- a/src/Reactor/Core/ElementPool.cs +++ b/src/Reactor/Core/ElementPool.cs @@ -226,6 +226,8 @@ internal static void CleanElement(FrameworkElement fe) { // Common properties Reconciler.ClearElementTag(fe); + // Refresh snapshots hold the previous renter's element graphs and describe its mount. + Reconciler.ClearRefreshSnapshots(fe); // SECURITY (TASK-060): clear the Current* user-handler delegates on // pool return so a pooled control can't fire the previous component's // captured rerender closure into the next mount. The underlying diff --git a/src/Reactor/Core/Reconciler.Update.cs b/src/Reactor/Core/Reconciler.Update.cs index 8b9b74f31..f58f389d0 100644 --- a/src/Reactor/Core/Reconciler.Update.cs +++ b/src/Reactor/Core/Reconciler.Update.cs @@ -244,6 +244,15 @@ public sealed partial class Reconciler }; } + // A transparent layer (a keyed Memo, a flyout wrapping its target) shares its inner + // element's control. When the inner update hands back a different control (a target whose + // type changed, a handler that always remounts), that control is new to this layer too, so + // its own modifiers apply with mount semantics (all written, OnMount runs) rather than as an + // old->new diff. Nested layers each see the same different control and do the same. + if (result is not null && !ReferenceEquals(result, control) + && newEl is KeyedMemoElement or FlyoutElement or MenuFlyoutElement or CommandBarFlyoutElement) + oldModifiers = null; + // Apply inline modifiers after update. When old modifiers existed but new // modifiers are null, pass an empty instance so ApplyModifiers can clear // stale values (same principle as the flex attached-property fix). @@ -1177,7 +1186,7 @@ internal void ReconcileChild(Element? oldChild, Element? newChild, if (newChild is not null && oldChild is not null && getControl() is UIElement existing && CanUpdate(oldChild, newChild)) { - var replacement = Update(oldChild, newChild, existing, requestRerender); + var replacement = UpdateHostedChild(oldChild, newChild, existing, requestRerender); if (replacement is not null) setControl(replacement); } else if (newChild is not null) @@ -1232,18 +1241,12 @@ internal void RefreshRealizedContainers(WinUI.ListViewBase listViewBase, Interna var oldItemElement = GetElementTag(cc); var newItemElement = viewSource.BuildItemView(index); - if (oldItemElement is not null && cc.Content is UIElement existingCtrl && CanUpdate(oldItemElement, newItemElement)) - { - var replacement = Update(oldItemElement, newItemElement, existingCtrl, requestRerender); - if (replacement is not null && !ReferenceEquals(cc.Content, replacement)) - cc.Content = replacement; - } - else - { - if (cc.Content is UIElement oldCtrl) - Unmount(oldCtrl); - cc.Content = Mount(newItemElement, requestRerender); - } + // ReconcileV1Child updates in place when it can and remounts otherwise, and either way + // unmounts a control it replaced (e.g. a same-key Memo whose output changed shape + // during a resource refresh), so the old item's effects and refs are cleaned up. + var next = ReconcileV1Child(oldItemElement, newItemElement, cc.Content as UIElement, requestRerender); + if (!ReferenceEquals(cc.Content, next)) + cc.Content = next; SetElementTag(cc, newItemElement); // Issue #951 — the container survives re-renders, so its automation @@ -1469,7 +1472,7 @@ private void ReconcileTreeNodeContent( && CanUpdate(oldContentEl, newContentEl)) { // Reconcile in place - var replacement = Update(oldContentEl, newContentEl, existingCtrl, requestRerender); + var replacement = UpdateHostedChild(oldContentEl, newContentEl, existingCtrl, requestRerender); if (replacement is not null && !ReferenceEquals(liveNode.Content, replacement)) liveNode.Content = replacement; } @@ -1567,10 +1570,10 @@ private void ReconcileTreeNodeContent( /// /// A same-key Memo(key, …) whose subtree holds a component that updated its own - /// state, or any same-key memo during a resource-refresh pass (where nothing skips, so the - /// whole subtree is walked and its theme values are applied again; a value the factory - /// resolved eagerly with ThemeRef.Resolve is equal on both sides and is not, which - /// the styling guide documents). The mounted inner element is not kept, so re-run the factory — pure by contract, + /// state, or any same-key memo during a resource-refresh pass (nothing skips there, so the + /// whole subtree is walked and its Theme.Ref values applied again; a brush the factory + /// resolved eagerly is equal on both sides and stays as it is, as the styling guide documents). + /// The mounted inner element is not kept, so re-run the factory — pure by contract, /// so its output describes what is mounted — and reconcile that output against itself. /// Old and new are the same instances all the way down, so every branch skips by reference /// except the dirty-ancestor path, which descends to the component. Nothing is compared diff --git a/src/Reactor/Core/Reconciler.cs b/src/Reactor/Core/Reconciler.cs index fef83e6f2..5a94aa3b3 100644 --- a/src/Reactor/Core/Reconciler.cs +++ b/src/Reactor/Core/Reconciler.cs @@ -539,10 +539,10 @@ internal void RefreshRealizedItemContainers( if (i < 0 || i >= items.Length) continue; var next = ReconcileV1Child(items[i], items[i], existing, requestRerender); if (next is not null && !ReferenceEquals(next, existing)) - { cc.Content = next; - PropagateItemAutomationName(container, next); - } + // The item's own automation name can change in place too (a keyed memo whose output + // reads a resource), so forward it after every reconcile, as RefreshRealizedContainers does. + PropagateItemAutomationName(container, cc.Content as UIElement); } } @@ -928,6 +928,13 @@ internal sealed class ReactorState /// public HashSet? ManagedResourceKeys; + /// + /// For a templated FlipView: whether each logical item realized a control. An item + /// whose view mounts nothing (Empty) has no entry in Items, so updates map logical + /// items to entries through this. + /// + public List? FlipViewItemHasControl; + /// /// For an ItemsHost control: the collection entry each logical item realized /// (-1 when it mounted nothing), recorded when the items are filled, so a resource @@ -1265,6 +1272,23 @@ internal static void ClearElementTag(FrameworkElement fe) state.Element = null; } + /// + /// Drops the item maps on a control (a FlipView's realized items, an ItemsHost's entries). + /// They describe one mount, so a control leaving the tree, to the pool or for good, must not + /// keep them. + /// + internal static void ClearRefreshSnapshots(FrameworkElement fe) + { + if (fe.GetValue(ReactorAttached.StateProperty) is ReactorState state) + ClearRefreshSnapshots(state); + } + + internal static void ClearRefreshSnapshots(ReactorState state) + { + state.FlipViewItemHasControl = null; + state.ItemsHostEntries = null; + } + /// /// Clears the Current* user-handler delegates from the per-element /// ModifierEventHandlerState while leaving the trampoline subscription on the @@ -1316,8 +1340,8 @@ public static void DetachReactorState(FrameworkElement fe) state.EchoSuppressScopeDepth = 0; state.PendingEchoMatch = null; state.PendingLabeledBy = null; - // Describes the items this control showed; a detached control has none. - state.ItemsHostEntries = null; + // Item maps describe the items this control showed; a detached control has none. + ClearRefreshSnapshots(state); // Issue #1262 — a retired control must not keep a ValidationContext (or a // pending async rule) alive through a binding the normal FormField / // ValidationRule unmount path never got to clear. Neutralize before dropping @@ -1685,7 +1709,7 @@ public void ReturnControl(T control, PoolPolicy? policy = null) where T : rs.EchoSuppressScopeDepth = 0; rs.PendingEchoMatch = null; rs.PendingLabeledBy = null; - rs.ItemsHostEntries = null; + ClearRefreshSnapshots(rs); rs.Element = null; } // Clear Reactor-set DataContext (FrameworkElement-only DP). @@ -2939,6 +2963,30 @@ internal void ReconcilePanelChildrenInto( return Update(oldEl, newEl, control, requestRerender); } + /// + /// for a hosted child slot (dialog/popup/flyout/tooltip content, tree and + /// list item containers) whose owner swaps a replacement in. When the update hands back a + /// different control, the old one is unmounted here (unless the update already did) so its + /// effects, refs and unmount callbacks don't outlive it. Returns the replacement, or null + /// when the existing control was kept: a drop-in for at those sites. + /// + internal UIElement? UpdateHostedChild(Element oldEl, Element newEl, UIElement existing, Action requestRerender) + { + var replacement = UpdateSlotChild(oldEl, newEl, existing, requestRerender, out var unmountedByUpdate); + if (replacement is null || IsSameControl(replacement, existing)) return null; + if (!unmountedByUpdate) Unmount(existing); + return replacement; + } + + /// + /// for a caller that unmounts a replaced control: reports whether + /// the update already unmounted it (e.g. a same-key Memo remounting its output during a + /// resource refresh), so the caller doesn't unmount it twice. + /// + internal UIElement? UpdateChildTracked(Element oldEl, Element newEl, UIElement control, Action requestRerender, + out bool unmountedByUpdate) + => UpdateSlotChild(oldEl, newEl, control, requestRerender, out unmountedByUpdate); + /// /// Unmounts a child control. Public so registered type handlers can unmount children. /// @@ -5046,7 +5094,7 @@ internal void ApplyModifiers(FrameworkElement fe, ElementModifiers? oldM, Elemen var existingTip = WinUI.ToolTipService.GetToolTip(fe) as UIElement; if (oldTipEl is not null && existingTip is not null && CanUpdate(oldTipEl, m.RichToolTip)) { - var replacement = Update(oldTipEl, m.RichToolTip, existingTip, requestRerender); + var replacement = UpdateHostedChild(oldTipEl, m.RichToolTip, existingTip, requestRerender); if (replacement is not null) WinUI.ToolTipService.SetToolTip(fe, replacement); } @@ -6430,7 +6478,7 @@ private void UpdateFlyoutInPlace(WinPrim.FlyoutBase existingFlyout, Element oldE var oldContent = oldEl is ContentFlyoutElement oldCf ? oldCf.Content : null; if (oldContent is not null && flyout.Content is UIElement existingContent && CanUpdate(oldContent, newCf.Content)) { - var replacement = Update(oldContent, newCf.Content, existingContent, requestRerender); + var replacement = UpdateHostedChild(oldContent, newCf.Content, existingContent, requestRerender); if (replacement is not null && !ReferenceEquals(flyout.Content, replacement)) flyout.Content = replacement; } @@ -6457,7 +6505,7 @@ private void UpdateFlyoutInPlace(WinPrim.FlyoutBase existingFlyout, Element oldE { if (CanUpdate(oldEl, newEl)) { - var replacement = Update(oldEl, newEl, existingCtrl, requestRerender); + var replacement = UpdateHostedChild(oldEl, newEl, existingCtrl, requestRerender); if (replacement is not null && !ReferenceEquals(plainFlyout.Content, replacement)) plainFlyout.Content = replacement; } diff --git a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs index 121496f9a..23fff1225 100644 --- a/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs +++ b/src/Reactor/Core/V1Protocol/CompositeLifecycle.cs @@ -27,10 +27,10 @@ internal static WinUI.Grid MountCommandHost(Reconciler reconciler, CommandHostEl // Update child element if (host.Children.Count > 0 && host.Children[0] is UIElement existingChild) { - var replacement = reconciler.UpdateChild(o.Child, n.Child, existingChild, requestRerender); + var replacement = reconciler.UpdateChildTracked(o.Child, n.Child, existingChild, requestRerender, out var unmountedByUpdate); if (replacement is not null) { - reconciler.UnmountChild(existingChild); + if (!unmountedByUpdate) reconciler.UnmountChild(existingChild); // Explicit RemoveAt+Insert instead of indexer assignment — WinUI's // Children[i] = x can leave the old element's internal parent state // attached, causing a COMException when it is later reused from the @@ -323,12 +323,12 @@ private static void ApplyAttachedValidation( var outgoingContent = existingContent; if (reconciler.CanUpdate(oldFf.Content, newFf.Content)) { - var replacement = reconciler.Update(oldFf.Content, newFf.Content, existingContent, requestRerender); + var replacement = reconciler.UpdateChildTracked(oldFf.Content, newFf.Content, existingContent, requestRerender, out var unmountedByUpdate); if (replacement is not null) { // WinUI indexer assignment doesn't fully disconnect the old element's // parent state — use RemoveAt+Insert (see ChildCollection.Replace). - reconciler.UnmountChild(existingContent); + if (!unmountedByUpdate) reconciler.UnmountChild(existingContent); panel.Children.RemoveAt(1); panel.Children.Insert(1, replacement); existingContent = replacement; diff --git a/src/Reactor/Core/V1Protocol/Handlers/ExpanderHandler.cs b/src/Reactor/Core/V1Protocol/Handlers/ExpanderHandler.cs index 52e3b33b3..9a2f76cc4 100644 --- a/src/Reactor/Core/V1Protocol/Handlers/ExpanderHandler.cs +++ b/src/Reactor/Core/V1Protocol/Handlers/ExpanderHandler.cs @@ -73,7 +73,7 @@ public UIElement Update(UpdateContext ctx, ExpanderElement oldEl, ExpanderElemen // Reconcile content child if (exp.Content is UIElement existingContent && reconciler.CanUpdate(oldEl.Content, newEl.Content)) { - var replacement = reconciler.UpdateChild(oldEl.Content, newEl.Content, existingContent, requestRerender); + var replacement = reconciler.UpdateHostedChild(oldEl.Content, newEl.Content, existingContent, requestRerender); if (replacement is not null && !ReferenceEquals(exp.Content, replacement)) exp.Content = replacement; } diff --git a/src/Reactor/Core/V1Protocol/NavigationHostLifecycle.cs b/src/Reactor/Core/V1Protocol/NavigationHostLifecycle.cs index cd6ddd120..38aaafe0c 100644 --- a/src/Reactor/Core/V1Protocol/NavigationHostLifecycle.cs +++ b/src/Reactor/Core/V1Protocol/NavigationHostLifecycle.cs @@ -127,8 +127,9 @@ public static WinUI.Grid Mount(Reconciler reconciler, NavigationHostElement elem { // Route unchanged — reconcile the existing child element in place var newChildElement = node.RouteMap(currentRoute); + bool unmountedByUpdate = false; var replacement = node.CurrentChildControl is not null - ? reconciler.UpdateChild(node.CurrentChildElement, newChildElement, node.CurrentChildControl, requestRerender) + ? reconciler.UpdateChildTracked(node.CurrentChildElement, newChildElement, node.CurrentChildControl, requestRerender, out unmountedByUpdate) : reconciler.Mount(newChildElement, requestRerender); if (replacement is not null && node.CurrentChildControl is not null) @@ -139,7 +140,7 @@ public static WinUI.Grid Mount(Reconciler reconciler, NavigationHostElement elem grid.Children[idx] = replacement; else grid.Children.Add(replacement); - reconciler.UnmountChild(node.CurrentChildControl); + if (!unmountedByUpdate) reconciler.UnmountChild(node.CurrentChildControl); node.CurrentChildControl = replacement; } else if (replacement is not null) diff --git a/src/Reactor/Core/V1Protocol/OverlayLifecycle.cs b/src/Reactor/Core/V1Protocol/OverlayLifecycle.cs index 2605fa310..f27f02636 100644 --- a/src/Reactor/Core/V1Protocol/OverlayLifecycle.cs +++ b/src/Reactor/Core/V1Protocol/OverlayLifecycle.cs @@ -95,7 +95,7 @@ public static UIElement MountContentDialog(Reconciler reconciler, ContentDialogE // the dialog (focus, scroll, caret) survives the owner's re-renders. if (dialog.Content is UIElement existing && reconciler.CanUpdate(o.Content, n.Content)) { - var replacement = reconciler.Update(o.Content, n.Content, existing, requestRerender); + var replacement = reconciler.UpdateHostedChild(o.Content, n.Content, existing, requestRerender); if (replacement is not null && !ReferenceEquals(dialog.Content, replacement)) dialog.Content = replacement; } @@ -320,7 +320,7 @@ private static async void ShowContentDialogCore(Reconciler reconciler, ContentDi { if (flyout.Content is UIElement existingContent && reconciler.CanUpdate(o.FlyoutContent, n.FlyoutContent)) { - var contentRepl = reconciler.Update(o.FlyoutContent, n.FlyoutContent, existingContent, requestRerender); + var contentRepl = reconciler.UpdateHostedChild(o.FlyoutContent, n.FlyoutContent, existingContent, requestRerender); if (contentRepl is not null) flyout.Content = contentRepl; } else @@ -553,7 +553,7 @@ public static UIElement MountPopup(Reconciler reconciler, PopupElement popup, Ac if (popup.Child is UIElement existing && reconciler.CanUpdate(o.Child, n.Child)) { - var replacement = reconciler.Update(o.Child, n.Child, existing, requestRerender); + var replacement = reconciler.UpdateHostedChild(o.Child, n.Child, existing, requestRerender); if (replacement is not null && !ReferenceEquals(popup.Child, replacement)) popup.Child = replacement; } diff --git a/src/Reactor/Core/V1Protocol/Reconciler.TemplatedTree.cs b/src/Reactor/Core/V1Protocol/Reconciler.TemplatedTree.cs index 4e84b2b1f..70e588587 100644 --- a/src/Reactor/Core/V1Protocol/Reconciler.TemplatedTree.cs +++ b/src/Reactor/Core/V1Protocol/Reconciler.TemplatedTree.cs @@ -244,7 +244,7 @@ private void RefreshRealizedTreeContainers(WinUI.TreeView tv, WinUI.ListView? li 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); + var replacement = UpdateHostedChild(oldView, newView, existing, requestRerender); if (replacement is not null && !ReferenceEquals(cc.Content, replacement)) cc.Content = replacement; } diff --git a/src/Reactor/Core/V1Protocol/TemplatedListLifecycle.cs b/src/Reactor/Core/V1Protocol/TemplatedListLifecycle.cs index 53c575fc7..1caa4e17f 100644 --- a/src/Reactor/Core/V1Protocol/TemplatedListLifecycle.cs +++ b/src/Reactor/Core/V1Protocol/TemplatedListLifecycle.cs @@ -194,12 +194,17 @@ internal static WinUI.FlipView MountFlipView(Reconciler reconciler, TemplatedLis // FlipView doesn't support ContainerContentChanging (not a ListViewBase). // Pre-mount all items — FlipView typically has few items so this is fine. + // An item whose view mounts nothing (Empty) has no entry in Items, so the record keeps, + // per logical item, whether it did. + var hasControl = new List(el.ItemCount); for (int i = 0; i < el.ItemCount; i++) { var itemElement = el.BuildItemView(i); var ctrl = reconciler.Mount(itemElement, requestRerender); if (ctrl is not null) flipView.Items.Add(ctrl); + hasControl.Add(ctrl is not null); } + Reconciler.GetOrCreateReactorState(flipView).FlipViewItemHasControl = hasControl; flipView.SelectionChanged += (s, _) => { @@ -352,43 +357,62 @@ public IEnumerator GetEnumerator() internal static UIElement? UpdateFlipView(Reconciler reconciler, TemplatedListElementBase o, TemplatedListElementBase n, WinUI.FlipView fv, Action requestRerender) { - // FlipView items are pre-mounted directly (no ContainerContentChanging). - // Build old element array from o, then reconcile like regular items. + // FlipView items are pre-mounted directly (no ContainerContentChanging): rebuild each old + // item view from o and reconcile it like a regular item. An item whose view is Empty has + // no control in Items, so walk the controls with their own index, using the record of + // which items realized one. Without a usable record, treat the first Items.Count items as + // the ones holding a control. int oldCount = o.ItemCount; int newCount = n.ItemCount; int shared = Math.Min(oldCount, newCount); + var fvState = Reconciler.GetOrCreateReactorState(fv); + var recorded = fvState.FlipViewItemHasControl is { } r && r.Count == oldCount ? r : null; + var nextHasControl = new List(newCount); + int ci = 0; for (int i = 0; i < shared; i++) { var oldItemElement = o.BuildItemView(i); + bool oldHasControl = recorded?[i] ?? i < fv.Items.Count; var newItemElement = n.BuildItemView(i); - if (fv.Items[i] is UIElement existingCtrl && reconciler.CanUpdate(oldItemElement, newItemElement)) + if (oldHasControl && ci < fv.Items.Count && fv.Items[ci] is UIElement existingCtrl + && reconciler.CanUpdate(oldItemElement, newItemElement)) { - var replacement = reconciler.Update(oldItemElement, newItemElement, existingCtrl, requestRerender); + var replacement = reconciler.UpdateHostedChild(oldItemElement, newItemElement, existingCtrl, requestRerender); if (replacement is not null && replacement != existingCtrl) - fv.Items[i] = replacement; + fv.Items[ci] = replacement; + ci++; + nextHasControl.Add(true); + continue; } - else + + if (oldHasControl && ci < fv.Items.Count) { - if (fv.Items[i] is UIElement oldCtrl) reconciler.UnmountChild(oldCtrl); - fv.Items[i] = reconciler.Mount(newItemElement, requestRerender)!; + if (fv.Items[ci] is UIElement oldCtrl) reconciler.UnmountChild(oldCtrl); + fv.Items.RemoveAt(ci); } + var ctrl = reconciler.Mount(newItemElement, requestRerender); + if (ctrl is not null) fv.Items.Insert(ci++, ctrl); + nextHasControl.Add(ctrl is not null); } - // Remove excess - for (int i = oldCount - 1; i >= shared; i--) + // Remove excess: every control past the shared items belongs to a removed item. + while (fv.Items.Count > ci) { - if (fv.Items[i] is UIElement oldCtrl) reconciler.UnmountChild(oldCtrl); - fv.Items.RemoveAt(i); + int last = fv.Items.Count - 1; + if (fv.Items[last] is UIElement oldCtrl) reconciler.UnmountChild(oldCtrl); + fv.Items.RemoveAt(last); } // Add new for (int i = shared; i < newCount; i++) { - var ctrl = reconciler.Mount(n.BuildItemView(i), requestRerender); + var itemElement = n.BuildItemView(i); + var ctrl = reconciler.Mount(itemElement, requestRerender); if (ctrl is not null) fv.Items.Add(ctrl); + nextHasControl.Add(ctrl is not null); } - + fvState.FlipViewItemHasControl = nextHasControl; // Issue #495 — drift-guarded, suppressed write. Previously // unconditional, which would write -1 when n.GetSelectedIndex() == -1 // (the default for TemplatedFlipViewElement is 0, so the prior diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/HostIdleAndThemeResourceFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/HostIdleAndThemeResourceFixtures.cs index 06353e6ca..19754e2eb 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/HostIdleAndThemeResourceFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/HostIdleAndThemeResourceFixtures.cs @@ -1,4 +1,5 @@ using Microsoft.UI; +using Microsoft.UI.Reactor.Controls.Validation; using Microsoft.UI.Reactor.Core; using Microsoft.UI.Reactor.Hosting; using Microsoft.UI.Xaml; @@ -251,6 +252,11 @@ public override async Task RunAsync() var sameKey = new Counter(); var legacyNested = new Counter(); var flyoutTarget = new Counter(); + // Hosted and transparent-wrapper slots whose child comes back as a different control. + var replacedTarget = new Counter(); + var popupContent = new Counter(); + int visualizerWrapperMounts = 0; + int flyoutWrapperMounts = 0; try { // Disposed at the end of this block, before finally restores the active host. @@ -270,6 +276,7 @@ public override async Task RunAsync() () => [Empty(), Memo("emptyItem", () => Empty()), TextBlock("GapItemProbe").Foreground(Theme.Ref(AppKey))]); var lvItems = ctx.UseMemo( () => [TextBlock("LvItemProbe").Foreground(Theme.Ref(AppKey)), TextBlock("LvSecond")]); + var flipGapItems = ctx.UseMemo>(() => ["gap", "shown"]); var gvItems = ctx.UseMemo( () => [TextBlock("GvItemProbe").Foreground(Theme.Ref(AppKey))]); var lazyItems = ctx.UseMemo>(() => ["lazy"]); @@ -290,6 +297,25 @@ public override async Task RunAsync() Memo("flyoutOuter", () => Flyout( Memo("flyoutInner", () => flyoutTarget.Probe("FlyoutTargetProbe", AppKey)), TextBlock("FlyoutTip"))), + // A memo whose output's handler always remounts (ValidationVisualizer): the new + // control gets the memo's modifiers and OnMount, as at mount. + Memo("visualizerWrapper", () => ValidationVisualizerDsl.ValidationVisualizer(VisualizerStyle.Inline, + TextBlock("VisualizerWrapProbe"))) + .Margin(9) + .OnMount(_ => visualizerWrapperMounts++), + // A flyout whose target changes type on an ordinary render: the old target is torn + // down exactly once, and the flyout's own modifiers reach the new one. + Flyout(tick == 0 ? replacedTarget.Probe("ReplacedTargetBefore", AppKey) : Border(TextBlock("ReplacedTargetAfter")), + TextBlock("ReplacedTargetTip")) + .Margin(19) + .OnMount(_ => flyoutWrapperMounts++), + // A popup child whose update always builds a fresh control: one live copy. + Popup(ValidationVisualizerDsl.ValidationVisualizer(VisualizerStyle.Inline, + popupContent.Probe("PopupContentProbe", AppKey))), + // A FlipView whose first item mounts nothing. + FlipView(flipGapItems, static s => s, static (s, _) => s == "gap" + ? Empty() + : TextBlock("FlipGapProbe").Foreground(Theme.Ref(AppKey))), ComboBox(gapItems, default, null), ComboBox(listItems, default, null), ListView(lvItems), @@ -320,6 +346,14 @@ public override async Task RunAsync() setTick!(1); await host.WaitForIdleAsync(); H.Check("ThemeMemo_Rerendered", FindText(target, "tick:1") is not null); + var replacedBorder = FindText(target, "ReplacedTargetAfter")?.Parent as Border; + H.Check("Replace_FlyoutTargetTornDownOnce", replacedTarget.Mounts == 1 && replacedTarget.Cleanups == 1, + $"mounts={replacedTarget.Mounts} cleanups={replacedTarget.Cleanups}"); + H.Check("Replace_FlyoutModifiersReachNewTarget", replacedBorder?.Margin == new Thickness(19) && flyoutWrapperMounts == 2, + $"margin={replacedBorder?.Margin.ToString() ?? "(not found)"} mounts={flyoutWrapperMounts}"); + H.Check("Replace_PopupContentOneLiveCopy", popupContent.Mounts >= 2 && popupContent.Mounts - popupContent.Cleanups == 1, + $"mounts={popupContent.Mounts} cleanups={popupContent.Cleanups}"); + H.Check("ThemeMemo_VisualizerWrapperRerenderAloneMountedOnce", visualizerWrapperMounts == 1, $"mounts={visualizerWrapperMounts}"); // A user expansion and selection, which rebuilding the TreeView or swapping the // ListView's ItemsSource would reset. var treeView = FindTreeView(target, "TreeRoot"); @@ -349,6 +383,10 @@ public override async Task RunAsync() foreach (var probe in stale.Append("RenderResolvedProbe")) H.Check($"ThemeMemo_{probe}NotifiedIsBlue", ProbeColor(target, probe) == Colors.Blue, $"color={ProbeColor(target, probe)}"); H.Check("ThemeMemo_ListItemNotifiedIsBlue", ListItemColor(target) == Colors.Blue, $"color={ListItemColor(target)}"); + H.Check("ThemeMemo_FlipGapNotifiedIsBlue", FlipViewItemColor(target, "FlipGapProbe") == Colors.Blue); + H.Check("ThemeMemo_VisualizerWrapperRemountRunsOnMount", visualizerWrapperMounts == 2 + && AncestorMargin(FindText(target, "VisualizerWrapProbe"), new Thickness(9)), + $"mounts={visualizerWrapperMounts} margin={AncestorMargin(FindText(target, "VisualizerWrapProbe"), new Thickness(9))}"); H.Check("ThemeMemo_GapItemNotifiedIsBlue", ListItemColor(target, "GapItemProbe") == Colors.Blue); foreach (var (name, counter) in new[] { ("SameKey", sameKey), ("LegacyNested", legacyNested), ("FlyoutTarget", flyoutTarget) }) H.Check($"ThemeMemo_{name}KeptNotRemounted", counter.Mounts == 1 && counter.Cleanups == 0, @@ -403,6 +441,24 @@ public Element Probe(string text, string resourceKey) => RenderEachTime(ctx => => (FindComboBoxes(target).SelectMany(cb => cb.Items.OfType()).FirstOrDefault(t => t.Text == probe) ?.Foreground as SolidColorBrush)?.Color; + // The item TextBlock with this text, in any FlipView under root. + private static global::Windows.UI.Color? FlipViewItemColor(DependencyObject root, string text) + { + if (root is FlipView fv) + { + foreach (var item in fv.Items) + { + if (item is TextBlock { Foreground: SolidColorBrush b } tb && tb.Text == text) return b.Color; + } + } + int count = VisualTreeHelper.GetChildrenCount(root); + for (int i = 0; i < count; i++) + { + if (FlipViewItemColor(VisualTreeHelper.GetChild(root, i), text) is { } found) return found; + } + return null; + } + private static T? FindDescendant(DependencyObject root) where T : DependencyObject { if (root is T match) return match; From 6bc91d38dae767a051fc107a2476bd80501f1f72 Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Mon, 19 Oct 2026 12:44:19 -0700 Subject: [PATCH 3/4] Keep the FlipView's mounted-item record as the update baseline A view rebuilt from the old element reads the items' current values, so an item changed in place compared equal to itself and stayed stale on an ordinary update. Restore main's per-item (element, realized) record - the FlipView counterpart of the element a templated ListView tags on its container - and cover the in-place change. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Reactor/Core/Reconciler.cs | 13 ++++---- .../Core/V1Protocol/TemplatedListLifecycle.cs | 32 +++++++++---------- .../HostIdleAndThemeResourceFixtures.cs | 19 +++++++++++ 3 files changed, 42 insertions(+), 22 deletions(-) diff --git a/src/Reactor/Core/Reconciler.cs b/src/Reactor/Core/Reconciler.cs index 5a94aa3b3..0dc0d97da 100644 --- a/src/Reactor/Core/Reconciler.cs +++ b/src/Reactor/Core/Reconciler.cs @@ -929,11 +929,12 @@ internal sealed class ReactorState public HashSet? ManagedResourceKeys; /// - /// For a templated FlipView: whether each logical item realized a control. An item - /// whose view mounts nothing (Empty) has no entry in Items, so updates map logical - /// items to entries through this. + /// For a templated FlipView: per logical item, the element it mounted or was last + /// updated to and whether it realized a control. Updates diff against it (as a templated + /// ListView diffs against the element tagged on its container) and map logical items to + /// Items entries through it: an item whose view is Empty has no entry. /// - public List? FlipViewItemHasControl; + public List<(Element Element, bool HasControl)>? MountedItemElements; /// /// For an ItemsHost control: the collection entry each logical item realized @@ -1273,7 +1274,7 @@ internal static void ClearElementTag(FrameworkElement fe) } /// - /// Drops the item maps on a control (a FlipView's realized items, an ItemsHost's entries). + /// Drops the item records on a control (a FlipView's mounted items, an ItemsHost's entries). /// They describe one mount, so a control leaving the tree, to the pool or for good, must not /// keep them. /// @@ -1285,7 +1286,7 @@ internal static void ClearRefreshSnapshots(FrameworkElement fe) internal static void ClearRefreshSnapshots(ReactorState state) { - state.FlipViewItemHasControl = null; + state.MountedItemElements = null; state.ItemsHostEntries = null; } diff --git a/src/Reactor/Core/V1Protocol/TemplatedListLifecycle.cs b/src/Reactor/Core/V1Protocol/TemplatedListLifecycle.cs index 1caa4e17f..1f3bc4b53 100644 --- a/src/Reactor/Core/V1Protocol/TemplatedListLifecycle.cs +++ b/src/Reactor/Core/V1Protocol/TemplatedListLifecycle.cs @@ -196,15 +196,15 @@ internal static WinUI.FlipView MountFlipView(Reconciler reconciler, TemplatedLis // Pre-mount all items — FlipView typically has few items so this is fine. // An item whose view mounts nothing (Empty) has no entry in Items, so the record keeps, // per logical item, whether it did. - var hasControl = new List(el.ItemCount); + var mounted = new List<(Element Element, bool HasControl)>(el.ItemCount); for (int i = 0; i < el.ItemCount; i++) { var itemElement = el.BuildItemView(i); var ctrl = reconciler.Mount(itemElement, requestRerender); if (ctrl is not null) flipView.Items.Add(ctrl); - hasControl.Add(ctrl is not null); + mounted.Add((itemElement, ctrl is not null)); } - Reconciler.GetOrCreateReactorState(flipView).FlipViewItemHasControl = hasControl; + Reconciler.GetOrCreateReactorState(flipView).MountedItemElements = mounted; flipView.SelectionChanged += (s, _) => { @@ -357,23 +357,23 @@ public IEnumerator GetEnumerator() internal static UIElement? UpdateFlipView(Reconciler reconciler, TemplatedListElementBase o, TemplatedListElementBase n, WinUI.FlipView fv, Action requestRerender) { - // FlipView items are pre-mounted directly (no ContainerContentChanging): rebuild each old - // item view from o and reconcile it like a regular item. An item whose view is Empty has - // no control in Items, so walk the controls with their own index, using the record of - // which items realized one. Without a usable record, treat the first Items.Count items as - // the ones holding a control. + // FlipView items are pre-mounted directly (no ContainerContentChanging). Diff each item + // against the element it mounted or was last updated to; a view rebuilt from o reads the + // items' current values, so an item changed in place would compare equal to itself and + // stay stale (a templated ListView likewise diffs against its container's tag). An item whose view is Empty has no control in Items, so walk the + // controls with their own index. Without a usable record, rebuild from o and treat the + // first Items.Count items as the ones holding a control. int oldCount = o.ItemCount; int newCount = n.ItemCount; int shared = Math.Min(oldCount, newCount); var fvState = Reconciler.GetOrCreateReactorState(fv); - var recorded = fvState.FlipViewItemHasControl is { } r && r.Count == oldCount ? r : null; - var nextHasControl = new List(newCount); + var recorded = fvState.MountedItemElements is { } r && r.Count == oldCount ? r : null; + var nextMounted = new List<(Element Element, bool HasControl)>(newCount); int ci = 0; for (int i = 0; i < shared; i++) { - var oldItemElement = o.BuildItemView(i); - bool oldHasControl = recorded?[i] ?? i < fv.Items.Count; + var (oldItemElement, oldHasControl) = recorded?[i] ?? (o.BuildItemView(i), i < fv.Items.Count); var newItemElement = n.BuildItemView(i); if (oldHasControl && ci < fv.Items.Count && fv.Items[ci] is UIElement existingCtrl && reconciler.CanUpdate(oldItemElement, newItemElement)) @@ -382,7 +382,7 @@ public IEnumerator GetEnumerator() if (replacement is not null && replacement != existingCtrl) fv.Items[ci] = replacement; ci++; - nextHasControl.Add(true); + nextMounted.Add((newItemElement, true)); continue; } @@ -393,7 +393,7 @@ public IEnumerator GetEnumerator() } var ctrl = reconciler.Mount(newItemElement, requestRerender); if (ctrl is not null) fv.Items.Insert(ci++, ctrl); - nextHasControl.Add(ctrl is not null); + nextMounted.Add((newItemElement, ctrl is not null)); } // Remove excess: every control past the shared items belongs to a removed item. @@ -410,9 +410,9 @@ public IEnumerator GetEnumerator() var itemElement = n.BuildItemView(i); var ctrl = reconciler.Mount(itemElement, requestRerender); if (ctrl is not null) fv.Items.Add(ctrl); - nextHasControl.Add(ctrl is not null); + nextMounted.Add((itemElement, ctrl is not null)); } - fvState.FlipViewItemHasControl = nextHasControl; + fvState.MountedItemElements = nextMounted; // Issue #495 — drift-guarded, suppressed write. Previously // unconditional, which would write -1 when n.GetSelectedIndex() == -1 // (the default for TemplatedFlipViewElement is 0, so the prior diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/HostIdleAndThemeResourceFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/HostIdleAndThemeResourceFixtures.cs index 19754e2eb..a9569dab8 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/HostIdleAndThemeResourceFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/HostIdleAndThemeResourceFixtures.cs @@ -257,6 +257,8 @@ public override async Task RunAsync() var popupContent = new Counter(); int visualizerWrapperMounts = 0; int flyoutWrapperMounts = 0; + // A FlipView item object changed in place, then shown again in a fresh array. + var flipItem = new FlipItem { Name = "flip-before" }; try { // Disposed at the end of this block, before finally restores the active host. @@ -277,6 +279,7 @@ public override async Task RunAsync() var lvItems = ctx.UseMemo( () => [TextBlock("LvItemProbe").Foreground(Theme.Ref(AppKey)), TextBlock("LvSecond")]); var flipGapItems = ctx.UseMemo>(() => ["gap", "shown"]); + IReadOnlyList flipMutable = [flipItem]; var gvItems = ctx.UseMemo( () => [TextBlock("GvItemProbe").Foreground(Theme.Ref(AppKey))]); var lazyItems = ctx.UseMemo>(() => ["lazy"]); @@ -312,6 +315,7 @@ public override async Task RunAsync() // A popup child whose update always builds a fresh control: one live copy. Popup(ValidationVisualizerDsl.ValidationVisualizer(VisualizerStyle.Inline, popupContent.Probe("PopupContentProbe", AppKey))), + FlipView(flipMutable, static i => "only", static (i, _) => TextBlock(i.Name)), // A FlipView whose first item mounts nothing. FlipView(flipGapItems, static s => s, static (s, _) => s == "gap" ? Empty() @@ -343,9 +347,11 @@ public override async Task RunAsync() if (lazyFactory is not null) lazyFactory.ShouldSkipRefresh = static () => true; resources[AppKey] = new SolidColorBrush(Colors.Blue); + flipItem.Name = "flip-after"; setTick!(1); await host.WaitForIdleAsync(); H.Check("ThemeMemo_Rerendered", FindText(target, "tick:1") is not null); + H.Check("Replace_FlipViewItemChangedInPlaceUpdates", FlipViewHasText(target, "flip-after") && !FlipViewHasText(target, "flip-before")); var replacedBorder = FindText(target, "ReplacedTargetAfter")?.Parent as Border; H.Check("Replace_FlyoutTargetTornDownOnce", replacedTarget.Mounts == 1 && replacedTarget.Cleanups == 1, $"mounts={replacedTarget.Mounts} cleanups={replacedTarget.Cleanups}"); @@ -415,6 +421,19 @@ public override async Task RunAsync() } } + private sealed class FlipItem { public string Name = ""; } + + private static bool FlipViewHasText(DependencyObject root, string text) + { + if (root is FlipView fv && fv.Items.OfType().Any(t => t.Text == text)) return true; + int count = VisualTreeHelper.GetChildrenCount(root); + for (int i = 0; i < count; i++) + { + if (FlipViewHasText(VisualTreeHelper.GetChild(root, i), text)) return true; + } + return false; + } + // A stateful component (an effect that runs once per mount) showing a themed TextBlock. private sealed class Counter { From d91e7b181e888335a872b4b49ccc6f09f00f42cc Mon Sep 17 00:00:00 2001 From: Alexandre Zollinger Chohfi Date: Mon, 19 Oct 2026 13:11:44 -0700 Subject: [PATCH 4/4] Clear the FlipView item record on unmount The record holds item element graphs; an unmounted FlipView an app still holds must not keep them, and FlipView never reaches pool cleanup. Restore main's clear at the top of both unmount traversals and cover it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Reactor/Core/Reconciler.cs | 6 +++++ .../HostIdleAndThemeResourceFixtures.cs | 24 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/Reactor/Core/Reconciler.cs b/src/Reactor/Core/Reconciler.cs index 0dc0d97da..712500e65 100644 --- a/src/Reactor/Core/Reconciler.cs +++ b/src/Reactor/Core/Reconciler.cs @@ -3029,6 +3029,9 @@ internal void CleanupNavigationHostNode(UIElement control) private void UnmountRecursive(UIElement control) { NoteUnmountDuringSlotUpdate(control); + // Before any early exit: an unmounted control an app still holds must not keep a + // FlipView's mounted item elements alive. + if (control is FrameworkElement snapshotFe) ClearRefreshSnapshots(snapshotFe); // Capture connected animation snapshot while element is still in the visual tree if (control is FrameworkElement caFe && GetElementTag(caFe) is Element caEl @@ -3460,6 +3463,9 @@ internal void UnmountAndPool(UIElement control) private void UnmountAndCollect(UIElement control, List toPool) { NoteUnmountDuringSlotUpdate(control); + // Before any early exit: an unmounted control an app still holds must not keep a + // FlipView's mounted item elements alive. + if (control is FrameworkElement snapshotFe) ClearRefreshSnapshots(snapshotFe); // Capture connected animation snapshot while element is still in the visual tree if (control is FrameworkElement caFe && GetElementTag(caFe) is Element caEl diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/HostIdleAndThemeResourceFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/HostIdleAndThemeResourceFixtures.cs index a9569dab8..629312698 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/HostIdleAndThemeResourceFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/HostIdleAndThemeResourceFixtures.cs @@ -280,6 +280,7 @@ public override async Task RunAsync() () => [TextBlock("LvItemProbe").Foreground(Theme.Ref(AppKey)), TextBlock("LvSecond")]); var flipGapItems = ctx.UseMemo>(() => ["gap", "shown"]); IReadOnlyList flipMutable = [flipItem]; + var flipGapItemsHeld = ctx.UseMemo>(() => ["held"]); var gvItems = ctx.UseMemo( () => [TextBlock("GvItemProbe").Foreground(Theme.Ref(AppKey))]); var lazyItems = ctx.UseMemo>(() => ["lazy"]); @@ -316,6 +317,8 @@ public override async Task RunAsync() Popup(ValidationVisualizerDsl.ValidationVisualizer(VisualizerStyle.Inline, popupContent.Probe("PopupContentProbe", AppKey))), FlipView(flipMutable, static i => "only", static (i, _) => TextBlock(i.Name)), + // In its own panel, so removing it doesn't shift the outer positional diff. + VStack(tick < 2 ? FlipView(flipGapItemsHeld, static s => s, static (_, _) => TextBlock("HeldFlipProbe")) : Empty()), // A FlipView whose first item mounts nothing. FlipView(flipGapItems, static s => s, static (s, _) => s == "gap" ? Empty() @@ -411,6 +414,16 @@ public override async Task RunAsync() // await host.WaitForIdleAsync(); H.Check("ThemeMemo_SecondEditIsGreen", ProbeColor(target) == Colors.Green, $"color={ProbeColor(target)}"); + + // A FlipView an app holds after it leaves the tree does not keep its item elements. + var heldFlip = FindFlipViewWithText(target, "HeldFlipProbe"); + bool flipRecorded = heldFlip is not null && Reconciler.TryGetReactorState(heldFlip, out var flipState) + && flipState.MountedItemElements is { Count: > 0 }; + setTick!(2); + await host.WaitForIdleAsync(); + H.Check("Replace_UnmountedFlipViewDropsItemRecord", flipRecorded && FindFlipViewWithText(target, "HeldFlipProbe") is null + && !(Reconciler.TryGetReactorState(heldFlip!, out var heldState) && heldState.MountedItemElements is not null), + $"recorded={flipRecorded}"); } finally { @@ -423,6 +436,17 @@ public override async Task RunAsync() private sealed class FlipItem { public string Name = ""; } + private static FlipView? FindFlipViewWithText(DependencyObject root, string text) + { + if (root is FlipView fv && fv.Items.OfType().Any(t => t.Text == text)) return fv; + int count = VisualTreeHelper.GetChildrenCount(root); + for (int i = 0; i < count; i++) + { + if (FindFlipViewWithText(VisualTreeHelper.GetChild(root, i), text) is { } found) return found; + } + return null; + } + private static bool FlipViewHasText(DependencyObject root, string text) { if (root is FlipView fv && fv.Items.OfType().Any(t => t.Text == text)) return true;