Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,36 @@ Conventions for contributors:

### Fixed

- **Virtualized list rows now see their ancestors' `Context`** (issue #1386). Rows of
`LazyVStack` / `LazyHStack`, `ItemsRepeater<T>`, `ItemsView<T>`, `ListView` / `GridView`
(typed and untyped) and `TreeView<T>` 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<T>()` 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<T>` 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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/guide/architecture-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
206 changes: 204 additions & 2 deletions src/Reactor/Core/ContextScope.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using Microsoft.UI.Xaml;

namespace Microsoft.UI.Reactor.Core;

/// <summary>
Expand All @@ -7,13 +9,14 @@ namespace Microsoft.UI.Reactor.Core;
/// </summary>
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<ContextBase, object?> values)
{
foreach (var (ctx, val) in values)
_stack.Add((ctx, val));
if (values.Count > 0) _everHadValues = true;
_version++;
}

Expand Down Expand Up @@ -48,7 +51,206 @@ internal T Read<T>(Context<T> 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;

/// <summary>
/// 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.
/// </summary>
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<long> _replayGenerations = new();

/// <summary>
/// The generation the live stack's contents correspond to: the replayed snapshot's
/// while a replay is active, otherwise <see cref="ProviderGeneration"/>.
/// </summary>
internal long SnapshotGeneration
=> _replayGenerations.Count > 0 ? _replayGenerations[^1] : ProviderGeneration;
Comment thread
azchohfi marked this conversation as resolved.

internal (ContextBase Context, object? Value)[] Snapshot()
=> _stack.Count == 0 ? [] : _stack.ToArray();

/// <summary>
/// True when the live stack holds exactly <paramref name="snapshot"/>: 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.
/// </summary>
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;
}

/// <summary>
/// True when the live stack is a positional prefix of <paramref name="snapshot"/>
/// (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 <em>removed</em> provider leaves a shorter stack that is still a prefix,
/// so callers must detect removal separately (see
/// <see cref="ProviderGeneration"/>).
/// </summary>
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;
}

/// <summary>
/// Replaces the live stack with <paramref name="snapshot"/> and returns the stack it
/// displaced, which the caller must hand back to <see cref="Restore"/>. Replacing
/// (rather than pushing on top) keeps a re-entrant realization from observing the
/// providers of whatever subtree the traversal happened to be in.
/// </summary>
internal List<(ContextBase Context, object? Value)> Replace((ContextBase Context, object? Value)[] snapshot)
=> Replace(snapshot, SnapshotGeneration);

/// <summary>
/// Replaces the live stack with <paramref name="snapshot"/>, recording the provider
/// generation it was taken at (see <see cref="SnapshotGeneration"/>), and returns the
/// displaced stack for <see cref="Restore"/>. 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.
/// </summary>
/// <param name="snapshot">The provider entries to replay.</param>
/// <param name="generation">The <see cref="ProviderGeneration"/> <paramref name="snapshot"/> was taken at.</param>
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++;
}
}

/// <summary>
/// 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 — <c>IElementFactory.GetElement</c>, <c>ContainerContentChanging</c> —
/// that run during layout, outside the traversal that maintains <see cref="ContextScope"/>,
/// so the row mount replays this snapshot instead of reading whatever the live stack
/// holds at that moment.
/// </summary>
internal sealed class ContextCapture
{
internal (ContextBase Context, object? Value)[] Entries { get; private set; } = [];

/// <summary>
/// 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.
/// </summary>
internal Type? OwnerType { get; init; }

internal bool IsOwnedBy(Element element) => OwnerType is null || OwnerType == element.GetType();

/// <summary><see cref="ContextScope.SnapshotGeneration"/> when <see cref="Entries"/> was taken.</summary>
internal long Generation { get; private set; } = long.MinValue;

internal void Refresh(ContextScope scope)
{
if (!scope.Matches(Entries))
Entries = scope.Snapshot();
Generation = scope.SnapshotGeneration;
}

/// <summary>Reconcile pass in which the host was last reached (see <see cref="PassTheme"/>).</summary>
internal long PassId { get; private set; } = -1;

/// <summary>
/// The subtree theme the host was reached with in pass <see cref="PassId"/>. Only
/// meaningful while that pass is still in flight: afterwards the host's native
/// <c>ActualTheme</c> is authoritative and is read live instead.
/// </summary>
internal ElementTheme PassTheme { get; private set; }

/// <summary>
/// The reconciler's theme generation when the host was last reached. An ancestor's
/// <c>RequestedTheme</c> change advances it, so a skipped host is detected as needing
/// to re-record its subtree theme (see <see cref="PassTheme"/>).
/// </summary>
internal long ThemeGeneration { get; private set; } = long.MinValue;

internal void NotePass(long passId, ElementTheme theme, long themeGeneration)
{
PassId = passId;
PassTheme = theme;
ThemeGeneration = themeGeneration;
}

/// <summary>
/// Releases the snapshot's provider values. The capture object can be aliased (an
/// <see cref="ElementFactory{T}"/> holds the same instance), so a host leaving Reactor's
/// ownership empties it rather than only dropping its own reference.
/// </summary>
internal void Clear()
{
Entries = [];
Generation = long.MinValue;
}

/// <param name="scope">The live stack at an ancestor of (or at) the host.</param>
internal bool IsStale(ContextScope scope)
=> Generation != scope.SnapshotGeneration || !scope.IsPrefixOf(Entries);
}
30 changes: 29 additions & 1 deletion src/Reactor/Core/ElementFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </remarks>
public sealed partial class ElementFactory<T> : IElementFactory
public sealed partial class ElementFactory<T> : IElementFactory, IContextCapturingFactory
{
private IReadOnlyList<T> _items;
private Func<T, int, Element> _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<ReactorRow> path
// (spec 042). Lets GetElement translate an ItemsRepeater realized
// index → stable key for _mountedElements lookup. Null when running
Expand Down Expand Up @@ -288,6 +311,7 @@ public ElementFactory(
_reconciler = reconciler;
_requestRerender = requestRerender;
_pool = pool;
_ownCapture = reconciler.CaptureLiveContext();
}

/// <summary>
Expand All @@ -301,6 +325,8 @@ internal void UpdateInPlace(IReadOnlyList<T> items, Func<T, int, Element> 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 <see cref="ViewBuilderCacheEntry.Built"/> entries. We can't see through
Expand Down Expand Up @@ -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);
Expand Down
10 changes: 10 additions & 0 deletions src/Reactor/Core/IItemsRepeaterFactorySource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,13 @@ internal interface IItemsRepeaterFactorySource
/// </summary>
void RefreshRealizedItems(IElementFactory factory, WinUI.ItemsRepeater repeater);
}

/// <summary>
/// An <see cref="IElementFactory"/> that replays its host's provider stack while
/// realizing rows (see <see cref="ContextCapture"/>). Hosts assign the capture via
/// <see cref="Reconciler.AttachContextCapture"/> whenever they install a factory.
/// </summary>
internal interface IContextCapturingFactory
{
ContextCapture? ContextCapture { get; set; }
}
Loading
Loading