Skip to content

Design: general model for control properties that reference another control (e.g. TeachingTip.Target, LabeledBy, XYFocus) #456

@codemonkeychris

Description

@codemonkeychris

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

  1. 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.
  2. 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

  • Add ElementRef.CurrentChanged + SetCurrent; route the Reconciler.cs:~3540 write through it.
  • Clear the ref (SetCurrent(null)) on unmount / ReturnControl.
  • Add ControlReferenceEntry<TElement,TControl> PropEntry + .ControlReference(...) descriptor fluent method.
  • Ensure referrer-side subscription teardown via the existing event-state bag.
  • Prove it end-to-end against one real property — suggest TeachingTip.Target — including reconcile across late mount and pool recycle.

Code references are approximate line numbers captured during exploration on main; verify against current source when picking this up.

Metadata

Metadata

Assignees

No one assigned

    Labels

    design proposalProposed design for something we should consider adding

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions