Element Pool¶
The Microsoft.UI.Reactor (Reactor) element pool recycles unmounted WinUI controls so a scroll-heavy
list or fast-mounting subtree doesn't allocate a fresh TextBlock /
Button / Border on every render. Each WinUI type gets its own
fixed-size stack (cap 32) inside ElementPool; the
reconciler calls TryRent(typeof(T)) on mount
and Return(element) on unmount. This page documents the rental
contract, the cleanup pass that resets a returned element, and the
exclusions that keep pooling safe — composition-tainted visuals,
non-poolable types, and parent-state corner cases.
Renting on mount¶
Every MountXxx handler in Reconciler.Mount.cs follows the same
shape: try the pool first, allocate fresh on miss.
public FrameworkElement? TryRent(Type type)
{
if (!Enabled) return null;
if (!PoolableTypes.Contains(type)) return null;
if (!_pools.TryGetValue(type, out var stack) || stack.Count == 0) return null;
var item = stack.Pop();
return item;
}
TryRent is three checks and a pop: pool enabled, type in the
poolable set, stack non-empty. Anything that fails returns null and
the caller falls through to new TextBlock() / new WinUI.Button().
The exclusions are deliberate — the live-previewer can flip
Pool.Enabled = false when recycled controls with stale property
state would cause visual glitches, and the poolable type set is a
hardcoded HashSet<Type> because pooling a type the cleanup pass
doesn't know about would leak property state into the next user.
| Type | Why it's poolable | Notes |
|---|---|---|
TextBlock, RichTextBlock |
Static leaf; no event handlers; cheap reset | High-volume win in lists. |
StackPanel, Grid, Border |
Container shells; children cleared on return | Reused across most layouts. |
ScrollViewer, Canvas, Viewbox |
Single-Content controls; content nulled on return |
Same shape. |
ProgressBar, ProgressRing |
Indeterminate flag + value reset to defaults | Stop-state restored on return. |
Image, InfoBadge |
Source / Value nulled |
Image source pooling is the largest win. |
Button, TextBox, ToggleSwitch |
Interactive — pooled via Tag-based event trampoline | See "Why interactive controls are safe" below. |
Types not in this set are not pooled — every mount of a ListView,
NumberBox, DatePicker, etc. allocates fresh. That's deliberate:
the cleanup pass has to know how to reset every property a Reactor
modifier or .Set callback might have written, and adding a type to
PoolableTypes without a matching CleanElement case would leak
property state into the next reader's mount.
Returning on unmount¶
Return is the more interesting half. A control coming back to the
pool may carry hundreds of WinUI property values, a parent reference,
event handler subscriptions, and (on interactive controls) the
trampoline state that drives Reactor's event dispatch:
public void Return(FrameworkElement element)
{
if (!Enabled) return;
var type = element.GetType();
if (!PoolableTypes.Contains(type)) return;
// Don't pool elements that had GetElementVisual() called — they permanently
// lose XAML implicit transition API access (OpacityTransition, etc.).
if (IsCompositorTainted(element)) return;
if (!_pools.TryGetValue(type, out var stack))
{
stack = new Stack<FrameworkElement>();
_pools[type] = stack;
}
if (stack.Count >= MaxPerType) return;
The path runs in five steps after the type check: composition-taint
check (a visual that has had GetElementVisual() called permanently
loses access to XAML implicit transitions like OpacityTransition,
so it must not be pooled), per-type stack lookup, per-type cap check
(stop accepting at 32 instances), then — past the snippet boundary —
the detach-from-parent dance and CleanElement(...) reset before the
control finally pushes onto the stack. The scratch-panel round-trip
in ForceDetach is the load-bearing piece: WinUI's internal parent
tracking can retain stale state that throws COMException when the
control is later re-parented, and _scratchPanel.Children.Add +
Remove forces WinUI to clear it.
The element record itself isn't pooled — only the realized WinUI control on the other side:
public abstract record Element
{
/// <summary>
/// Optional key for stable identity across re-renders (like React's key prop).
/// When set, the reconciler uses it to match elements across list reorderings.
/// </summary>
public string? Key { get; init; }
/// <summary>
/// Layout modifiers (margin, padding, size, alignment, etc.) applied to this element.
/// Set via fluent extension methods: Text("hi").Margin(10).Width(200)
/// Modifiers are stored inline so the concrete element type is preserved through chaining.
/// </summary>
public ElementModifiers? Modifiers { get; init; }
Records are cheap; the heavy classes are the WinUI controls that allocate template instances, brush caches, and visual state machines. Pooling the records would save nothing because the reconciler creates fresh records every render by design.
Why interactive controls are safe to pool¶
A Button.Click handler that closes over the captured rerender
closure cannot be left dangling on a pooled control — the next user
of that control would receive clicks targeting the wrong component.
The fix is the Tag-based event trampoline: the WinUI subscription
attached at first mount stays attached, but it reads the current
element from the control's Tag at invocation time, so a recycled
control dispatching from its old subscription naturally routes to
the new element's OnClick after Reconciler.SetElementTag runs:
private bool MarshalIfOffUIThread(string hookName, Action work)
{
// Hot path — same thread that ran BeginRender. ~1ns TLS read + cmp + branch.
if (Environment.CurrentManagedThreadId == _uiThreadId) return false;
CleanElement calls ClearCurrentEventHandlers(fe) on pool return,
which nulls the per-mount user-handler delegates without touching the
WinUI subscription. On the next mount, the trampoline finds null
handlers in the new Tag's element record, dispatches to nothing, and
costs essentially zero — until the user actually wires .Click(...),
at which point the trampoline routes correctly. TASK-060 in
Reconciler.cs calls this out specifically: clearing the delegate
list on return is what stops a pooled control from firing the
previous component's captured closure into the next mount.
Tips¶
The pool is opt-in by type. Adding a custom control to the pool
means adding it to ElementPool.PoolableTypes and extending
CleanElement with a switch arm that resets every property the
control might have carried. Skipping the second step leaks state.
GetElementVisual() is one-way. Once a control's composition
visual has been requested (typically for an .Animate() modifier),
the control can no longer use XAML implicit transitions and is
permanently un-poolable. ElementPool.MarkCompositorTainted records
this; the next Return for that instance silently drops it. Reach for
.WithOpacityTransition() and friends before .Animate() when both
work.
The cap is 32 per type. A burst of 100 list items mounts 32 from
the pool and 68 fresh; the next render returns 32 to the pool and
drops 36. That's fine for steady-state scrolling — the working set
stabilizes around the visible window — but doesn't scale to giant
flat lists. Reach for VirtualList for those.
Disable the pool when you need pristine state. The live previewer
sets Pool.Enabled = false because hot-reloading new code into a
recycled control with old property state causes visible glitches.
Test fixtures that snapshot-compare WinUI properties on mount should
do the same.
Next Steps¶
- Reconciliation — Previous: where Mount and Unmount call
TryRent/Return. - Collections — Next: the heaviest user of pooling (virtualized list items).
- Source mapping — How pool reuse interacts with per-control source attribution.
- Architecture overview — Where the pool sits in the render loop.