Summary
We need a general model for a WinUI native control property whose setter expects a live FrameworkElement reference to another control — e.g. TeachingTip.Target, Popup.PlacementTarget, AutomationProperties.LabeledBy, XYFocusUp/Down/Left/Right, flyout placement targets.
RelativePanel already solves a narrow version of this, but its approach does not generalize. This issue captures the design discussion so we can pick it back up later.
Why RelativePanel's approach doesn't generalize
RelativePanel works only because the panel is the common parent of both controls. It mounts all children, builds a Dictionary<string,UIElement> name map, and resolves references in a second pass within its own subtree:
RelativePanelDescriptor.ApplyRelativePanelAttachedProps via Panel.PerChildAttachedAfterAll (src/Reactor/Core/V1Protocol/Descriptor/Descriptors/RelativePanelDescriptor.cs)
- Legacy hardcoded arms:
Reconciler.Mount.cs (~L3465–3519) and Reconciler.Update.cs (~L2328–2411)
That resolution is scoped, bounded, and synchronous because the panel owns both endpoints.
The general case (TeachingTip.Target, LabeledBy, XYFocus*, PlacementTarget) has the two controls living anywhere in the tree, with no common container that owns resolution. The panel two-pass cannot reach them, and there is currently no tree-wide deferred-resolution infrastructure — the only post-mount hooks (Panel.PerChildAttachedAfterAll, ControlDescriptor.AfterChildrenMount) are panel-scoped. We'd be inventing the mechanism.
Proposed model: reference by ElementRef, resolved by making the ref observable
Don't use name strings for the general case. Names need a NameScope/registry and only resolve within a scope (which is exactly why RelativePanel can use them — it has a scope). For arbitrary cross-tree references, ElementRef is already the right primitive:
- identity-stable, allocated once via
UseElementRef<T>()
- reconciler-populated —
Reconciler.cs:~3540 sets ref._current = fe on every mount/update
- survives pool recycling
- type-checked via
ElementRef<T>
- no global registry needed
Author-facing API
Attach the ref to the target, pass the same ref to the referring control's property:
var mapRef = UseElementRef<MapControl>();
return Grid(
Map().Ref(mapRef), // target, anywhere in the tree
TeachingTip("Pan around here").Target(mapRef) // referrer, anywhere else
);
.Target(ElementRef<T>) is just another generated descriptor binding.
Mechanism: make ElementRef an observable handle
Avoid a tree-wide deferred queue entirely. Raise an event when _current changes; have the referencing property subscribe. The reconciler already has exactly one write site to instrument.
// FocusManager.cs — ElementRef gains a change signal
public sealed class ElementRef
{
internal FrameworkElement? _current;
public FrameworkElement? Current => _current;
internal event Action<FrameworkElement?>? CurrentChanged;
internal void SetCurrent(FrameworkElement? fe)
{
if (ReferenceEquals(_current, fe)) return;
_current = fe;
CurrentChanged?.Invoke(fe);
}
}
// Reconciler.cs:~3540 — was a bare field write; now goes through SetCurrent
if (m.Ref is not null)
{
m.Ref.SetCurrent(fe); // fires CurrentChanged
AssertTypedRefMatch(m.Ref, fe);
}
New PropEntry kind, living entirely in the existing EnsureSubscribed phase (runs after all Mount writes complete):
sealed class ControlReferenceEntry<TElement, TControl> : PropEntry<TElement, TControl>
where TElement : Element where TControl : UIElement
{
readonly Func<TElement, ElementRef?> _get; // pull ref off the element
readonly Action<TControl, FrameworkElement?> _set; // write the WinUI DP
public override void Mount(TControl ctrl, TElement el)
=> _set(ctrl, _get(el)?.Current); // resolves now if target already mounted
public override void EnsureSubscribed(ReactorBinding<TElement> binding, TControl ctrl, TElement el)
{
if (_get(el) is not { } r) return;
void Handler(FrameworkElement? fe) => binding.WriteSuppressed(() => _set(ctrl, fe));
r.CurrentChanged += Handler;
// hang unsubscribe on the control's existing event-state bag so
// ReturnControl()/unmount tears it down (same path as event handlers)
binding.TrackTeardown(() => r.CurrentChanged -= Handler);
}
}
// Descriptor fluent registration — mirrors .OneWay etc.
.ControlReference<FrameworkElement>(
get: e => e.TargetRef,
set: (tip, fe) => tip.Target = fe);
Why this beats a deferred-resolution queue
Handles every hard case uniformly, without a root-level pending-write pass:
- Target mounts after referrer → at referrer mount
Current is null (harmless write); when the target mounts, SetCurrent fires CurrentChanged and the handler writes tip.Target = fe. Ordering solved by the event, not a queue.
- Target recycles/remounts into a new control → the target's own reconcile calls
SetCurrent(newFe), the event fires, the referrer's DP is re-pointed even though the referrer never re-rendered. A deferred queue would miss this; an event does not.
- No global pass → cost is one subscription per reference + one event fire per target (re)mount.
Edge cases to fix alongside
- Dangling refs on unmount.
ElementRef._current is currently never cleared when an element unmounts without replacement (confirmed in Reconciler / ReturnControl), so Target could point at a detached control. With SetCurrent, call ref.SetCurrent(null) from the unmount/ReturnControl path — fires CurrentChanged(null), referrer cleanly nulls its DP. This dovetails: same change fixes a latent dangling-ref bug.
- Leak symmetry. The referrer must unsubscribe from
CurrentChanged when it unmounts. Route the unsubscribe through the same event-state bag that ReturnControl already clears.
Decision rule (when to use which)
| Situation |
Mechanism |
| Both controls are children of the same container; reference is layout/structural |
name strings + Panel.PerChildAttachedAfterAll (the RelativePanel path — already built, simpler) |
Reference crosses the tree / no common owner (TeachingTip.Target, LabeledBy, XYFocus*, PlacementTarget) |
ElementRef + observable CurrentChanged + .ControlReference binding |
| Not a control reference at all — reacting to another control's data (e.g. compass bound to map) |
lifted state / Context |
Proposed scope of work
Code references are approximate line numbers captured during exploration on main; verify against current source when picking this up.
Summary
We need a general model for a WinUI native control property whose setter expects a live
FrameworkElementreference to another control — e.g.TeachingTip.Target,Popup.PlacementTarget,AutomationProperties.LabeledBy,XYFocusUp/Down/Left/Right, flyout placement targets.RelativePanelalready solves a narrow version of this, but its approach does not generalize. This issue captures the design discussion so we can pick it back up later.Why RelativePanel's approach doesn't generalize
RelativePanelworks only because the panel is the common parent of both controls. It mounts all children, builds aDictionary<string,UIElement>name map, and resolves references in a second pass within its own subtree:RelativePanelDescriptor.ApplyRelativePanelAttachedPropsviaPanel.PerChildAttachedAfterAll(src/Reactor/Core/V1Protocol/Descriptor/Descriptors/RelativePanelDescriptor.cs)Reconciler.Mount.cs(~L3465–3519) andReconciler.Update.cs(~L2328–2411)That resolution is scoped, bounded, and synchronous because the panel owns both endpoints.
The general case (
TeachingTip.Target,LabeledBy,XYFocus*,PlacementTarget) has the two controls living anywhere in the tree, with no common container that owns resolution. The panel two-pass cannot reach them, and there is currently no tree-wide deferred-resolution infrastructure — the only post-mount hooks (Panel.PerChildAttachedAfterAll,ControlDescriptor.AfterChildrenMount) are panel-scoped. We'd be inventing the mechanism.Proposed model: reference by
ElementRef, resolved by making the ref observableDon't use name strings for the general case. Names need a NameScope/registry and only resolve within a scope (which is exactly why
RelativePanelcan use them — it has a scope). For arbitrary cross-tree references,ElementRefis already the right primitive:UseElementRef<T>()Reconciler.cs:~3540setsref._current = feon every mount/updateElementRef<T>Author-facing API
Attach the ref to the target, pass the same ref to the referring control's property:
.Target(ElementRef<T>)is just another generated descriptor binding.Mechanism: make
ElementRefan observable handleAvoid a tree-wide deferred queue entirely. Raise an event when
_currentchanges; have the referencing property subscribe. The reconciler already has exactly one write site to instrument.New
PropEntrykind, living entirely in the existingEnsureSubscribedphase (runs after all Mount writes complete):Why this beats a deferred-resolution queue
Handles every hard case uniformly, without a root-level pending-write pass:
Currentis null (harmless write); when the target mounts,SetCurrentfiresCurrentChangedand the handler writestip.Target = fe. Ordering solved by the event, not a queue.SetCurrent(newFe), the event fires, the referrer's DP is re-pointed even though the referrer never re-rendered. A deferred queue would miss this; an event does not.Edge cases to fix alongside
ElementRef._currentis currently never cleared when an element unmounts without replacement (confirmed inReconciler/ReturnControl), soTargetcould point at a detached control. WithSetCurrent, callref.SetCurrent(null)from the unmount/ReturnControlpath — firesCurrentChanged(null), referrer cleanly nulls its DP. This dovetails: same change fixes a latent dangling-ref bug.CurrentChangedwhen it unmounts. Route the unsubscribe through the same event-state bag thatReturnControlalready clears.Decision rule (when to use which)
Panel.PerChildAttachedAfterAll(the RelativePanel path — already built, simpler)TeachingTip.Target,LabeledBy,XYFocus*,PlacementTarget)ElementRef+ observableCurrentChanged+.ControlReferencebindingContextProposed scope of work
ElementRef.CurrentChanged+SetCurrent; route theReconciler.cs:~3540write through it.SetCurrent(null)) on unmount /ReturnControl.ControlReferenceEntry<TElement,TControl>PropEntry+.ControlReference(...)descriptor fluent method.TeachingTip.Target— including reconcile across late mount and pool recycle.