diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d729a0ac..10d79134e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -380,6 +380,36 @@ Conventions for contributors: ### Fixed +- **Virtualized list rows now see their ancestors' `Context`** (issue #1386). Rows of + `LazyVStack` / `LazyHStack`, `ItemsRepeater`, `ItemsView`, `ListView` / `GridView` + (typed and untyped) and `TreeView` are built by WinUI during layout, after the + reconciler has finished walking the tree, so `UseContext` in a row returned the + context's default value, and hooks built on it misbehaved: child-mode + `UseNavigation()` threw "found no ancestor NavigationHost", `UseIntl()` ignored a + `LocaleProvider`, and `UseResource` ignored an enclosing `Pending`. A re-render of the + parent temporarily fixed the visible rows, but rows that scrolled in afterwards were + wrong again. Each list now saves its ancestors' provided values when it is reconciled + and restores them while building a row, so rows see the nearest `.Provide(...)` on first + display and while scrolling. Rows that previously read a default will now read the + provided value. Lists built by a custom handler on `ElementFactory` are covered too; + a custom handler that builds rows itself from `ContainerContentChanging` is not. + Removing the only `.Provide(...)` above a reference-stable consumer now also re-renders + that consumer with the default, which it previously missed. +- **Building a list row in the middle of another update no longer corrupts that update** + (issue #1386). WinUI can ask a virtualized list for rows synchronously while Reactor is + still applying an unrelated update, for example when an effect or a control's own + property setter calls `UpdateLayout()`. Such rows used to join that update's + bookkeeping: + - If the update was mounting an `ErrorBoundary` whose child then failed, the boundary's + rollback tore down those rows too: they stayed on screen with their effects cleaned + up, so they stopped updating. + - A row that threw was reported to that unrelated boundary, which then showed its + fallback. + + Row building now sets the surrounding update's state aside and restores the list's own + context and theme, so a failing row shows its own error and a failing boundary only + rolls back its own subtree. (Window hooks in such rows are covered by the + window-owner fix below.) - **Wrapped third-party controls now get source-map call sites** (spec 010). A factory emitted by `[GenerateReactorWrapper]` (`SettingsCard(...)`, `RadialGauge(...)`) is invisible to the source-map generator, because no source generator sees another's @@ -527,7 +557,6 @@ Conventions for contributors: for close as issue #537 requires. Embedded content that needs its window can pass it explicitly (`UseWindowSize(window)`, `UseBreakpoint(window, minWidth)`) or read `XamlRoot.RasterizationScale` for DPI. - - **Replacing a host's root no longer leaves the old root running** (issue #1326). `ReactorHost.Mount(...)` and `ReactorHostControl.Mount(...)` now retire the outgoing root before installing the new one: its effect cleanups run, and a diff --git a/docs/guide/architecture-overview.md b/docs/guide/architecture-overview.md index 987b021c3..bbee837e5 100644 --- a/docs/guide/architecture-overview.md +++ b/docs/guide/architecture-overview.md @@ -155,6 +155,8 @@ public UIElement GetElement(ElementFactoryGetArgs args) if (index < 0 || index >= _items.Count) return new TextBlock { Text = "" }; + using var restoreContext = _reconciler.EnterRealization(_hostCapture ?? _ownCapture, args.Parent); + var item = _items[index]; var element = BuildOrCache(key, item, index, keyed); TryTakeCompatibleFromPool(element, out var reused, out var oldElement, out var parkedVisibility); diff --git a/src/Reactor/Core/ContextScope.cs b/src/Reactor/Core/ContextScope.cs index 21ae17ad0..480d5319f 100644 --- a/src/Reactor/Core/ContextScope.cs +++ b/src/Reactor/Core/ContextScope.cs @@ -1,3 +1,5 @@ +using Microsoft.UI.Xaml; + namespace Microsoft.UI.Reactor.Core; /// @@ -7,13 +9,14 @@ namespace Microsoft.UI.Reactor.Core; /// internal sealed class ContextScope { - private readonly List<(ContextBase Context, object? Value)> _stack = new(); + private List<(ContextBase Context, object? Value)> _stack = new(); private long _version; internal void Push(IReadOnlyDictionary values) { foreach (var (ctx, val) in values) _stack.Add((ctx, val)); + if (values.Count > 0) _everHadValues = true; _version++; } @@ -48,7 +51,206 @@ internal T Read(Context context) return context.DefaultValueBoxed; } - internal bool HasActiveValues => _stack.Count > 0; + internal bool HasActiveValues => _stack.Count > 0 || (_replayDepth > 0 && _everHadValues); + + // A replayed snapshot (Replace) can be EMPTY because the host's last provider was + // removed, while the row being realized was built under that provider. With an empty + // stack HasActiveValues would disable every context skip check, so a reused row that + // is shallow-equal would keep the removed value. While a replay is active, keep the + // checks on — but only once this scope has ever seen a provider, so provider-free + // apps pay nothing during row realization. + private int _replayDepth; + private bool _everHadValues; internal long Version => _version; + + /// + /// Advances whenever an element's own provided values change between renders (a + /// provider added, removed or re-valued). A host snapshot records the generation it + /// was taken at; a mismatch means some provider changed since, and the snapshot is + /// re-taken the next time the reconciler reaches the host. Unlike a check scoped to + /// the pass that removed the provider, this survives until the host is reached — + /// e.g. a nested host inside a recycled row that comes back in a later pass. + /// + internal long ProviderGeneration { get; private set; } + + internal void BumpProviderGeneration() + { + var before = ProviderGeneration++; + if (_replayGenerations.Count == 0) return; + // A provider changed inside a replayed subtree (e.g. a reused row dropped its own + // .Provide): the replayed stack changed, so snapshots taken under it before the + // change are no longer current. A replay that was current moves to the new live + // generation; one that was already stale gets a value no live generation can + // ever equal, so nothing captured from it is treated as current afterwards. + int top = _replayGenerations.Count - 1; + _replayGenerations[top] = _replayGenerations[top] == before ? ProviderGeneration : _staleReplaySeq--; + } + + // Distinct negative generations for stale replays that saw a provider change. + // Live generations are non-negative and ContextCapture uses long.MinValue for "none". + private long _staleReplaySeq = -1; + + // Generations of the snapshots currently being replayed (innermost last). While a + // replay is active the live stack IS that snapshot, so a nested host captured from it + // must record the snapshot's generation — not the current one, which would mark + // possibly-outdated replayed values as current. + private readonly List _replayGenerations = new(); + + /// + /// The generation the live stack's contents correspond to: the replayed snapshot's + /// while a replay is active, otherwise . + /// + internal long SnapshotGeneration + => _replayGenerations.Count > 0 ? _replayGenerations[^1] : ProviderGeneration; + + internal (ContextBase Context, object? Value)[] Snapshot() + => _stack.Count == 0 ? [] : _stack.ToArray(); + + /// + /// True when the live stack holds exactly : same contexts, + /// same value instances. Lets a refresh keep the existing snapshot array instead of + /// allocating a new one on every reconcile of the host. + /// + internal bool Matches((ContextBase Context, object? Value)[] snapshot) + { + if (_stack.Count != snapshot.Length) return false; + for (int i = 0; i < _stack.Count; i++) + { + if (!ReferenceEquals(_stack[i].Context, snapshot[i].Context) + || !ReferenceEquals(_stack[i].Value, snapshot[i].Value)) + return false; + } + return true; + } + + /// + /// True when the live stack is a positional prefix of + /// (same contexts, equal values). The traversal pushes providers along the path, + /// so at any ancestor of the node that took the snapshot the live stack is a + /// prefix of it unless a provider at or above that ancestor changed value or was + /// added. A removed provider leaves a shorter stack that is still a prefix, + /// so callers must detect removal separately (see + /// ). + /// + internal bool IsPrefixOf((ContextBase Context, object? Value)[] snapshot) + { + if (_stack.Count > snapshot.Length) return false; + for (int i = 0; i < _stack.Count; i++) + { + if (!ReferenceEquals(_stack[i].Context, snapshot[i].Context) + || !Equals(_stack[i].Value, snapshot[i].Value)) + return false; + } + return true; + } + + /// + /// Replaces the live stack with and returns the stack it + /// displaced, which the caller must hand back to . Replacing + /// (rather than pushing on top) keeps a re-entrant realization from observing the + /// providers of whatever subtree the traversal happened to be in. + /// + internal List<(ContextBase Context, object? Value)> Replace((ContextBase Context, object? Value)[] snapshot) + => Replace(snapshot, SnapshotGeneration); + + /// + /// Replaces the live stack with , recording the provider + /// generation it was taken at (see ), and returns the + /// displaced stack for . Replaying an outdated snapshot gets a + /// fresh token that no live generation and no other replay can ever equal, so a + /// snapshot taken from it is never treated as current — not after the replay, and not + /// by a later replay of the same outdated snapshot. + /// + /// The provider entries to replay. + /// The was taken at. + internal List<(ContextBase Context, object? Value)> Replace((ContextBase Context, object? Value)[] snapshot, long generation) + { + var previous = _stack; + _stack = new List<(ContextBase Context, object? Value)>(snapshot); + _replayGenerations.Add(generation == ProviderGeneration ? generation : _staleReplaySeq--); + _replayDepth++; + _version++; + return previous; + } + + internal void Restore(List<(ContextBase Context, object? Value)> previous) + { + _stack = previous; + _replayGenerations.RemoveAt(_replayGenerations.Count - 1); + _replayDepth--; + _version++; + } +} + +/// +/// The provider stack as it stood when the reconciler last reconciled a virtualized +/// host (LazyVStack, ItemsView, ListView, TreeView, …). Those hosts realize rows from +/// WinUI callbacks — IElementFactory.GetElement, ContainerContentChanging — +/// that run during layout, outside the traversal that maintains , +/// so the row mount replays this snapshot instead of reading whatever the live stack +/// holds at that moment. +/// +internal sealed class ContextCapture +{ + internal (ContextBase Context, object? Value)[] Entries { get; private set; } = []; + + /// + /// Type of the element whose handler registered this capture, or null when it was + /// registered outside element dispatch (then any element sharing the control may + /// refresh it). Decorators and transparent wrappers that share the host's control + /// are a different type, so they do not overwrite the snapshot at the wrong depth. + /// + internal Type? OwnerType { get; init; } + + internal bool IsOwnedBy(Element element) => OwnerType is null || OwnerType == element.GetType(); + + /// when was taken. + internal long Generation { get; private set; } = long.MinValue; + + internal void Refresh(ContextScope scope) + { + if (!scope.Matches(Entries)) + Entries = scope.Snapshot(); + Generation = scope.SnapshotGeneration; + } + + /// Reconcile pass in which the host was last reached (see ). + internal long PassId { get; private set; } = -1; + + /// + /// The subtree theme the host was reached with in pass . Only + /// meaningful while that pass is still in flight: afterwards the host's native + /// ActualTheme is authoritative and is read live instead. + /// + internal ElementTheme PassTheme { get; private set; } + + /// + /// The reconciler's theme generation when the host was last reached. An ancestor's + /// RequestedTheme change advances it, so a skipped host is detected as needing + /// to re-record its subtree theme (see ). + /// + internal long ThemeGeneration { get; private set; } = long.MinValue; + + internal void NotePass(long passId, ElementTheme theme, long themeGeneration) + { + PassId = passId; + PassTheme = theme; + ThemeGeneration = themeGeneration; + } + + /// + /// Releases the snapshot's provider values. The capture object can be aliased (an + /// holds the same instance), so a host leaving Reactor's + /// ownership empties it rather than only dropping its own reference. + /// + internal void Clear() + { + Entries = []; + Generation = long.MinValue; + } + + /// The live stack at an ancestor of (or at) the host. + internal bool IsStale(ContextScope scope) + => Generation != scope.SnapshotGeneration || !scope.IsPrefixOf(Entries); } diff --git a/src/Reactor/Core/ElementFactory.cs b/src/Reactor/Core/ElementFactory.cs index 01956f3b0..559a7fcb5 100644 --- a/src/Reactor/Core/ElementFactory.cs +++ b/src/Reactor/Core/ElementFactory.cs @@ -17,13 +17,36 @@ namespace Microsoft.UI.Reactor.Core; /// because the dictionary's int keys no longer matched the repeater's /// new positions. Keying by string makes the mapping reorder-stable. /// -public sealed partial class ElementFactory : IElementFactory +public sealed partial class ElementFactory : IElementFactory, IContextCapturingFactory { private IReadOnlyList _items; private Func _viewBuilder; private readonly Reconciler _reconciler; private readonly Action _requestRerender; private readonly ElementPool? _pool; + + // The host's provider-stack snapshot. GetElement runs from WinUI layout, + // outside the traversal that maintains the reconciler's context stack, so + // rows replay this instead (see ContextCapture). Built-in hosts attach the + // capture registered on their control, which the reconciler also refreshes + // when the host is skipped. A factory installed by an out-of-tree handler has + // none attached and falls back to _ownCapture, which it takes itself at + // construction and in UpdateInPlace — both run inside the host's Mount/Update, + // where the live stack is the host's. Attaching a host capture drops the + // fallback so it cannot pin mount-time provider values for the list's lifetime. + private ContextCapture? _hostCapture; + private ContextCapture? _ownCapture; + + ContextCapture? IContextCapturingFactory.ContextCapture + { + get => _hostCapture; + set + { + _hostCapture = value; + if (value is not null) + _ownCapture = null; + } + } // Optional state used when ItemsSource is the OC path // (spec 042). Lets GetElement translate an ItemsRepeater realized // index → stable key for _mountedElements lookup. Null when running @@ -288,6 +311,7 @@ public ElementFactory( _reconciler = reconciler; _requestRerender = requestRerender; _pool = pool; + _ownCapture = reconciler.CaptureLiveContext(); } /// @@ -301,6 +325,8 @@ internal void UpdateInPlace(IReadOnlyList items, Func viewBu { _items = items; _viewBuilder = viewBuilder; + if (_ownCapture is not null) + _reconciler.RefreshLiveContext(_ownCapture); // A new viewBuilder closure may capture different external state (UseState // cells, Observable subscriptions, theme, etc.) than the one that produced the // cached entries. We can't see through @@ -603,6 +629,8 @@ public UIElement GetElement(ElementFactoryGetArgs args) if (index < 0 || index >= _items.Count) return new TextBlock { Text = "" }; + using var restoreContext = _reconciler.EnterRealization(_hostCapture ?? _ownCapture, args.Parent); + var item = _items[index]; var element = BuildOrCache(key, item, index, keyed); TryTakeCompatibleFromPool(element, out var reused, out var oldElement, out var parkedVisibility); diff --git a/src/Reactor/Core/IItemsRepeaterFactorySource.cs b/src/Reactor/Core/IItemsRepeaterFactorySource.cs index 8eb42fec4..b31d757c9 100644 --- a/src/Reactor/Core/IItemsRepeaterFactorySource.cs +++ b/src/Reactor/Core/IItemsRepeaterFactorySource.cs @@ -67,3 +67,13 @@ internal interface IItemsRepeaterFactorySource /// void RefreshRealizedItems(IElementFactory factory, WinUI.ItemsRepeater repeater); } + +/// +/// An that replays its host's provider stack while +/// realizing rows (see ). Hosts assign the capture via +/// whenever they install a factory. +/// +internal interface IContextCapturingFactory +{ + ContextCapture? ContextCapture { get; set; } +} diff --git a/src/Reactor/Core/Reconciler.KeyedItemsBinding.cs b/src/Reactor/Core/Reconciler.KeyedItemsBinding.cs index cde856b4d..9485c1b11 100644 --- a/src/Reactor/Core/Reconciler.KeyedItemsBinding.cs +++ b/src/Reactor/Core/Reconciler.KeyedItemsBinding.cs @@ -180,6 +180,7 @@ private void BindItemsRepeaterErasedKeyedItems( ir.ItemsSource = state.Source; var factory = factorySource.CreateFactory(this, requestRerender, _pool); factorySource.AttachListStateToFactory(factory, state); + AttachContextCapture(factory, ir); ir.ItemTemplate = factory; return; } @@ -203,6 +204,7 @@ private void BindItemsRepeaterErasedKeyedItems( ir.ItemsSource = fresh.Source; var factory = factorySource.CreateFactory(this, requestRerender, _pool); factorySource.AttachListStateToFactory(factory, fresh); + AttachContextCapture(factory, ir); ir.ItemTemplate = factory; } } @@ -267,6 +269,7 @@ private void BindItemsViewErasedKeyedItems( iv.ItemsSource = state.Source; var factory = itemsViewSource.CreateFactory(this, requestRerender, _pool); itemsViewSource.AttachListStateToFactory(factory, state); + AttachContextCapture(factory, iv); iv.ItemTemplate = factory; return; } @@ -286,6 +289,7 @@ private void BindItemsViewErasedKeyedItems( iv.ItemsSource = fresh.Source; var replacementFactory = itemsViewSource.CreateFactory(this, requestRerender, _pool); itemsViewSource.AttachListStateToFactory(replacementFactory, fresh); + AttachContextCapture(replacementFactory, iv); iv.ItemTemplate = replacementFactory; } @@ -331,6 +335,7 @@ private void BindListViewBaseErasedKeyedItems( { if (isMount) { + CaptureContextForHost(lvb); lvb.ItemTemplate = SharedContentControlTemplate.Value; lvb.ContainerContentChanging += (sender, args) => HandleTemplatedContainerContentChanging(sender, args, requestRerender); @@ -478,6 +483,7 @@ private void BindListViewBaseKeyedItems( // body is shared 1:1 with the legacy path so existing CCC // semantics (recycle teardown, attached-tag bookkeeping, // ambient-enter animation) come for free. + CaptureContextForHost(lvb); lvb.ItemTemplate = SharedContentControlTemplate.Value; lvb.ContainerContentChanging += (sender, args) => HandleTemplatedContainerContentChanging(sender, args, requestRerender); diff --git a/src/Reactor/Core/Reconciler.Mount.cs b/src/Reactor/Core/Reconciler.Mount.cs index b4b76996f..110473788 100644 --- a/src/Reactor/Core/Reconciler.Mount.cs +++ b/src/Reactor/Core/Reconciler.Mount.cs @@ -77,8 +77,12 @@ public sealed partial class Reconciler bool pushedStagger = element.StaggerConfig is not null; if (pushedStagger) PushStaggerScope(element.StaggerConfig!.Delay); + var prevReconcilingElement = _reconcilingElement; try { + // The innermost element being mounted owns any virtualized-host context capture + // its handler registers (see CaptureContextForHost). + _reconcilingElement = element; // Publish the effective theme for the subtree — INSIDE the try so the finally // restores it on every exit (including an exception during child mount below). // Children mounted inside MountXxx and this element's own ApplyResourceOverrides @@ -251,6 +255,7 @@ public sealed partial class Reconciler } finally { + _reconcilingElement = prevReconcilingElement; if (pushedStagger) PopStaggerScope(); if (ctxCount > 0) @@ -711,6 +716,9 @@ internal void HandleTemplatedContainerContentChanging(object sender, ContainerCo if (viewSource is not null && args.ItemIndex >= 0 && args.ItemIndex < viewSource.ItemCount && args.ItemContainer.ContentTemplateRoot is ContentControl cc) { + // Realization runs from layout, outside the traversal — replay the + // host's provider stack so the row sees its ancestors' context. + using var realization = EnterRealization((UIElement)sender!); using var realizationTheme = EnterRealizationTheme(args.ItemContainer); // WinUI can re-realize a container without first raising the recycle event // for its previous row; unmount that row's view or its components leak. diff --git a/src/Reactor/Core/Reconciler.Update.cs b/src/Reactor/Core/Reconciler.Update.cs index 94a7d7791..3afd36a5f 100644 --- a/src/Reactor/Core/Reconciler.Update.cs +++ b/src/Reactor/Core/Reconciler.Update.cs @@ -131,6 +131,10 @@ public sealed partial class Reconciler if ((HasGestureOrDragSlots(modifiers) || HasGestureOrDragSlots(oldModifiers)) && control is FrameworkElement gestFeSE) RefreshGestureDragStateOnSkip(gestFeSE, oldModifiers, modifiers); + // A virtualized host's own skipped element re-snapshots here at its own depth + // (no-op for every other control or a decorator sharing it, and + // allocation-free when nothing changed). + RefreshContextCaptureOnSkip(control, newEl, effectiveTheme); // No validation refresh here, deliberately (issue #1262). A `.Validate()` // verdict is republished only by a component that re-rendered, and that // component's output is on the dirty ancestor path, which this arm declines @@ -178,6 +182,21 @@ public sealed partial class Reconciler _contextScope.Push(ctxValues); ctxCount = ctxValues.Count; } + bool providerChanged = !Element.ContextValuesEqual(oldEl.ContextValues, ctxValues); + if (providerChanged) + EnterProviderChange(); + bool themeChanged = (oldModifiers?.RequestedTheme ?? ElementTheme.Default) + != (modifiers?.RequestedTheme ?? ElementTheme.Default); + if (themeChanged) + EnterThemeChange(); + + // Virtualized hosts realize rows outside this traversal; keep their provider + // snapshot current (no-op for every other control, and for a decorator that + // shares the host's control — the host element's own Update refreshes it). + RefreshContextCapture(control, newEl, effectiveTheme); + + var prevReconcilingElement = _reconcilingElement; + _reconcilingElement = newEl; UIElement? result; bool registeredType = false; @@ -378,6 +397,11 @@ public sealed partial class Reconciler } finally { + _reconcilingElement = prevReconcilingElement; + if (themeChanged) + ExitThemeChange(); + if (providerChanged) + ExitProviderChange(); if (ctxCount > 0) _contextScope.Pop(ctxCount); _ambientRequestedTheme = prevAmbientTheme; diff --git a/src/Reactor/Core/Reconciler.cs b/src/Reactor/Core/Reconciler.cs index d0919be5f..62978fbf6 100644 --- a/src/Reactor/Core/Reconciler.cs +++ b/src/Reactor/Core/Reconciler.cs @@ -451,10 +451,19 @@ private void RollBackDiscardedBoundaryMount(BoundaryMountRecord record, UIElemen private EntryFrame EnterFrame() { var scope = _entryDepth == 0 ? RenderErrorDispatch.EnterPropagationScope() : default; + if (_entryDepth == 0) + _passId++; _entryDepth++; return new EntryFrame(this, scope); } + // Identifies the outermost Reconcile/Mount pass in flight (advanced on entry at depth + // 0). A virtualized host records the pass and the subtree theme it was reached with, + // so a row realized re-entrantly later in the SAME pass can use that pending theme: + // the native RequestedTheme modifier is only applied after children are dispatched, + // so the host's ActualTheme still shows the previous theme at that point. + private long _passId; + private readonly struct EntryFrame(Reconciler owner, RenderErrorDispatch.PropagationScope scope) : IDisposable { public void Dispose() @@ -1563,6 +1572,11 @@ internal sealed class ReactorState // abstract base. public Internal.IItemViewSource? ItemViewSource; + // Provider stack snapshot for a virtualized host whose rows are realized + // from WinUI layout callbacks outside the reconciler traversal (see + // ContextCapture). Lives here rather than in a wrapper-keyed CWT so the + // realization callback finds it through any managed wrapper of the control. + public ContextCapture? ContextCapture; // Spec 047 §9.2 / §14 Phase 1 (1.7) — per-control event payload box. // Holds a strongly-typed payload struct (e.g. ToggleSwitchEventPayload) // discriminated by HandlerType. Populated for ported V1 controls and @@ -2039,6 +2053,11 @@ internal static void ClearCurrentEventHandlers(FrameworkElement fe) state.EchoSuppressScopeDepth = 0; state.PendingEchoMatch = null; state.PendingLabeledBy = null; + // A pooled host must not hand its provider values (and the objects they + // reference) to an unrelated renter. Empty the capture before dropping it: + // the host's ElementFactory aliases the same instance. + state.ContextCapture?.Clear(); + state.ContextCapture = null; } } @@ -2088,6 +2107,11 @@ public static void DetachReactorState(FrameworkElement fe) state.ValidationRootBinding = null; state.ValidationRuleBinding = null; state.ValidationAttachedBinding = null; + // A retired host's native subtree (and its ItemTemplate factory, which aliases + // this capture) can outlive Reactor ownership — e.g. a nested list inside a + // parked repeater row — so release the provider values, not just the reference. + state.ContextCapture?.Clear(); + state.ContextCapture = null; } /// @@ -2744,7 +2768,258 @@ internal IDisposable PushContextDisposable(Context context, T value) // without exposing the traversal stack itself. internal T ReadContext(Context context) => _contextScope.Read(context); - internal bool HasActiveContextValues => _contextScope.HasActiveValues; + internal bool HasActiveContextValues => _contextScope.HasActiveValues || _providerChangeDepth > 0 || _themeChangeDepth > 0; + + // Depth of non-skip Updates, on the current traversal path, whose element's own + // ContextValues differ from the previous render's (a provider added, removed or + // changed). Removing the LAST provider leaves the live stack empty, which used to + // short-circuit every context skip check (HasActiveContextValues), so a + // reference-stable consumer below kept the removed value. Host snapshots use the + // longer-lived ContextScope.ProviderGeneration instead (bumped alongside). + private int _providerChangeDepth; + + internal void EnterProviderChange() + { + _providerChangeDepth++; + _contextScope.BumpProviderGeneration(); + } + + internal void ExitProviderChange() => _providerChangeDepth--; + + // Same idea for RequestedTheme: depth of non-skip Updates on the current path whose + // own RequestedTheme changed, plus a generation bumped alongside. A virtualized host + // records the generation when it is reached; a host skipped below a theme change + // would keep the previous pass's subtree theme, so the skip check declines it once + // and its Update re-records the pending theme for rows realized later in the pass. + private int _themeChangeDepth; + private long _themeGeneration; + + internal void EnterThemeChange() + { + _themeChangeDepth++; + _themeGeneration++; + } + + internal void ExitThemeChange() => _themeChangeDepth--; + + // ── Context replay for virtualized realization ───────────────────── + // + // ItemsRepeater (IElementFactory.GetElement), ListViewBase + // (ContainerContentChanging) and the typed TreeView build row content from + // WinUI layout callbacks. Those run after the traversal that maintains + // _contextScope has unwound (first realization, scrolling) or re-entrantly in + // the middle of an unrelated subtree's update (a sibling's synchronous + // UpdateLayout), so the live stack is either empty or someone else's. Each + // such host captures the stack whenever it is reconciled, and the realization + // callbacks replay that capture around the row mount (see EnterRealization). + + // Gates the per-control capture lookups so apps without a virtualized host + // never pay the attached-state read. + private bool _hasContextCaptures; + + // The innermost element currently in Mount/Update dispatch. A capture registered + // by a host handler records this element's type as its owner: a decorator + // (Flyout, MenuFlyout, …) or a transparent wrapper shares the host's control but + // sits at a shallower depth, so only the owner's own Update/skip may re-snapshot. + private Element? _reconcilingElement; + + private static bool MayHostContextCapture(UIElement control) + => control is WinUI.ScrollViewer or WinUI.ItemsRepeater or WinUI.ItemsView + or WinUI.ListViewBase or WinUI.TreeView; + + /// + /// Registers as a virtualized host and snapshots the live + /// provider stack onto it. Call from the host's Mount, while the traversal is at + /// the host; the generic Update path refreshes the snapshot from then on. + /// + internal ContextCapture CaptureContextForHost(FrameworkElement host) + { + global::System.Diagnostics.Debug.Assert(MayHostContextCapture(host), + $"{host.GetType().Name} is not covered by MayHostContextCapture; its capture would never refresh."); + var state = GetOrCreateReactorState(host); + var capture = state.ContextCapture ??= new ContextCapture { OwnerType = _reconcilingElement?.GetType() }; + capture.Refresh(_contextScope); + capture.NotePass(_passId, _ambientRequestedTheme, _themeGeneration); + _hasContextCaptures = true; + return capture; + } + + private ContextCapture? TryGetContextCapture(UIElement control) + => _hasContextCaptures + && MayHostContextCapture(control) + && ((FrameworkElement)control).GetValue(ReactorAttached.StateProperty) is ReactorState { ContextCapture: { } capture } + ? capture + : null; + + /// + /// Re-snapshots a host's provider stack when is the element that + /// owns the capture; a no-op for any other control or element. + /// + internal void RefreshContextCapture(UIElement control, Element newEl, ElementTheme effectiveTheme) + { + if (TryGetContextCapture(control) is { } capture && capture.IsOwnedBy(newEl)) + { + capture.Refresh(_contextScope); + capture.NotePass(_passId, effectiveTheme, _themeGeneration); + } + } + + /// + /// Skip-arm counterpart of . The skip arm runs + /// before the element's own ContextValues are pushed, so they are pushed here + /// for the snapshot. A decorator sharing the host's control is not the owner, so a + /// skipped decorator leaves the host's snapshot (taken at the host's own depth) + /// untouched; if a provider above it changed, the stale check declines that skip and + /// the decorator's Update reaches the host element. + /// + internal void RefreshContextCaptureOnSkip(UIElement control, Element newEl, ElementTheme effectiveTheme) + { + if (TryGetContextCapture(control) is not { } capture || !capture.IsOwnedBy(newEl)) return; + capture.NotePass(_passId, effectiveTheme, _themeGeneration); + var own = newEl.ContextValues; + if (own is not { Count: > 0 }) + { + capture.Refresh(_contextScope); + return; + } + _contextScope.Push(own); + try { capture.Refresh(_contextScope); } + finally { _contextScope.Pop(own.Count); } + } + + /// + /// A standalone snapshot of the live provider stack, for an + /// whose host never registered one (an out-of-tree handler installing the factory). + /// + internal ContextCapture CaptureLiveContext() + { + var capture = new ContextCapture(); + capture.Refresh(_contextScope); + capture.NotePass(_passId, _ambientRequestedTheme, _themeGeneration); + return capture; + } + + internal void RefreshLiveContext(ContextCapture capture) + { + capture.Refresh(_contextScope); + capture.NotePass(_passId, _ambientRequestedTheme, _themeGeneration); + } + + /// + /// Opens a realization frame for building a row outside (or re-entrantly inside) the + /// traversal. Until the returned scope is disposed: + /// + /// the live provider stack is replaced with , + /// and the ambient theme is read live off (see + /// ); + /// the in-flight pass's per-subtree bookkeeping is set aside, so the + /// row is not recorded in an ErrorBoundary's rollback lists (a failing boundary child + /// would otherwise tear down a live row in an unrelated list), a row that throws gets + /// its own in-tree fallback instead of escaping into WinUI layout, and stagger, + /// provider/theme-change state and the diagnostic owner of the surrounding subtree + /// do not leak in. + /// + /// Window hooks need nothing here: they read , which every component + /// render receives regardless of when it runs. + /// A null capture still isolates; it just leaves the provider stack as it is. + /// + internal RealizationFrame EnterRealization(ContextCapture? capture, UIElement? host) + => new(this, capture, RealizationTheme(capture, host)); + + /// + internal RealizationFrame EnterRealization(UIElement host) => EnterRealization(TryGetContextCapture(host), host); + + // Inside the pass that last reached the host, the theme that pass is applying to the + // host's subtree (its native RequestedTheme modifier lands only after children are + // dispatched); otherwise the host's live inherited RequestedTheme. + private ElementTheme RealizationTheme(ContextCapture? capture, UIElement? host) + => capture is not null && _entryDepth > 0 && capture.PassId == _passId + ? capture.PassTheme + : LiveAmbientTheme(host); + + /// + /// The theme a row realized into should resolve ThemeRef + /// resource overrides against. Read from the host's visual tree rather than captured at + /// reconcile time, so an ancestor RequestedTheme that changed while the host was + /// skipped is still seen. Uses the same rule as (the + /// nearest explicit RequestedTheme, or Default), so a row's recorded realization + /// theme compares equal whichever path built it. + /// + private static ElementTheme LiveAmbientTheme(UIElement? host) => InheritedRequestedTheme(host); + + /// + /// Registers (see ) and + /// hands its capture to so GetElement replays it. + /// + internal void AttachContextCapture(IElementFactory factory, FrameworkElement host) + { + if (factory is IContextCapturingFactory capturing) + capturing.ContextCapture = CaptureContextForHost(host); + } + + internal readonly struct RealizationFrame : IDisposable + { + private readonly Reconciler _owner; + private readonly List<(ContextBase Context, object? Value)>? _previousStack; + private readonly ElementTheme _previousTheme; + private readonly List? _previousJournal; + private readonly List? _previousRoots; + private readonly int _previousBoundaryDepth; + private readonly int _previousProviderChangeDepth; + private readonly int _previousThemeChangeDepth; + private readonly Element? _previousReconcilingElement; + private readonly StaggerScope? _previousStagger; + private readonly string? _previousDiagOwner; + private readonly string? _previousDiagRootOwner; + + internal RealizationFrame(Reconciler owner, ContextCapture? capture, ElementTheme ambientTheme) + { + _owner = owner; + _previousTheme = owner._ambientRequestedTheme; + _previousJournal = owner._boundaryMountJournal; + _previousRoots = owner._boundaryMountRoots; + _previousBoundaryDepth = owner._errorBoundaryDepth; + _previousProviderChangeDepth = owner._providerChangeDepth; + _previousThemeChangeDepth = owner._themeChangeDepth; + _previousReconcilingElement = owner._reconcilingElement; + _previousStagger = _staggerScope; + _previousDiagOwner = owner._diagOwner; + _previousDiagRootOwner = owner.DiagnosticRootOwner; + + owner._boundaryMountJournal = null; + owner._boundaryMountRoots = null; + owner._errorBoundaryDepth = 0; + owner._providerChangeDepth = 0; + owner._themeChangeDepth = 0; + owner._reconcilingElement = null; + owner._ambientRequestedTheme = ambientTheme; + // A row built re-entrantly is not part of the component being rendered: report + // its owner as unknown, as for any row realized during layout. + owner._diagOwner = null; + owner.DiagnosticRootOwner = null; + _staggerScope = null; + + _previousStack = capture is not null ? owner._contextScope.Replace(capture.Entries, capture.Generation) : null; + } + + public void Dispose() + { + var owner = _owner; + if (owner is null) return; + if (_previousStack is not null) + owner._contextScope.Restore(_previousStack); + owner._ambientRequestedTheme = _previousTheme; + owner._boundaryMountJournal = _previousJournal; + owner._boundaryMountRoots = _previousRoots; + owner._errorBoundaryDepth = _previousBoundaryDepth; + owner._providerChangeDepth = _previousProviderChangeDepth; + owner._themeChangeDepth = _previousThemeChangeDepth; + owner._reconcilingElement = _previousReconcilingElement; + owner._diagOwner = _previousDiagOwner; + owner.DiagnosticRootOwner = _previousDiagRootOwner; + _staggerScope = _previousStagger; + } + } /// /// Spec 047 §14 Phase 1 (1.6) — push a stagger scope for child enter @@ -3648,6 +3923,13 @@ internal bool HasConsumedContextChangedInSubtree(UIElement control) && HasConsumedContextChanged(node)) return true; + // A virtualized host has no realized consumer for rows it has yet to realize, + // so its own snapshot stands in for them: if the providers above it changed, + // decline the skip so the Update path re-snapshots before the next realization. + if (TryGetContextCapture(control) is { } capture + && (capture.IsStale(_contextScope) || capture.ThemeGeneration != _themeGeneration)) + return true; + bool found = false; ForEachReactorChildControl(control, child => { diff --git a/src/Reactor/Core/V1Protocol/Handlers/GridViewHandler.cs b/src/Reactor/Core/V1Protocol/Handlers/GridViewHandler.cs index 56af7229a..6cce51909 100644 --- a/src/Reactor/Core/V1Protocol/Handlers/GridViewHandler.cs +++ b/src/Reactor/Core/V1Protocol/Handlers/GridViewHandler.cs @@ -36,6 +36,7 @@ public WinUI.GridView Mount(MountContext ctx, GridViewElement gv) if (gv.ItemContainerStyle is not null) gridView.ItemContainerStyle = gv.ItemContainerStyle; Reconciler.SetElementTag(gridView, gv); + reconciler.CaptureContextForHost(gridView); gridView.ItemTemplate = Reconciler.SharedContentControlTemplate.Value; @@ -58,6 +59,7 @@ public WinUI.GridView Mount(MountContext ctx, GridViewElement gv) if (items is not null && args.ItemIndex >= 0 && args.ItemIndex < items.Length && args.ItemContainer.ContentTemplateRoot is ContentControl cc) { + using var realization = reconciler.EnterRealization((UIElement)sender!); using var realizationTheme = reconciler.EnterRealizationTheme(args.ItemContainer); var ctrl = reconciler.Mount(items[args.ItemIndex], requestRerender); cc.Content = ctrl; diff --git a/src/Reactor/Core/V1Protocol/Handlers/ListViewHandler.cs b/src/Reactor/Core/V1Protocol/Handlers/ListViewHandler.cs index d172527d0..3192936b0 100644 --- a/src/Reactor/Core/V1Protocol/Handlers/ListViewHandler.cs +++ b/src/Reactor/Core/V1Protocol/Handlers/ListViewHandler.cs @@ -37,6 +37,7 @@ public WinUI.ListView Mount(MountContext ctx, ListViewElement lv) if (lv.ItemContainerStyle is not null) listView.ItemContainerStyle = lv.ItemContainerStyle; Reconciler.SetElementTag(listView, lv); + reconciler.CaptureContextForHost(listView); // DataTemplate with a ContentControl shell — we populate its Content on demand listView.ItemTemplate = Reconciler.SharedContentControlTemplate.Value; @@ -60,6 +61,7 @@ public WinUI.ListView Mount(MountContext ctx, ListViewElement lv) if (items is not null && args.ItemIndex >= 0 && args.ItemIndex < items.Length && args.ItemContainer.ContentTemplateRoot is ContentControl cc) { + using var realization = reconciler.EnterRealization((UIElement)sender!); using var realizationTheme = reconciler.EnterRealizationTheme(args.ItemContainer); var ctrl = reconciler.Mount(items[args.ItemIndex], requestRerender); cc.Content = ctrl; diff --git a/src/Reactor/Core/V1Protocol/LazyStackLifecycle.cs b/src/Reactor/Core/V1Protocol/LazyStackLifecycle.cs index cf05a61b3..13960276d 100644 --- a/src/Reactor/Core/V1Protocol/LazyStackLifecycle.cs +++ b/src/Reactor/Core/V1Protocol/LazyStackLifecycle.cs @@ -43,6 +43,7 @@ internal static UIElement Mount(Reconciler reconciler, LazyStackElementBase lazy Reconciler.ApplySetters(lazy.RepeaterSetters, repeater); var sv = reconciler._pool.TryRent(typeof(WinUI.ScrollViewer)) as WinUI.ScrollViewer ?? new WinUI.ScrollViewer(); + reconciler.AttachContextCapture(factory, sv); sv.Content = repeater; sv.HorizontalScrollBarVisibility = lazy.Orientation == Microsoft.UI.Xaml.Controls.Orientation.Horizontal ? WinUI.ScrollBarVisibility.Auto @@ -87,6 +88,7 @@ internal static UIElement Mount(Reconciler reconciler, LazyStackElementBase lazy repeater.ItemsSource = fresh.Source; var factory = n.CreateFactory(reconciler, requestRerender, reconciler._pool); n.AttachListStateToFactory(factory, fresh); + reconciler.AttachContextCapture(factory, sv); repeater.ItemTemplate = factory; } if (repeater.Layout is WinUI.StackLayout layout) diff --git a/src/Reactor/Core/V1Protocol/Reconciler.TemplatedTree.cs b/src/Reactor/Core/V1Protocol/Reconciler.TemplatedTree.cs index 6993ac735..3d0396817 100644 --- a/src/Reactor/Core/V1Protocol/Reconciler.TemplatedTree.cs +++ b/src/Reactor/Core/V1Protocol/Reconciler.TemplatedTree.cs @@ -44,6 +44,7 @@ internal WinUI.TreeView MountTemplatedTreeView(TemplatedTreeViewElementBase el, treeView.RootNodes.Add(BuildTemplatedTreeNode(el, root)); SetElementTag(treeView, el); + CaptureContextForHost(treeView); // Trampolines resolve the live element + the data item (node.Content) // on dispatch, so they're wired unconditionally and never need @@ -174,6 +175,9 @@ private void PopulateTypedTreeContainer( if (cc.Content is not null) return; // already hosted for this realization if (item is not WinUI.TreeViewNode node || node.Content is not { } data) return; + // Runs from ContainerContentChanging / Loaded / LayoutUpdated, outside the + // traversal — replay the tree's provider stack for the node view. + using var realization = EnterRealization(treeView); using var realizationTheme = EnterRealizationTheme(container); var view = el.BuildView(data); cc.Content = Mount(view, requestRerender); diff --git a/src/Reactor/Core/V1Protocol/TemplatedListLifecycle.cs b/src/Reactor/Core/V1Protocol/TemplatedListLifecycle.cs index b4676c9a4..14648a528 100644 --- a/src/Reactor/Core/V1Protocol/TemplatedListLifecycle.cs +++ b/src/Reactor/Core/V1Protocol/TemplatedListLifecycle.cs @@ -52,6 +52,7 @@ internal static WinUI.ListView MountListView(Reconciler reconciler, TemplatedLis if (header is not null) listView.Header = header; Reconciler.SetElementTag(listView, el); + reconciler.CaptureContextForHost(listView); listView.ItemTemplate = Reconciler.SharedContentControlTemplate.Value; listView.ContainerContentChanging += (sender, args) => @@ -133,6 +134,7 @@ internal static WinUI.GridView MountGridView(Reconciler reconciler, TemplatedLis if (header is not null) gridView.Header = header; Reconciler.SetElementTag(gridView, el); + reconciler.CaptureContextForHost(gridView); gridView.ItemTemplate = Reconciler.SharedContentControlTemplate.Value; gridView.ContainerContentChanging += (sender, args) => diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/RealizationIsolationFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/RealizationIsolationFixtures.cs new file mode 100644 index 000000000..ff596d78e --- /dev/null +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/RealizationIsolationFixtures.cs @@ -0,0 +1,584 @@ +using Microsoft.UI.Reactor; +using Microsoft.UI.Reactor.Core; +using Microsoft.UI.Reactor.AppTests.Host.SelfTest; +using Microsoft.UI.Reactor.Elements; +using Microsoft.UI.Reactor.Hosting; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using static Microsoft.UI.Reactor.Factories; + +namespace Microsoft.UI.Reactor.AppTests.Host.SelfTest.Fixtures; + +/// +/// Issue #1386 — a virtualized host builds rows from WinUI layout callbacks, which can +/// run re-entrantly in the middle of an unrelated Reactor update (any synchronous +/// UpdateLayout(): an app effect, a control's own property setter). Row building +/// must not join that update's bookkeeping: its ErrorBoundary rollback lists, its +/// boundary depth (error routing), its window host. And a host's provider snapshot must +/// go stale on a provider change even when the host is only reached in a later pass. +/// +internal static class RealizationIsolationFixtures +{ + private static readonly Context Ctx = new("default"); + + // ── Re-entrant realization during a failing ErrorBoundary mount ────────── + + private static int s_rowEffects; + private static int s_rowCleanups; + private static Action? s_forceLayout; + + private sealed record IndexProps(int Index); + + private sealed class EffectRow : Component + { + public override Element Render() + { + UseEffect(() => + { + s_rowEffects++; + return () => s_rowCleanups++; + }); + return TextBlock($"erow{Props.Index}").Height(30); + } + } + + private sealed class ThrowingRow : Component + { + public override Element Render() + => Props.Index >= 6 + ? throw new InvalidOperationException($"row-boom-{Props.Index}") + : TextBlock($"trow{Props.Index}").Height(30); + } + + private sealed class LayoutForcer : Component + { + public override Element Render() + { + UseEffect(() => s_forceLayout?.Invoke()); + return TextBlock("forcer"); + } + } + + private sealed class Thrower : Component + { + public override Element Render() => throw new InvalidOperationException("boundary child failed"); + } + + private static Action ForceLayoutOf(Harness h) => () => + { + var sv = h.FindControl(s => s.Content is ItemsRepeater); + (sv?.XamlRoot?.Content as UIElement)?.UpdateLayout(); + }; + + internal sealed class RowsRealizedDuringFailedBoundaryMount_StayAlive(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + s_rowEffects = s_rowCleanups = 0; + s_forceLayout = null; + var host = H.CreateHost(); + host.Mount(ctx => + { + var (phase, setPhase) = ctx.UseState(0); + IReadOnlyList items = Enumerable.Range(0, phase == 0 ? 2 : 20).ToList(); + return VStack( + Button("IsoBoundaryGo", () => setPhase(1)), + LazyVStack(items, i => i.ToString(), (i, _) => + Component(new IndexProps(i))).Height(300), + phase == 0 + ? TextBlock("iso-idle") + : ErrorBoundary( + VStack(Component(), Component()), + TextBlock("iso-fallback"))); + }); + await Harness.Render(); + + int before = s_rowEffects; + int realizedDuringPass = -1; + var force = ForceLayoutOf(H); + s_forceLayout = () => { force(); realizedDuringPass = s_rowEffects - before; }; + + H.ClickButton("IsoBoundaryGo"); + await Harness.Render(); + s_forceLayout = null; + + int visible = H.FindAllControls(tb => tb.Text?.StartsWith("erow", StringComparison.Ordinal) == true).Count; + var detail = $"realizedDuringPass={realizedDuringPass} effects={s_rowEffects} cleanups={s_rowCleanups} visible={visible}"; + H.Check("Iso_Boundary_FallbackShown", H.FindText("iso-fallback") is not null); + // Precondition: the scenario really realized rows inside the failing pass. + H.Check("Iso_Boundary_RowsRealizedReentrantly", realizedDuringPass > 0, detail); + H.Check("Iso_Boundary_LiveRowsNotTornDown", s_rowCleanups == 0 && visible > 0, detail); + } + } + + // A row that throws while realized re-entrantly under a mounting boundary is the + // row's own failure: it must not be routed to (and fail) that unrelated boundary. + internal sealed class ThrowingRowRealizedDuringBoundaryMount_DoesNotTripBoundary(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + s_forceLayout = null; + var host = H.CreateHost(); + host.Mount(ctx => + { + var (phase, setPhase) = ctx.UseState(0); + IReadOnlyList items = Enumerable.Range(0, phase == 0 ? 2 : 20).ToList(); + return VStack( + Button("IsoThrowGo", () => setPhase(1)), + LazyVStack(items, i => i.ToString(), (i, _) => + Component(new IndexProps(i))).Height(300), + phase == 0 + ? TextBlock("iso-throw-idle") + : ErrorBoundary( + VStack(Component(), TextBlock("iso-boundary-ok")), + TextBlock("iso-throw-fallback"))); + }); + await Harness.Render(); + s_forceLayout = ForceLayoutOf(H); + + H.ClickButton("IsoThrowGo"); + await Harness.Render(); + s_forceLayout = null; + + H.Check("Iso_Throw_BoundaryChildRendered", H.FindText("iso-boundary-ok") is not null); + H.Check("Iso_Throw_BoundaryNotTripped", H.FindText("iso-throw-fallback") is null); + H.Check("Iso_Throw_RowShowsItsOwnFallback", + H.FindTextContaining("row-boom-") is not null, + H.FindTextContaining("Render error")?.Text ?? ""); + } + } + + // ── Nested host inside a parked row, provider removed meanwhile ────────── + + private sealed record NestedProps(int Outer, int Inner); + + private sealed class NestedConsumer : Component + { + public override Element Render() + => TextBlock($"n{Props.Outer}.{Props.Inner}:{UseContext(Ctx)}").Height(30); + } + + // Inner rows below 10 do not consume context, so when the outer row comes back + // nothing realized inside the nested host can reveal that the provider is gone — + // only the nested host's own snapshot can. + private static Element InnerList(int outer) + => LazyVStack(Enumerable.Range(0, 40).ToList(), j => j.ToString(), (j, _) => + j < 10 + ? TextBlock($"n{outer}.{j} plain").Height(30) + : Component(new NestedProps(outer, j))) + .Height(120); + + internal sealed class NestedHostInRecycledRow_SeesProviderRemoval(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + var inner = Enumerable.Range(0, 200).Select(InnerList).ToArray(); + var outerList = LazyVStack(Enumerable.Range(0, 200).ToList(), i => i.ToString(), (i, _) => + Border(inner[i]).Height(130)) + .Height(300); + + var host = H.CreateHost(); + host.Mount(ctx => + { + var (on, setOn) = ctx.UseState(true); + var body = VStack( + Button("IsoNestedDrop", () => setOn(false)), + TextBlock($"iso-nested-on:{on}"), + outerList); + return on ? body.Provide(Ctx, "removed") : body; + }); + await Harness.Render(); + + ScrollViewer Outer() => H.FindControl(s => s.Content is ItemsRepeater && global::System.Math.Abs(s.Height - 300.0) < 0.001)!; + H.Check("Iso_Nested_Row0Realized", H.FindText("n0.0 plain") is not null); + + // Park outer row 0 (with its nested host) by scrolling far away. + Outer().ChangeView(null, 100 * 130, null, disableAnimation: true); + await Harness.Render(); + await Harness.Render(); + H.Check("Iso_Nested_Row0Parked", H.FindAllControls(t => t.Text == "n0.0 plain") + .All(t => !IsEffectivelyVisible(t))); + + H.ClickButton("IsoNestedDrop"); + await Harness.Render(); + H.Check("Iso_Nested_ProviderRemoved", H.FindText("iso-nested-on:False") is not null); + + // Bring row 0 back: the repeater reuses its parked container. + Outer().ChangeView(null, 0, null, disableAnimation: true); + await Harness.Render(); + await Harness.Render(); + + var innerSv = H.FindAllControls(t => t.Text == "n0.0 plain" && IsEffectivelyVisible(t)) + .Select(AncestorScrollViewer).FirstOrDefault(s => s is not null); + H.Check("Iso_Nested_Row0Back", innerSv is not null); + if (innerSv is null) return; + + // Realize consumer rows inside the nested host. + innerSv.ChangeView(null, 20 * 30, null, disableAnimation: true); + await Harness.Render(); + await Harness.Render(); + var consumers = H.FindAllControls(t => t.Text?.StartsWith("n0.", StringComparison.Ordinal) == true + && t.Text.Contains(':') && IsEffectivelyVisible(t)) + .Select(t => t.Text).ToList(); + H.Check("Iso_Nested_ConsumersSeeDefault", + consumers.Count > 0 && consumers.All(t => t.EndsWith(":default", StringComparison.Ordinal)), + consumers.Count == 0 ? "" : string.Join(",", consumers.Take(5))); + } + + private static bool IsEffectivelyVisible(DependencyObject d) + { + for (var cur = d; cur is not null; cur = VisualTreeHelper.GetParent(cur)) + if (cur is UIElement { Visibility: Visibility.Collapsed }) return false; + return true; + } + + private static ScrollViewer? AncestorScrollViewer(DependencyObject d) + { + for (var cur = VisualTreeHelper.GetParent(d); cur is not null; cur = VisualTreeHelper.GetParent(cur)) + if (cur is ScrollViewer { Content: Microsoft.UI.Xaml.Controls.ItemsRepeater } sv) return sv; + return null; + } + } + + // ── Retired host releases its provider values ───────────────────────────── + + private sealed record Payload(string Name); + private static readonly Context PayloadCtx = new(defaultValue: null); + + internal sealed class DetachedHost_ReleasesFactoryCapture(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + var host = H.CreateHost(); + host.Mount(_ => VStack( + LazyVStack(Enumerable.Range(0, 20).ToList(), i => i.ToString(), (i, _) => + TextBlock($"d{i}").Height(30)).Height(200)) + .Provide(PayloadCtx, new Payload("retained"))); + await Harness.Render(); + + var sv = H.FindControl(s => s.Content is ItemsRepeater)!; + var factory = (sv.Content as ItemsRepeater)?.ItemTemplate as IContextCapturingFactory; + var capture = factory?.ContextCapture; + H.Check("Iso_Detach_FactoryHasCapture", capture is not null && capture.Entries.Length > 0); + if (capture is null) return; + + // What retiring a parked outer row does to every control in it. + Reconciler.DetachReactorState(sv); + H.Check("Iso_Detach_FactoryAliasReleased", capture.Entries.Length == 0, + $"entries={capture.Entries.Length}"); + } + } + + // ── Nested host mounted while an outdated snapshot is replayed ──────────── + + // A provider is removed, and before the traversal refreshes the outer host's snapshot, + // a row is realized from the outdated snapshot and mounts a nested host. That nested + // host's snapshot holds the removed provider, so it must not record the current + // generation — otherwise a later skip check treats it as current and its later rows + // read the removed value. Drives a standalone outer factory so the ordering is exact. + internal sealed class NestedHostMountedDuringStaleReplay_IsNotCurrent(Harness h) : SelfTestFixtureBase(h) + { + public override Task RunAsync() + { + var reconciler = new Reconciler(); + Func builder = (i, _) => + LazyVStack(Enumerable.Range(0, 30).ToList(), j => j.ToString(), (j, _) => + TextBlock($"sr{i}.{j}").Height(30)).Height(120); + + ElementFactory outer; + using (reconciler.PushContextDisposable(Ctx, "removed")) + outer = new ElementFactory(Enumerable.Range(0, 5).ToList(), builder, reconciler, static () => { }, pool: null); + + // The pass that removes the provider: a non-skip Update whose own provided + // values changed. Inside it, an earlier sibling forces layout, so a row is + // realized from the outer snapshot before the traversal reaches the outer + // host; then the traversal reaches the (reference-stable) nested host. + reconciler.EnterProviderChange(); + try + { + var row = ((IElementFactory)outer).GetElement(new ElementFactoryGetArgs { Data = 0 }); + var nestedSv = row as ScrollViewer; + var nested = (nestedSv?.Content as ItemsRepeater)?.ItemTemplate as IContextCapturingFactory; + H.Check("Iso_StaleReplay_NestedHostMounted", nested?.ContextCapture is { Entries.Length: 1 }, + $"entries={nested?.ContextCapture?.Entries.Length}"); + if (nestedSv is null) return Task.CompletedTask; + + // The live stack is now empty (the removal happened): the nested host's + // snapshot still holds the removed provider, so it must decline the skip. + H.Check("Iso_StaleReplay_NestedHostDeclinesSkip", reconciler.ShouldDeclineSkip(nestedSv)); + } + finally + { + reconciler.ExitProviderChange(); + } + return Task.CompletedTask; + } + } + + // ── Reused row drops its own provider around a stable nested host ───────── + + // A recycled row is reused for the same item, and the new row element no longer + // carries its own .Provide. The nested list is reference-stable, so the row's Update + // reaches it through the skip check while the outer snapshot is being replayed. The + // provider change inside the replay must make the nested snapshot stale. + internal sealed class ReusedRowDropsOwnProvider_NestedHostRefreshes(Harness h) : SelfTestFixtureBase(h) + { + public override Task RunAsync() + { + var reconciler = new Reconciler(); + var stableInner = LazyVStack(Enumerable.Range(0, 30).ToList(), j => j.ToString(), (j, _) => + TextBlock($"ru{j}").Height(30)).Height(120); + var items = Enumerable.Range(0, 5).ToList(); + Func withProvider = (_, _) => VStack(stableInner).Provide(Ctx, "row-local"); + Func withoutProvider = (_, _) => VStack(stableInner); + + var outer = new ElementFactory(items, withProvider, reconciler, static () => { }, pool: null); + IElementFactory f = outer; + + var first = f.GetElement(new ElementFactoryGetArgs { Data = 0 }); + ContextCapture? NestedCapture(UIElement row) + => ((row as StackPanel ?? (row as Panel))?.Children.OfType().FirstOrDefault()?.Content + as ItemsRepeater)?.ItemTemplate is IContextCapturingFactory c ? c.ContextCapture : null; + var nestedBefore = NestedCapture(first); + H.Check("Iso_RowLocal_NestedSeesRowProvider", nestedBefore is { Entries.Length: 1 }, + $"entries={nestedBefore?.Entries.Length} root={first.GetType().Name}"); + + f.RecycleElement(new ElementFactoryRecycleArgs { Element = first }); + outer.UpdateInPlace(items, withoutProvider); + var again = f.GetElement(new ElementFactoryGetArgs { Data = 0 }); + + H.Check("Iso_RowLocal_ContainerReused", ReferenceEquals(first, again)); + var nestedAfter = NestedCapture(again); + H.Check("Iso_RowLocal_NestedDroppedRemovedProvider", nestedAfter is { Entries.Length: 0 }, + $"entries={nestedAfter?.Entries.Length}"); + return Task.CompletedTask; + } + } + + // ── Ancestor theme changes in the same pass that realizes rows ─────────── + + private sealed class ForcerWithDep : Component + { + public override Element Render() + { + UseEffect(() => s_forceLayout?.Invoke(), Props.Index); + return TextBlock("forcer-dep"); + } + } + + // The native RequestedTheme modifier on the ancestor lands only after its children + // are dispatched, so rows realized re-entrantly by a LATER sibling in that same pass + // must use the pass's pending theme, not the host's still-old ActualTheme. + internal sealed class RowsRealizedDuringThemeChangingPass_UsePendingTheme(Harness h) : SelfTestFixtureBase(h) + { + private const string SrcKey = "IsoPassThemeSrcBrush"; + private const string TargetKey = "IsoPassThemeTargetBrush"; + + public override async Task RunAsync() + { + var light = new SolidColorBrush(global::Windows.UI.Color.FromArgb(255, 0, 0, 200)); + var dark = new SolidColorBrush(global::Windows.UI.Color.FromArgb(255, 200, 0, 0)); + var dict = new ResourceDictionary(); + dict.ThemeDictionaries["Light"] = new ResourceDictionary { [SrcKey] = light }; + dict.ThemeDictionaries["Dark"] = new ResourceDictionary { [SrcKey] = dark }; + Application.Current.Resources.MergedDictionaries.Add(dict); + ThemeRef.InvalidateResolutionCache(); + s_forceLayout = null; + try + { + bool appIsDark = Application.Current.RequestedTheme == ApplicationTheme.Dark; + var first = appIsDark ? ElementTheme.Light : ElementTheme.Dark; + var second = appIsDark ? ElementTheme.Dark : ElementTheme.Light; + SolidColorBrush BrushFor(ElementTheme t) => t == ElementTheme.Dark ? dark : light; + + var host = H.CreateHost(); + host.Mount(ctx => + { + var (phase, setPhase) = ctx.UseState(0); + IReadOnlyList items = Enumerable.Range(0, phase == 0 ? 2 : 20).ToList(); + return VStack( + Button("IsoPassThemeGo", () => setPhase(1)), + VStack( + LazyVStack(items, i => i.ToString(), (i, _) => + TextBlock($"pt{i}").Height(30).Resources(r => r.Set(TargetKey, Theme.Ref(SrcKey)))) + .Height(300), + Component(new IndexProps(phase))) + .RequestedTheme(phase == 0 ? first : second)); + }); + await Harness.Render(); + + var forceLayout = ForceLayoutOf(H); + var realizedInPass = new List(); + s_forceLayout = () => + { + forceLayout(); + realizedInPass.AddRange(H.FindAllControls(t => t.Text?.StartsWith("pt", StringComparison.Ordinal) == true + && int.TryParse(t.Text.AsSpan(2), out var n) && n >= 2) + .Select(t => t.Text)); + }; + + H.ClickButton("IsoPassThemeGo"); + await Harness.Render(); + s_forceLayout = null; + + var rows = H.FindAllControls(t => t.Text?.StartsWith("pt", StringComparison.Ordinal) == true + && int.TryParse(t.Text.AsSpan(2), out var n) && n >= 2) + .ToList(); + int right = rows.Count(t => t.Resources.TryGetValue(TargetKey, out var b) && ReferenceEquals(b, BrushFor(second))); + H.Check("Iso_PassTheme_RowsRealizedInPass", realizedInPass.Count > 0, $"inPass={realizedInPass.Count}"); + H.Check("Iso_PassTheme_RowsUseNewTheme", rows.Count > 0 && right == rows.Count, $"right={right}/{rows.Count}"); + } + finally + { + s_forceLayout = null; + Application.Current.Resources.MergedDictionaries.Remove(dict); + ThemeRef.InvalidateResolutionCache(); + } + } + } + + // ── Stable list below an ancestor theme change, realized in the same pass ─ + + // The list element is reference-stable (child-level skip) and its items do not + // change; a later sibling's effect scrolls it and forces layout during the same + // update that flips the ancestor's RequestedTheme. Newly realized rows must resolve + // against the new theme, not the host's still-old ActualTheme. + internal sealed class StableListRealizedDuringThemeChangingPass_UsesPendingTheme(Harness h) : SelfTestFixtureBase(h) + { + private const string SrcKey = "IsoStablePassSrcBrush"; + private const string TargetKey = "IsoStablePassTargetBrush"; + + public override async Task RunAsync() + { + var light = new SolidColorBrush(global::Windows.UI.Color.FromArgb(255, 0, 0, 180)); + var dark = new SolidColorBrush(global::Windows.UI.Color.FromArgb(255, 180, 0, 0)); + var dict = new ResourceDictionary(); + dict.ThemeDictionaries["Light"] = new ResourceDictionary { [SrcKey] = light }; + dict.ThemeDictionaries["Dark"] = new ResourceDictionary { [SrcKey] = dark }; + Application.Current.Resources.MergedDictionaries.Add(dict); + ThemeRef.InvalidateResolutionCache(); + s_forceLayout = null; + try + { + bool appIsDark = Application.Current.RequestedTheme == ApplicationTheme.Dark; + var first = appIsDark ? ElementTheme.Light : ElementTheme.Dark; + var second = appIsDark ? ElementTheme.Dark : ElementTheme.Light; + SolidColorBrush BrushFor(ElementTheme t) => t == ElementTheme.Dark ? dark : light; + + var stableList = LazyVStack(Enumerable.Range(0, 300).ToList(), i => i.ToString(), (i, _) => + TextBlock($"sp{i}").Height(30).Resources(r => r.Set(TargetKey, Theme.Ref(SrcKey)))) + .Height(300); + + var host = H.CreateHost(); + host.Mount(ctx => + { + var (phase, setPhase) = ctx.UseState(0); + return VStack( + Button("IsoStablePassGo", () => setPhase(1)), + VStack(stableList, Component(new IndexProps(phase))) + .RequestedTheme(phase == 0 ? first : second)); + }); + await Harness.Render(); + + int realizedInPass = 0; + s_forceLayout = () => + { + var sv = H.FindControl(s => s.Content is ItemsRepeater)!; + sv.ChangeView(null, 150 * 30, null, disableAnimation: true); + sv.UpdateLayout(); + (sv.XamlRoot?.Content as UIElement)?.UpdateLayout(); + realizedInPass = H.FindAllControls(t => t.Text?.StartsWith("sp1", StringComparison.Ordinal) == true + && int.TryParse(t.Text.AsSpan(2), out var n) && n >= 100).Count; + }; + + H.ClickButton("IsoStablePassGo"); + await Harness.Render(); + s_forceLayout = null; + + var rows = H.FindAllControls(t => t.Text?.StartsWith("sp1", StringComparison.Ordinal) == true + && int.TryParse(t.Text.AsSpan(2), out var n) && n >= 100) + .ToList(); + int right = rows.Count(t => t.Resources.TryGetValue(TargetKey, out var b) && ReferenceEquals(b, BrushFor(second))); + H.Check("Iso_StablePass_RowsRealizedInPass", realizedInPass > 0, $"inPass={realizedInPass}"); + H.Check("Iso_StablePass_RowsUseNewTheme", rows.Count > 0 && right == rows.Count, $"right={right}/{rows.Count}"); + } + finally + { + s_forceLayout = null; + Application.Current.Resources.MergedDictionaries.Remove(dict); + ThemeRef.InvalidateResolutionCache(); + } + } + } + + // ── Ancestor theme changes while the host is skipped ───────────────────── + + // No Context providers anywhere: the provider checks are not even consulted. The + // list is reference-stable, so a theme toggle on its parent skips it, and rows + // realized afterwards must still resolve ThemeRef overrides against the NEW theme. + internal sealed class AncestorThemeToggledWhileSkipped_RowsResolveNewTheme(Harness h) : SelfTestFixtureBase(h) + { + private const string SrcKey = "IsoThemeSrcBrush"; + private const string TargetKey = "IsoThemeTargetBrush"; + + public override async Task RunAsync() + { + var light = new SolidColorBrush(global::Windows.UI.Color.FromArgb(255, 0, 0, 220)); + var dark = new SolidColorBrush(global::Windows.UI.Color.FromArgb(255, 220, 0, 0)); + var dict = new ResourceDictionary(); + dict.ThemeDictionaries["Light"] = new ResourceDictionary { [SrcKey] = light }; + dict.ThemeDictionaries["Dark"] = new ResourceDictionary { [SrcKey] = dark }; + Application.Current.Resources.MergedDictionaries.Add(dict); + ThemeRef.InvalidateResolutionCache(); + try + { + bool appIsDark = Application.Current.RequestedTheme == ApplicationTheme.Dark; + var first = appIsDark ? ElementTheme.Light : ElementTheme.Dark; + var second = appIsDark ? ElementTheme.Dark : ElementTheme.Light; + SolidColorBrush BrushFor(ElementTheme t) => t == ElementTheme.Dark ? dark : light; + + var stableList = LazyVStack(Enumerable.Range(0, 300).ToList(), i => i.ToString(), (i, _) => + TextBlock($"th{i}").Height(30).Resources(r => r.Set(TargetKey, Theme.Ref(SrcKey)))) + .Height(300); + + var host = H.CreateHost(); + host.Mount(ctx => + { + var (flipped, setFlipped) = ctx.UseState(false); + return VStack( + Button("IsoThemeFlip", () => setFlipped(true)), + TextBlock($"iso-theme:{flipped}"), + stableList) + .RequestedTheme(flipped ? second : first); + }); + await Harness.Render(); + + var row0 = H.FindText("th0"); + H.Check("Iso_Theme_MountResolvesAncestor", + row0?.Resources.TryGetValue(TargetKey, out var b0) == true && ReferenceEquals(b0, BrushFor(first))); + + H.ClickButton("IsoThemeFlip"); + await Harness.Render(); + H.Check("Iso_Theme_Flipped", H.FindText("iso-theme:True") is not null); + + var sv = H.FindControl(s => s.Content is ItemsRepeater)!; + sv.ChangeView(null, 150 * 30, null, disableAnimation: true); + await Harness.Render(); + await Harness.Render(); + + var scrolled = H.FindAllControls(t => t.Text?.StartsWith("th1", StringComparison.Ordinal) == true + && int.TryParse(t.Text.AsSpan(2), out var n) && n >= 100) + .ToList(); + int right = scrolled.Count(t => t.Resources.TryGetValue(TargetKey, out var b) && ReferenceEquals(b, BrushFor(second))); + H.Check("Iso_Theme_ScrolledRowsResolveNewTheme", scrolled.Count > 0 && right == scrolled.Count, + $"right={right}/{scrolled.Count}"); + } + finally + { + Application.Current.Resources.MergedDictionaries.Remove(dict); + ThemeRef.InvalidateResolutionCache(); + } + } + } +} diff --git a/tests/Reactor.AppTests.Host/SelfTest/Fixtures/VirtualizedRowContextFixtures.cs b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/VirtualizedRowContextFixtures.cs new file mode 100644 index 000000000..c66e675a7 --- /dev/null +++ b/tests/Reactor.AppTests.Host/SelfTest/Fixtures/VirtualizedRowContextFixtures.cs @@ -0,0 +1,461 @@ +using Microsoft.UI.Reactor; +using Microsoft.UI.Reactor.Core; +using Microsoft.UI.Reactor.AppTests.Host.SelfTest; +using Microsoft.UI.Reactor.Navigation; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using static Microsoft.UI.Reactor.Factories; + +namespace Microsoft.UI.Reactor.AppTests.Host.SelfTest.Fixtures; + +/// +/// Virtualized hosts realize rows from WinUI layout callbacks +/// (IElementFactory.GetElement, ContainerContentChanging) that run +/// after the reconciler traversal has unwound, so a row that consumes context used +/// to read the provider's default (and UseNavigation<T>() threw) until +/// the parent happened to re-render (issue #1386). Each host now snapshots the provider stack when +/// reconciled and replays it around the row mount. +/// +internal static class VirtualizedRowContextFixtures +{ + private static readonly Context RowCtx = new("default"); + + private sealed record RowProps(int Index); + + private sealed class RowConsumer : Component + { + public override Element Render() + => TextBlock($"row{Props.Index}:{UseContext(RowCtx)}").Height(30); + } + + private sealed class EagerConsumer : Component + { + public override Element Render() => TextBlock($"eager:{UseContext(RowCtx)}"); + } + + private static Element Row(int i) => Component(new RowProps(i)); + + private static List RowTexts(Harness h, int minIndex = 0) + => h.FindAllControls(tb => tb.Text?.StartsWith("row", StringComparison.Ordinal) == true) + .Select(tb => tb.Text) + .Where(t => int.TryParse(t.AsSpan(3, t.IndexOf(':') - 3), out var i) && i >= minIndex) + .ToList(); + + private static bool AllEndWith(List texts, string suffix) + => texts.Count > 0 && texts.All(t => t.EndsWith(suffix, StringComparison.Ordinal)); + + private static string Sample(List texts) + => texts.Count == 0 ? "" : string.Join(",", texts.Take(5)); + + private static async Task ScrollListTo(Harness h, double offset) + { + var sv = h.FindControl(s => s.Content is ItemsRepeater) + ?? throw new InvalidOperationException("LazyVStack ScrollViewer not found"); + sv.ChangeView(null, offset, null, disableAnimation: true); + await Harness.Render(); + await Harness.Render(); + } + + internal sealed class LazyVStack_RowsSeeProvider(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + var items = Enumerable.Range(0, 300).ToList(); + var host = H.CreateHost(); + host.Mount(ctx => + { + var (tick, setTick) = ctx.UseState(0); + return VStack( + Button("VCtxBump", () => setTick(tick + 1)), + TextBlock($"vctx-tick:{tick}"), + Component(), + LazyVStack(items, i => i.ToString(), (i, _) => Row(i)).Height(300)) + .Provide(RowCtx, "provided"); + }); + await Harness.Render(); + + // Positive control: the same consumer outside the virtualized host. + H.Check("VirtualizedCtx_Lazy_EagerControl", H.FindText("eager:provided") is not null); + + var initial = RowTexts(H); + H.Check("VirtualizedCtx_Lazy_InitialRows", AllEndWith(initial, ":provided"), Sample(initial)); + + await ScrollListTo(H, 150 * 30); + var scrolled = RowTexts(H, minIndex: 100); + H.Check("VirtualizedCtx_Lazy_ScrolledRows", AllEndWith(scrolled, ":provided"), Sample(scrolled)); + + H.ClickButton("VCtxBump"); + await Harness.Render(); + H.Check("VirtualizedCtx_Lazy_Bumped", H.FindText("vctx-tick:1") is not null); + var afterBump = RowTexts(H); + H.Check("VirtualizedCtx_Lazy_RowsAfterRerender", AllEndWith(afterBump, ":provided"), Sample(afterBump)); + } + } + + // The list element is reference-stable and the realized rows do not consume the + // context, so nothing realized tells the reconciler the provider changed. Rows + // realized afterwards must still see the new value, not the mount-time snapshot. + internal sealed class LazyVStack_ProviderChangeWhileSkipped(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + var items = Enumerable.Range(0, 300).ToList(); + var stableList = LazyVStack(items, i => i.ToString(), (i, _) => + i < 100 ? TextBlock($"plain{i}").Height(30) : Row(i)) + .Height(300); + + var host = H.CreateHost(); + host.Mount(ctx => + { + var (value, setValue) = ctx.UseState("first"); + return VStack( + Button("VCtxChange", () => setValue("second")), + TextBlock($"vctx-value:{value}"), + stableList) + .Provide(RowCtx, value); + }); + await Harness.Render(); + H.Check("VirtualizedCtx_Skip_NoConsumerRealized", RowTexts(H).Count == 0, Sample(RowTexts(H))); + + H.ClickButton("VCtxChange"); + await Harness.Render(); + H.Check("VirtualizedCtx_Skip_ProviderChanged", H.FindText("vctx-value:second") is not null); + + await ScrollListTo(H, 150 * 30); + var scrolled = RowTexts(H, minIndex: 100); + H.Check("VirtualizedCtx_Skip_RowsSeeNewValue", AllEndWith(scrolled, ":second"), Sample(scrolled)); + } + } + + private static readonly Context OuterCtx = new("outer-default"); + + private static LazyVStackElement HalfConsumerList() + => LazyVStack(Enumerable.Range(0, 300).ToList(), i => i.ToString(), (i, _) => + i < 100 ? TextBlock($"plain{i}").Height(30) : Row(i)) + .Height(300); + + // A removed provider leaves a shorter live stack that is still a prefix of the + // skipped host's snapshot, so removal needs its own signal. + internal sealed class LazyVStack_OnlyProviderRemovedWhileSkipped(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + var stableList = HalfConsumerList(); + var host = H.CreateHost(); + host.Mount(ctx => + { + var (on, setOn) = ctx.UseState(true); + var body = VStack( + Button("VCtxDropOnly", () => setOn(false)), + TextBlock($"vctx-on:{on}"), + stableList); + return on ? body.Provide(RowCtx, "removed") : body; + }); + await Harness.Render(); + + H.ClickButton("VCtxDropOnly"); + await Harness.Render(); + H.Check("VirtualizedCtx_DropOnly_Toggled", H.FindText("vctx-on:False") is not null); + + await ScrollListTo(H, 150 * 30); + var scrolled = RowTexts(H, minIndex: 100); + H.Check("VirtualizedCtx_DropOnly_RowsSeeDefault", AllEndWith(scrolled, ":default"), Sample(scrolled)); + } + } + + internal sealed class LazyVStack_InnerProviderRemovedWhileSkipped(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + var stableList = HalfConsumerList(); + var host = H.CreateHost(); + host.Mount(ctx => + { + var (on, setOn) = ctx.UseState(true); + var inner = Border(stableList); + return VStack( + Button("VCtxDropInner", () => setOn(false)), + TextBlock($"vctx-inner:{on}"), + on ? inner.Provide(RowCtx, "removed") : inner) + .Provide(OuterCtx, "stable"); + }); + await Harness.Render(); + + H.ClickButton("VCtxDropInner"); + await Harness.Render(); + H.Check("VirtualizedCtx_DropInner_Toggled", H.FindText("vctx-inner:False") is not null); + + await ScrollListTo(H, 150 * 30); + var scrolled = RowTexts(H, minIndex: 100); + H.Check("VirtualizedCtx_DropInner_RowsSeeDefault", AllEndWith(scrolled, ":default"), Sample(scrolled)); + } + } + + // Same signal, non-virtualized: removing the LAST provider used to empty the stack, + // which short-circuited the reference-stable consumer check entirely. + internal sealed class StableConsumer_LastProviderRemoved(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + var stableConsumer = Component(); + var host = H.CreateHost(); + host.Mount(ctx => + { + var (on, setOn) = ctx.UseState(true); + var body = VStack(Button("VCtxDropEager", () => setOn(false)), stableConsumer); + return on ? body.Provide(RowCtx, "removed") : body; + }); + await Harness.Render(); + H.Check("VirtualizedCtx_DropEager_Before", H.FindText("eager:removed") is not null); + + H.ClickButton("VCtxDropEager"); + await Harness.Render(); + H.Check("VirtualizedCtx_DropEager_SeesDefault", H.FindText("eager:default") is not null, + H.FindTextContaining("eager:")?.Text ?? ""); + } + } + + // A decorator (Flyout) shares the host's control and refreshes its snapshot before + // the list element's own .Provide() is on the stack; the list then skips. + internal sealed class LazyVStack_OwnProviderUnderDecorator(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + var stableList = HalfConsumerList().Provide(RowCtx, "own"); + var host = H.CreateHost(); + host.Mount(ctx => + { + var (tick, setTick) = ctx.UseState(0); + return VStack( + Button("VCtxDecoBump", () => setTick(tick + 1)), + TextBlock($"vctx-deco:{tick}"), + Flyout(stableList, TextBlock($"fly{tick}"))) + .Provide(RowCtx, "outer"); + }); + await Harness.Render(); + + H.ClickButton("VCtxDecoBump"); + await Harness.Render(); + H.Check("VirtualizedCtx_Deco_Bumped", H.FindText("vctx-deco:1") is not null); + + await ScrollListTo(H, 150 * 30); + var scrolled = RowTexts(H, minIndex: 100); + H.Check("VirtualizedCtx_Deco_RowsSeeOwnProvider", AllEndWith(scrolled, ":own"), Sample(scrolled)); + } + } + + // The whole decorator is reference-stable and skips at the element level (Border's + // single child goes through Update's skip arm). Its skip must not re-snapshot the + // shared control at the decorator's depth, which would drop the list's own provider. + internal sealed class LazyVStack_OwnProviderUnderSkippedDecorator(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + var stableFlyout = Flyout(HalfConsumerList().Provide(RowCtx, "own"), TextBlock("fly")); + var host = H.CreateHost(); + host.Mount(ctx => + { + var (tick, setTick) = ctx.UseState(0); + return VStack( + Button("VCtxDecoSkipBump", () => setTick(tick + 1)), + TextBlock($"vctx-decoskip:{tick}"), + // The Border changes each tick so it is updated, and hands the + // reference-stable Flyout to Update, whose element-level skip arm runs. + Border(stableFlyout).Margin(tick)); + }); + await Harness.Render(); + + H.ClickButton("VCtxDecoSkipBump"); + await Harness.Render(); + H.Check("VirtualizedCtx_DecoSkip_Bumped", H.FindText("vctx-decoskip:1") is not null); + + await ScrollListTo(H, 150 * 30); + var scrolled = RowTexts(H, minIndex: 100); + H.Check("VirtualizedCtx_DecoSkip_RowsSeeOwnProvider", AllEndWith(scrolled, ":own"), Sample(scrolled)); + } + } + + private sealed record NavRowProps(int Index); + + // A row parked by RecycleElement is not refreshed when the provider is removed; + // reusing it for the same row under the now-EMPTY snapshot must still re-check the + // context its consumer read, rather than shallow-skip and keep the removed value. + // Drives a standalone factory so every Get/Recycle is one the test asked for. + internal sealed class Factory_RecycledRowAfterLastProviderRemoved(Harness h) : SelfTestFixtureBase(h) + { + public override Task RunAsync() + { + var items = Enumerable.Range(0, 5).ToList(); + // A reference-stable consumer under a fresh Border: the Border updates, and its + // child takes the element-level skip unless a context check declines it. + var consumers = items.Select(Row).ToArray(); + Func builder = (i, _) => Border(consumers[i]); + var reconciler = new Reconciler(); + + ElementFactory factory; + using (reconciler.PushContextDisposable(RowCtx, "removed")) + factory = new ElementFactory(items, builder, reconciler, static () => { }, pool: null); + IElementFactory f = factory; + + static string Text(UIElement c) + => (c as Border)?.Child is Border wrapper && wrapper.Child is TextBlock tb ? tb.Text : $"<{c.GetType().Name}>"; + + var first = f.GetElement(new ElementFactoryGetArgs { Data = 0 }); + H.Check("VirtualizedCtx_Recycle_FirstSeesProvider", Text(first) == "row0:removed", Text(first)); + f.RecycleElement(new ElementFactoryRecycleArgs { Element = first }); + + // The host re-renders without the provider: its snapshot is now empty. + factory.UpdateInPlace(items, builder); + + var again = f.GetElement(new ElementFactoryGetArgs { Data = 0 }); + H.Check("VirtualizedCtx_Recycle_ContainerReused", ReferenceEquals(first, again)); + H.Check("VirtualizedCtx_Recycle_ReusedRowSeesDefault", Text(again) == "row0:default", Text(again)); + return Task.CompletedTask; + } + } + + private sealed class NavRow : Component + { + public override Element Render() + { + var nav = UseNavigation(); + return Button($"open{Props.Index}", () => nav.Navigate($"detail{Props.Index}")).Height(30); + } + } + + // The common list → detail shape: child-mode UseNavigation in a virtualized row + // used to throw "found no ancestor NavigationHost" on every realization. + internal sealed class LazyVStack_UseNavigationChildMode(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + var items = Enumerable.Range(0, 100).ToList(); + var host = H.CreateHost(); + host.Mount(ctx => + { + var nav = ctx.UseNavigation("list"); + return NavigationHost(nav, route => route == "list" + ? LazyVStack(items, i => i.ToString(), (i, _) => + Component(new NavRowProps(i))).Height(300) + : TextBlock($"page:{route}")) with { Transition = NavigationTransition.None }; + }); + await Harness.Render(); + + var open0 = H.FindButton("open0"); + H.Check("VirtualizedCtx_Nav_RowRendered", open0 is not null, + H.FindTextContaining("UseNavigation")?.Text ?? ""); + if (open0 is null) return; + + H.ClickButton("open0"); + await Harness.Render(); + H.Check("VirtualizedCtx_Nav_RowNavigates", H.FindText("page:detail0") is not null); + } + } + + internal sealed class ListView_RowsSeeProvider(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + var items = Enumerable.Range(0, 50).ToList(); + var host = H.CreateHost(); + host.Mount(_ => VStack( + ListView(items, i => i.ToString(), (i, _) => Row(i)).Height(300)) + .Provide(RowCtx, "provided")); + await Harness.Render(); + // Realization can take more than one layout wave (TreeView hosts on Loaded/LayoutUpdated). + await Harness.WaitFor(() => RowTexts(H).Count > 0); + + var rows = RowTexts(H); + H.Check("VirtualizedCtx_ListView_Rows", AllEndWith(rows, ":provided"), Sample(rows)); + } + } + + internal sealed class GridView_RowsSeeProvider(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + var items = Enumerable.Range(0, 50).ToList(); + var host = H.CreateHost(); + host.Mount(_ => VStack( + GridView(items, i => i.ToString(), (i, _) => Row(i)).Height(300)) + .Provide(RowCtx, "provided")); + await Harness.Render(); + // Realization can take more than one layout wave (TreeView hosts on Loaded/LayoutUpdated). + await Harness.WaitFor(() => RowTexts(H).Count > 0); + + var rows = RowTexts(H); + H.Check("VirtualizedCtx_GridView_Rows", AllEndWith(rows, ":provided"), Sample(rows)); + } + } + + // The untyped ListView/GridView handlers have their own ContainerContentChanging bodies. + internal sealed class UntypedListView_RowsSeeProvider(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + var host = H.CreateHost(); + host.Mount(_ => VStack( + ListView(Enumerable.Range(0, 20).Select(Row).ToArray()).Height(300)) + .Provide(RowCtx, "provided")); + await Harness.Render(); + // Realization can take more than one layout wave (TreeView hosts on Loaded/LayoutUpdated). + await Harness.WaitFor(() => RowTexts(H).Count > 0); + + var rows = RowTexts(H); + H.Check("VirtualizedCtx_UntypedListView_Rows", AllEndWith(rows, ":provided"), Sample(rows)); + } + } + + internal sealed class UntypedGridView_RowsSeeProvider(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + var host = H.CreateHost(); + host.Mount(_ => VStack( + GridView(Enumerable.Range(0, 20).Select(Row).ToArray()).Height(300)) + .Provide(RowCtx, "provided")); + await Harness.Render(); + // Realization can take more than one layout wave (TreeView hosts on Loaded/LayoutUpdated). + await Harness.WaitFor(() => RowTexts(H).Count > 0); + + var rows = RowTexts(H); + H.Check("VirtualizedCtx_UntypedGridView_Rows", AllEndWith(rows, ":provided"), Sample(rows)); + } + } + + internal sealed class ItemsView_RowsSeeProvider(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + var items = Enumerable.Range(0, 50).ToList(); + var host = H.CreateHost(); + host.Mount(_ => VStack( + ItemsView(items, i => i.ToString(), (i, _) => ItemContainer(Row(i))).Height(300)) + .Provide(RowCtx, "provided")); + await Harness.Render(); + // Realization can take more than one layout wave (TreeView hosts on Loaded/LayoutUpdated). + await Harness.WaitFor(() => RowTexts(H).Count > 0); + + var rows = RowTexts(H); + H.Check("VirtualizedCtx_ItemsView_Rows", AllEndWith(rows, ":provided"), Sample(rows)); + } + } + + internal sealed class TreeView_NodesSeeProvider(Harness h) : SelfTestFixtureBase(h) + { + public override async Task RunAsync() + { + var roots = Enumerable.Range(0, 5).ToList(); + var host = H.CreateHost(); + host.Mount(_ => VStack( + TreeView(roots, i => i.ToString(), _ => null, i => Row(i)).Height(300)) + .Provide(RowCtx, "provided")); + await Harness.Render(); + // Realization can take more than one layout wave (TreeView hosts on Loaded/LayoutUpdated). + await Harness.WaitFor(() => RowTexts(H).Count > 0); + + var rows = RowTexts(H); + H.Check("VirtualizedCtx_TreeView_Nodes", AllEndWith(rows, ":provided"), Sample(rows)); + } + } +} diff --git a/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs b/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs index de019212a..edd5564df 100644 --- a/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs +++ b/tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs @@ -649,6 +649,30 @@ internal static class SelfTestFixtureRegistry "Issue811_SplitViewContent_ContextConsumerRerenders", "Issue811_ViewboxNested_ContextConsumerRerenders", "Issue811_ActiveButUnchangedContext_StillSkips", + "VirtualizedCtx_LazyVStack_RowsSeeProvider", + "VirtualizedCtx_LazyVStack_ProviderChangeWhileSkipped", + "VirtualizedCtx_LazyVStack_OnlyProviderRemovedWhileSkipped", + "VirtualizedCtx_LazyVStack_InnerProviderRemovedWhileSkipped", + "VirtualizedCtx_StableConsumer_LastProviderRemoved", + "VirtualizedCtx_LazyVStack_OwnProviderUnderDecorator", + "VirtualizedCtx_LazyVStack_OwnProviderUnderSkippedDecorator", + "VirtualizedCtx_Factory_RecycledRowAfterLastProviderRemoved", + "Iso_RowsRealizedDuringFailedBoundaryMount_StayAlive", + "Iso_ThrowingRowRealizedDuringBoundaryMount_DoesNotTripBoundary", + "Iso_NestedHostInRecycledRow_SeesProviderRemoval", + "Iso_DetachedHost_ReleasesFactoryCapture", + "Iso_AncestorThemeToggledWhileSkipped_RowsResolveNewTheme", + "Iso_RowsRealizedDuringThemeChangingPass_UsePendingTheme", + "Iso_NestedHostMountedDuringStaleReplay_IsNotCurrent", + "Iso_ReusedRowDropsOwnProvider_NestedHostRefreshes", + "Iso_StableListRealizedDuringThemeChangingPass_UsesPendingTheme", + "VirtualizedCtx_GridView_RowsSeeProvider", + "VirtualizedCtx_UntypedListView_RowsSeeProvider", + "VirtualizedCtx_UntypedGridView_RowsSeeProvider", + "VirtualizedCtx_LazyVStack_UseNavigationChildMode", + "VirtualizedCtx_ListView_RowsSeeProvider", + "VirtualizedCtx_ItemsView_RowsSeeProvider", + "VirtualizedCtx_TreeView_NodesSeeProvider", "SelfTrigReuse_PositionalReusedInstance", "SelfTrigReuse_PositionalMemoizedWrapper", "SelfTrigReuse_PositionalNestedStableTree", @@ -2779,6 +2803,30 @@ internal static string[] StaleTierDeclarations() => "Issue811_SplitViewContent_ContextConsumerRerenders" => new Issue811ContextConsumerSkipFixtures.SplitViewContent_ContextConsumerRerenders(harness), "Issue811_ViewboxNested_ContextConsumerRerenders" => new Issue811ContextConsumerSkipFixtures.ViewboxNested_ContextConsumerRerenders(harness), "Issue811_ActiveButUnchangedContext_StillSkips" => new Issue811ContextConsumerSkipFixtures.ActiveButUnchangedContext_StillSkips(harness), + "VirtualizedCtx_LazyVStack_RowsSeeProvider" => new VirtualizedRowContextFixtures.LazyVStack_RowsSeeProvider(harness), + "VirtualizedCtx_LazyVStack_ProviderChangeWhileSkipped" => new VirtualizedRowContextFixtures.LazyVStack_ProviderChangeWhileSkipped(harness), + "VirtualizedCtx_LazyVStack_OnlyProviderRemovedWhileSkipped" => new VirtualizedRowContextFixtures.LazyVStack_OnlyProviderRemovedWhileSkipped(harness), + "VirtualizedCtx_LazyVStack_InnerProviderRemovedWhileSkipped" => new VirtualizedRowContextFixtures.LazyVStack_InnerProviderRemovedWhileSkipped(harness), + "VirtualizedCtx_StableConsumer_LastProviderRemoved" => new VirtualizedRowContextFixtures.StableConsumer_LastProviderRemoved(harness), + "VirtualizedCtx_LazyVStack_OwnProviderUnderDecorator" => new VirtualizedRowContextFixtures.LazyVStack_OwnProviderUnderDecorator(harness), + "VirtualizedCtx_LazyVStack_OwnProviderUnderSkippedDecorator" => new VirtualizedRowContextFixtures.LazyVStack_OwnProviderUnderSkippedDecorator(harness), + "VirtualizedCtx_Factory_RecycledRowAfterLastProviderRemoved" => new VirtualizedRowContextFixtures.Factory_RecycledRowAfterLastProviderRemoved(harness), + "Iso_RowsRealizedDuringFailedBoundaryMount_StayAlive" => new RealizationIsolationFixtures.RowsRealizedDuringFailedBoundaryMount_StayAlive(harness), + "Iso_ThrowingRowRealizedDuringBoundaryMount_DoesNotTripBoundary" => new RealizationIsolationFixtures.ThrowingRowRealizedDuringBoundaryMount_DoesNotTripBoundary(harness), + "Iso_NestedHostInRecycledRow_SeesProviderRemoval" => new RealizationIsolationFixtures.NestedHostInRecycledRow_SeesProviderRemoval(harness), + "Iso_DetachedHost_ReleasesFactoryCapture" => new RealizationIsolationFixtures.DetachedHost_ReleasesFactoryCapture(harness), + "Iso_AncestorThemeToggledWhileSkipped_RowsResolveNewTheme" => new RealizationIsolationFixtures.AncestorThemeToggledWhileSkipped_RowsResolveNewTheme(harness), + "Iso_RowsRealizedDuringThemeChangingPass_UsePendingTheme" => new RealizationIsolationFixtures.RowsRealizedDuringThemeChangingPass_UsePendingTheme(harness), + "Iso_NestedHostMountedDuringStaleReplay_IsNotCurrent" => new RealizationIsolationFixtures.NestedHostMountedDuringStaleReplay_IsNotCurrent(harness), + "Iso_ReusedRowDropsOwnProvider_NestedHostRefreshes" => new RealizationIsolationFixtures.ReusedRowDropsOwnProvider_NestedHostRefreshes(harness), + "Iso_StableListRealizedDuringThemeChangingPass_UsesPendingTheme" => new RealizationIsolationFixtures.StableListRealizedDuringThemeChangingPass_UsesPendingTheme(harness), + "VirtualizedCtx_GridView_RowsSeeProvider" => new VirtualizedRowContextFixtures.GridView_RowsSeeProvider(harness), + "VirtualizedCtx_UntypedListView_RowsSeeProvider" => new VirtualizedRowContextFixtures.UntypedListView_RowsSeeProvider(harness), + "VirtualizedCtx_UntypedGridView_RowsSeeProvider" => new VirtualizedRowContextFixtures.UntypedGridView_RowsSeeProvider(harness), + "VirtualizedCtx_LazyVStack_UseNavigationChildMode" => new VirtualizedRowContextFixtures.LazyVStack_UseNavigationChildMode(harness), + "VirtualizedCtx_ListView_RowsSeeProvider" => new VirtualizedRowContextFixtures.ListView_RowsSeeProvider(harness), + "VirtualizedCtx_ItemsView_RowsSeeProvider" => new VirtualizedRowContextFixtures.ItemsView_RowsSeeProvider(harness), + "VirtualizedCtx_TreeView_NodesSeeProvider" => new VirtualizedRowContextFixtures.TreeView_NodesSeeProvider(harness), "SelfTrigReuse_PositionalReusedInstance" => new SelfTriggeredReusedChildFixtures.PositionalReusedInstance(harness), "SelfTrigReuse_PositionalMemoizedWrapper" => new SelfTriggeredReusedChildFixtures.PositionalMemoizedWrapper(harness), "SelfTrigReuse_PositionalNestedStableTree" => new SelfTriggeredReusedChildFixtures.PositionalNestedStableTree(harness), diff --git a/tests/Reactor.Tests/ContextCaptureUnitTests.cs b/tests/Reactor.Tests/ContextCaptureUnitTests.cs new file mode 100644 index 000000000..d5cb2b708 --- /dev/null +++ b/tests/Reactor.Tests/ContextCaptureUnitTests.cs @@ -0,0 +1,375 @@ +using Microsoft.UI.Reactor.Core; +using Xunit; + +namespace Microsoft.UI.Reactor.Tests; + +/// +/// Issue #1386 — the snapshot/replay primitives virtualized hosts use to give rows +/// realized outside the reconciler traversal their ancestors' context values. +/// +public class ContextCaptureUnitTests +{ + private static readonly Context Theme = new("light"); + private static readonly Context Count = new(0); + + private sealed record Payload(string Name); + private static readonly Context Data = new(defaultValue: null); + + private static void Push(ContextScope scope, ContextBase ctx, object? value) + => scope.Push(new Dictionary { [ctx] = value }); + + // ── IsPrefixOf ─────────────────────────────────────────────────── + + [Fact] + public void IsPrefixOf_EmptyLiveStack_IsPrefixOfAnything() + { + var snapshotSource = new ContextScope(); + Push(snapshotSource, Theme, "dark"); + Assert.True(new ContextScope().IsPrefixOf(snapshotSource.Snapshot())); + } + + [Fact] + public void IsPrefixOf_EqualStack_IsPrefix() + { + var scope = new ContextScope(); + Push(scope, Theme, "dark"); + Push(scope, Count, 3); + Assert.True(scope.IsPrefixOf(scope.Snapshot())); + } + + [Fact] + public void IsPrefixOf_LiveLongerThanSnapshot_IsNotPrefix() + { + var scope = new ContextScope(); + Push(scope, Theme, "dark"); + var snapshot = scope.Snapshot(); + Push(scope, Count, 3); + Assert.False(scope.IsPrefixOf(snapshot)); + } + + [Fact] + public void IsPrefixOf_SameContextDifferentValue_IsNotPrefix() + { + var scope = new ContextScope(); + Push(scope, Theme, "dark"); + var snapshot = scope.Snapshot(); + scope.Pop(1); + Push(scope, Theme, "high-contrast"); + Assert.False(scope.IsPrefixOf(snapshot)); + } + + [Fact] + public void IsPrefixOf_EqualButDistinctValue_IsPrefix() + { + // Matches the consumer check (HasConsumedContextChanged uses Equals). + var scope = new ContextScope(); + Push(scope, Data, new Payload("a")); + var snapshot = scope.Snapshot(); + scope.Pop(1); + Push(scope, Data, new Payload("a")); + Assert.True(scope.IsPrefixOf(snapshot)); + } + + [Fact] + public void IsPrefixOf_DifferentContextInSameSlot_IsNotPrefix() + { + var scope = new ContextScope(); + Push(scope, Theme, "dark"); + var snapshot = scope.Snapshot(); + scope.Pop(1); + Push(scope, Count, 1); + Assert.False(scope.IsPrefixOf(snapshot)); + } + + // ── Matches ────────────────────────────────────────────────────── + + [Fact] + public void Matches_RequiresSameLengthAndSameInstances() + { + var scope = new ContextScope(); + var payload = new Payload("a"); + Push(scope, Data, payload); + var snapshot = scope.Snapshot(); + Assert.True(scope.Matches(snapshot)); + + scope.Pop(1); + Push(scope, Data, new Payload("a")); + Assert.False(scope.Matches(snapshot)); // equal but a different instance + + scope.Pop(1); + Assert.False(scope.Matches(snapshot)); // shorter + } + + // ── Replace / Restore ──────────────────────────────────────────── + + [Fact] + public void Replace_HidesLiveValues_AndRestoreBringsThemBack() + { + var captured = new ContextScope(); + Push(captured, Theme, "captured"); + + var scope = new ContextScope(); + Push(scope, Theme, "live"); + Push(scope, Count, 7); + + var previous = scope.Replace(captured.Snapshot()); + Assert.Equal("captured", scope.Read(Theme)); + Assert.Equal(0, scope.Read(Count)); // live-only provider is not visible + + scope.Restore(previous); + Assert.Equal("live", scope.Read(Theme)); + Assert.Equal(7, scope.Read(Count)); + } + + [Fact] + public void Replace_PushesInsideTheReplacedScope_DoNotLeakIntoRestoredStack() + { + var scope = new ContextScope(); + Push(scope, Theme, "live"); + + var previous = scope.Replace([]); + Push(scope, Count, 5); + Assert.Equal(5, scope.Read(Count)); + scope.Pop(1); + + scope.Restore(previous); + Assert.Equal("live", scope.Read(Theme)); + Assert.Equal(0, scope.Read(Count)); + } + + [Fact] + public void Replace_Nested_RestoresInLifoOrder() + { + var a = new ContextScope(); Push(a, Theme, "a"); + var b = new ContextScope(); Push(b, Theme, "b"); + + var scope = new ContextScope(); + Push(scope, Theme, "live"); + + var outer = scope.Replace(a.Snapshot()); + var inner = scope.Replace(b.Snapshot()); + Assert.Equal("b", scope.Read(Theme)); + scope.Restore(inner); + Assert.Equal("a", scope.Read(Theme)); + scope.Restore(outer); + Assert.Equal("live", scope.Read(Theme)); + } + + [Fact] + public void ReplaceAndRestore_AdvanceVersion() + { + var scope = new ContextScope(); + long v0 = scope.Version; + var previous = scope.Replace([]); + long v1 = scope.Version; + scope.Restore(previous); + Assert.True(v1 > v0); + Assert.True(scope.Version > v1); + } + + [Fact] + public void HasActiveValues_StaysOnDuringEmptyReplay_OnceAProviderWasSeen() + { + var scope = new ContextScope(); + Push(scope, Theme, "dark"); + scope.Pop(1); + Assert.False(scope.HasActiveValues); + + var outer = scope.Replace([]); + Assert.True(scope.HasActiveValues); + var inner = scope.Replace([]); + Assert.True(scope.HasActiveValues); + scope.Restore(inner); + Assert.True(scope.HasActiveValues); + scope.Restore(outer); + Assert.False(scope.HasActiveValues); + } + + [Fact] + public void HasActiveValues_StaysOffDuringEmptyReplay_WhenNoProviderWasEverSeen() + { + var scope = new ContextScope(); + var previous = scope.Replace([]); + Assert.False(scope.HasActiveValues); + scope.Restore(previous); + } + + // ── ContextCapture ─────────────────────────────────────────────── + + private static ContextCapture Captured(ContextScope scope) + { + var capture = new ContextCapture(); + capture.Refresh(scope); + return capture; + } + + [Fact] + public void Capture_IsStale_TracksValueChangesAndAdditions() + { + var scope = new ContextScope(); + Push(scope, Theme, "dark"); + var capture = Captured(scope); + Assert.False(capture.IsStale(scope)); + + Push(scope, Count, 1); // provider added above the host + Assert.True(capture.IsStale(scope)); + + capture.Refresh(scope); + Assert.False(capture.IsStale(scope)); + } + + [Fact] + public void Capture_IsStale_RemovedProviderNeedsTheGenerationSignal() + { + var scope = new ContextScope(); + Push(scope, Theme, "dark"); + Push(scope, Count, 1); + var capture = Captured(scope); + + scope.Pop(1); // provider removed: the shorter stack is still a prefix + Assert.False(capture.IsStale(scope)); + + scope.BumpProviderGeneration(); // what the reconciler does for a changed provider + Assert.True(capture.IsStale(scope)); + + capture.Refresh(scope); + Assert.False(capture.IsStale(scope)); + Assert.Single(capture.Entries); + } + + [Fact] + public void Capture_IsStale_GenerationOutlivesTheChangingPass() + { + // A host that was NOT reached in the pass that removed the provider (a nested + // host inside a parked row) must still be stale when it is reached later, under + // a stack that by then looks like an ancestor's. + var scope = new ContextScope(); + Push(scope, Theme, "dark"); + var nested = Captured(scope); + scope.Pop(1); + scope.BumpProviderGeneration(); + + var previous = scope.Replace([]); // later realization replays the outer snapshot + Assert.True(nested.IsStale(scope)); + scope.Restore(previous); + } + + [Fact] + public void Capture_TakenDuringStaleReplay_IsNeverCurrent() + { + // Outer snapshot taken under a provider, then the provider is removed. + var scope = new ContextScope(); + Push(scope, Theme, "dark"); + var outer = Captured(scope); + scope.Pop(1); + scope.BumpProviderGeneration(); + + // A row realized from the (now outdated) outer snapshot mounts a nested host. + var previous = scope.Replace(outer.Entries, outer.Generation); + var nested = Captured(scope); + Assert.NotEqual(scope.ProviderGeneration, nested.Generation); + Assert.False(nested.IsStale(scope)); // consistent with the replayed stack + scope.Restore(previous); + + // Back in the live traversal the nested snapshot must not look current. + Assert.True(nested.IsStale(scope)); + + // Nor in a later replay of the same outdated snapshot. + previous = scope.Replace(outer.Entries, outer.Generation); + Assert.True(nested.IsStale(scope)); + scope.Restore(previous); + } + + [Fact] + public void RepeatedReuse_ParkedGrandchildStaysStale() + { + // Copilot round 8: the outer factory keeps its generation-0 snapshot while a + // reused row drops its own .Provide; a parked grandchild list still holds the + // removed provider. A SECOND reuse must not re-stamp the nested list with gen 0. + var scope = new ContextScope(); + var outer = Captured(scope); // gen 0, empty, never refreshed + + var r1 = scope.Replace(outer.Entries, outer.Generation); + Push(scope, Theme, "row-local"); + var grandchild = Captured(scope); // [row-local] @ gen 0 + scope.Pop(1); + scope.BumpProviderGeneration(); // first reuse drops .Provide + var inner = Captured(scope); // empty @ live gen + scope.Restore(r1); + + var r2 = scope.Replace(outer.Entries, outer.Generation); // outer is now stale + inner.Refresh(scope); // inner refreshed under that replay + var r3 = scope.Replace(inner.Entries, inner.Generation); // inner realizes the grandchild + Assert.True(grandchild.IsStale(scope)); + scope.Restore(r3); + scope.Restore(r2); + } + + [Fact] + public void ProviderChangeDuringReplay_InvalidatesSnapshotsTakenUnderIt() + { + var scope = new ContextScope(); + var outer = Captured(scope); // current, empty + + var previous = scope.Replace(outer.Entries, outer.Generation); + Push(scope, Theme, "row-local"); // a reused row's own provider, still on + var nested = Captured(scope); + Assert.False(nested.IsStale(scope)); + + scope.Pop(1); // the reused row drops its .Provide + scope.BumpProviderGeneration(); + Assert.True(nested.IsStale(scope)); // shorter stack is a prefix; generation catches it + var refreshed = Captured(scope); + scope.Restore(previous); + + // The replay was current, so a snapshot refreshed after the change is current live. + Assert.False(refreshed.IsStale(scope)); + } + + [Fact] + public void ProviderChangeDuringStaleReplay_NeverYieldsACurrentSnapshot() + { + var scope = new ContextScope(); + Push(scope, Theme, "removed"); + var outer = Captured(scope); + scope.Pop(1); + scope.BumpProviderGeneration(); // outer snapshot is now stale + + var previous = scope.Replace(outer.Entries, outer.Generation); + scope.BumpProviderGeneration(); // a provider change inside the replayed row + var nested = Captured(scope); + scope.Restore(previous); + + Assert.True(nested.IsStale(scope)); // still holds the removed provider + } + + [Fact] + public void Capture_Refresh_KeepsArrayWhenNothingChanged() + { + var scope = new ContextScope(); + Push(scope, Theme, "dark"); + var capture = Captured(scope); + var first = capture.Entries; + + capture.Refresh(scope); + Assert.Same(first, capture.Entries); + + scope.Pop(1); + Push(scope, Theme, "light"); + capture.Refresh(scope); + Assert.NotSame(first, capture.Entries); + Assert.Equal("light", capture.Entries[0].Value); + } + + [Fact] + public void Capture_Clear_ReleasesValuesAndIsStale() + { + var scope = new ContextScope(); + Push(scope, Data, new Payload("big")); + var capture = Captured(scope); + + capture.Clear(); + Assert.Empty(capture.Entries); + Assert.True(capture.IsStale(scope)); + } +}