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/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..f58f389d0 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,42 +233,24 @@ 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) + // 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 @@ -297,17 +276,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 @@ -1602,7 +1570,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 (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 @@ -1614,190 +1585,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..712500e65 100644 --- a/src/Reactor/Core/Reconciler.cs +++ b/src/Reactor/Core/Reconciler.cs @@ -929,27 +929,10 @@ 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). + /// 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<(Element Element, bool HasControl)>? MountedItemElements; @@ -1291,9 +1274,9 @@ internal static void ClearElementTag(FrameworkElement fe) } /// - /// 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. + /// 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. /// internal static void ClearRefreshSnapshots(FrameworkElement fe) { @@ -1303,9 +1286,6 @@ internal static void ClearRefreshSnapshots(FrameworkElement fe) internal static void ClearRefreshSnapshots(ReactorState state) { - state.KeyedMemoKey = null; - state.KeyedMemoOutput = null; - state.MoreKeyedMemoOutputs = null; state.MountedItemElements = null; state.ItemsHostEntries = null; } @@ -1361,8 +1341,7 @@ 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. + // 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 / @@ -2275,8 +2254,6 @@ private void TagControl(UIElement control, Element element) { _forceFullRenderActive = false; _resourceRefreshActive = false; - _freshForWrapperMount = null; - _freshMountFromDispatch = null; _hotReloadRetryActive = false; _dirtyAncestorPath?.Clear(); _dirtyPathChildren?.Clear(); @@ -3052,8 +3029,8 @@ 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. + // 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 @@ -3486,8 +3463,8 @@ 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. + // 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 diff --git a/src/Reactor/Core/V1Protocol/TemplatedListLifecycle.cs b/src/Reactor/Core/V1Protocol/TemplatedListLifecycle.cs index d1c394533..1f3bc4b53 100644 --- a/src/Reactor/Core/V1Protocol/TemplatedListLifecycle.cs +++ b/src/Reactor/Core/V1Protocol/TemplatedListLifecycle.cs @@ -358,9 +358,9 @@ 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 + // 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; diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/HostIdleAndThemeResourceFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/HostIdleAndThemeResourceFixtures.cs index 739fa47a7..629312698 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/HostIdleAndThemeResourceFixtures.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/HostIdleAndThemeResourceFixtures.cs @@ -224,14 +224,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 +247,18 @@ 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; + // 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(); + // 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 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; + 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. @@ -283,268 +271,76 @@ 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 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")])]); + IReadOnlyList flipMutable = [flipItem]; + var flipGapItemsHeld = ctx.UseMemo>(() => ["held"]); 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")), - ]); + var lazyItems = ctx.UseMemo>(() => ["lazy"]); + var treeNodes = ctx.UseMemo( + () => [new TreeViewNodeData("TreeRoot", [new TreeViewNodeData("TreeChild")])]); 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. - ComboBox(gapItems, default, null), - ListView(lvItems), - GridView(gvItems), - TreeView(treeNodes), - // Not memoized: the DSL builds fresh but equivalent node arrays every render. - 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("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", () => RenderEachTime(fctx => - { - fctx.UseEffect(() => - { - legacyNestedMounts++; - return () => legacyNestedCleanups++; - }); - return TextBlock("LegacyNestedProbe").Foreground(Theme.Ref(AppKey)); - })), + Memo("legacyNestedInner", () => legacyNested.Probe("LegacyNestedProbe", 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)); - })), + Memo("flyoutInner", () => flyoutTarget.Probe("FlyoutTargetProbe", 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")) + // 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(_ => 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), + .OnMount(_ => flyoutWrapperMounts++), + // 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)), + // 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() - : 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"))), + : TextBlock("FlipGapProbe").Foreground(Theme.Ref(AppKey))), + ComboBox(gapItems, default, null), + ComboBox(listItems, default, null), + ListView(lvItems), + GridView(gvItems), + TreeView(treeNodes), + // Not memoized: the DSL builds fresh but equivalent node arrays every render. + TreeView(new TreeViewNodeData("FreshRoot", [new TreeViewNodeData("FreshChild")])), + // Node content that switches to and from Empty with the resource. + ContentTogglingTree(AppKey), 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")}"); @@ -554,183 +350,62 @@ 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("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. + 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}"); + 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"); 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_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, + $"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. // @@ -740,20 +415,15 @@ 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}"); + // 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 { @@ -764,6 +434,47 @@ 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; + 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 + { + 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; @@ -773,6 +484,24 @@ public override async Task RunAsync() => (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; @@ -784,8 +513,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 +558,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 +580,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) {