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)
{