Proposed — design converged, not yet implemented. This spec is the follow-on
to spec 047, which delivered the V1 handler
protocol (IElementHandler<TElement, TControl>, descriptors, the
Reconciler.RegisterHandler author surface) and proved an external assembly can
author + register a control against the public surface only — see
tests/external_proof/Reactor.External.TestControl/. 047 made external authoring
possible. This spec settles how registration should happen so that it is
automatic, lazy, and — the load-bearing requirement — does not defeat AOT
trimming.
The design below was reached through a multi-step discussion; §3 and §4 preserve the dead-ends and why each was rejected, because the rejections are what pin the final shape.
- §1 Motivation
- §2 Requirements
- §3 The core tension — pick two of three
- §4 Rejected approaches
- §5 Resolution — the factory is the registration link
- §6 Pattern A — the 3P / hand-authored control
- §7 Pattern B — the scale pattern for the built-in catalog
- §8 The
ControlRegistrycontract - §9 Cost model — the hot path
- §10 Built-in migration — dismantling the central registrar
- §11 Trimming model and caveats
- §12 Open questions
- §13 Phasing
Two goals, one of which dominates.
Goal 1 — external (third-party) controls should "just work." A 3P author who
wraps a WinUI/WinRT control (the spec 047 RatingControl / MarqueeControl
walkthroughs) should be able to ship an element + handler such that a consuming app
uses the control with no registration ceremony — and certainly without the
"register on every host, before first render" footgun that the current
per-Reconciler RegisterHandler model carries (documented as a Common Mistake in
docs/guide/extending-reactor-controls.md).
Goal 2 (the north star) — AOT trimming must be able to remove unused elements and their WinUI control types, to shrink the app binary. This is the requirement that disqualifies the otherwise-obvious automatic-registration designs, and it forces a change to how built-in controls register, not just external ones.
That second point is the crux and deserves stating plainly: today every Reactor
app roots the entire built-in control surface. RegisterV1BuiltInHandlers() in
the Reconciler constructor (src/Reactor/Core/Reconciler.cs:335) news up ~50
handlers and names ~50 WinUI control types, from a constructor every app reaches.
The trimmer therefore marks all of them reachable and keeps every one — an app that
uses only Button and TextBlock still carries TabView, TreeView, GridView,
every overlay, and their WinUI backing types. No built-in control is removable as
the architecture stands. Goal 2 is impossible without changing this.
In priority order:
- Elements stay pure data objects. An
Elementrecord must not reference its handler or its WinUI control. The coupling direction is strictly handler → element (the handler already names the element viaIElementHandler<E, C>). - Automatic / unforgettable. No app-developer registration step. Using a control must be sufficient to register it; there is nothing to forget.
- Lazy. Referencing or even constructing an element must not force its handler or WinUI control type to load. Heavy types load on first use.
- AOT-trimmable. An element never used in an app — and its WinUI control type — must be removable by the trimmer. This is the binary-size win.
Requirements 1, 2, and 4 cannot all hold at once. The reason is a fact about how the trimmer works: the ILLink / NativeAOT trimmer keeps a type only if there is a static reference path to it from a root. It follows the static call graph; it cannot follow a runtime dictionary lookup.
To remove an unused control, the trimmer needs a static path of the form "the app uses this element ⇒ therefore this handler + WinUI control are needed." But:
- A pure-data element (req 1) does not reference its handler, so that path does
not exist statically — it exists only at runtime, as a
Dictionary<Type, …>lookup the trimmer cannot see. - Automatic registration (req 2), done eagerly, means something references all handlers from a root (a registrar, a module initializer, a ctor). That roots everything ⇒ nothing trims.
So:
| Keep | Give up | Mechanism |
|---|---|---|
| pure data + automatic | trimmable | runtime registry, eagerly populated (roots all) |
| pure data + trimmable | automatic | link made explicitly at each use site |
| automatic + trimmable | pure data | element statically references its handler |
This is the same reason dependency-injection containers and reflection-based registries are trim-hostile. The resolution (§5) does not break the tension — it relocates the static link to a place that is neither the element nor a global root: the factory function the app already calls to use the control.
Each was rejected against a specific requirement.
Explicit per-host RegisterHandler(reconciler) (the 047 status quo). Fails
req 2: the app must register on every ReactorHost, before first render; a miss is
a runtime Mount throw (exact-type dispatch has no fallback, by 047 §13 Q17).
Kept, but demoted to an override / test escape hatch (§8).
Element implements a self-describing interface (element.CreateHandler()).
Fails req 1 — the element carries behavior and references its handler.
Element static constructor self-registers. Fails req 1 (same coupling) and adds a precise-cctor check to the element-allocation path (the M1 hot path 047 is already nursing).
[ModuleInitializer] self-registration. This is the seductive one, and it fails
req 4 decisively. At runtime a module initializer that registers factory
delegates stays lazy (the delegates defer handler construction). But a
[ModuleInitializer] is a trimmer root that can never be removed, and its body
references every handler and WinUI control it registers — even behind a
static () => new FooHandler() lambda, the lambda's IL names FooHandler →
FooControl. So the trimmer keeps all of them. Module init does not merely fail to
help trimming; it actively prevents it. It also has the assembly-granularity
problem (loading one type in an assembly runs the initializer, which touches every
registered type's metadata).
Reflection / naming-convention discovery (Type.GetType("…Handler"), assembly
attribute scans). Most automatic of all, but fails the project's AOT-clean
constraint (spec 047 §4.7) — reflection-resolved handlers are exactly what the
trimmer strips and what NativeAOT forbids.
Reactor controls are already used through factory functions: Button(...),
TextBlock(...), and for externals an author-provided Marquee(...). Factories are
the single chokepoint through which an element is constructed. Make the factory the
carrier of the static element→handler link and the lazy, idempotent registration
trigger:
- The element stays a pure-data
recordreferencing nothing (req 1). - The factory references the handler + control and registers it on first call (req 2 — using the control is registering it; req 3 — registration/loading happens on first use).
- Because the only static reference to the handler/control flows through the
factory, the trimmer keeps them iff the factory is reachable (req 4). An app
that never calls
Marquee(...)lets the factory, handler, and WinUI control all be trimmed.
The global registry still exists (handlers must be found by element.GetType() at
dispatch), but it is populated lazily by factory calls, never by an eager root —
so the registry itself roots nothing (§8).
Two ergonomic expressions of this one mechanism follow: Pattern A for hand-authored 3P controls, Pattern B for the ~50-control built-in catalog. They compile to the same registry contract and the same hot-path cost.
For an author shipping one or a few controls, co-locate registration in the factory holder's static constructor:
// Pure data — references nothing about its handler or its WinUI control.
public sealed record MarqueeElement(string Caption) : Element;
// The factory the app calls. The static cctor registers the handler exactly once —
// CLR-guaranteed to run before the first Of() returns — and the `static` lambda is
// cached in a static field, so steady-state calls allocate only the element record.
public static class Marquee
{
static Marquee() =>
ControlRegistry.Register<MarqueeElement, MarqueeControl>(static () => new MarqueeHandler());
public static MarqueeElement Of(string caption) => new(caption);
}Why this satisfies every requirement:
- Pure data: the element is untouched; the factory holder references the handler (which references the control).
- Automatic: the app cannot obtain a
MarqueeElementwithout callingMarquee.Of, and that call's class-init registers the handler. Nothing to forget. - Lazy:
MarqueeHandler/MarqueeControlload on the firstMarquee.Ofcall, not when the assembly loads and not when an unrelated control is used. - Trimmable: the trimmer follows
Marquee→ cctor →MarqueeHandler→MarqueeControl. IfMarqueeis never referenced, the whole chain is unreachable and removed.
The static keyword on the lambda is mandatory, not stylistic: it guarantees
the delegate is cached in a static field (one allocation, ever) and captures
nothing (a capture is a compile error).
Construction discipline. For the trim story to hold there must be no catch-all
fallback resolver (one would re-root every handler — see §8). So the factory must be
the only construction path: give MarqueeElement an internal constructor and
expose only Marquee.Of. A raw new MarqueeElement(...) from outside would bypass
registration and dispatch-miss; closing the ctor makes that unrepresentable.
The built-in catalog has ~50 factories on the giant public static partial class Factories (src/Reactor/Elements/Dsl.cs:31). Pattern A would mean ~50 holder
types and would not fit the existing facade. Instead, a single generic helper does
the guarded registration, because statics on a closed generic type are
per-closed-type — Reg<ButtonElement, …> and Reg<TreeViewElement, …> are
distinct types with distinct cctors and distinct trim fates:
// Written once. Each closed Reg<E, C, H> is its own type with its own cctor +
// static field: it registers exactly one control, once, and is trimmed unless a
// kept factory references that exact instantiation.
internal static class Reg<TElement, TControl, THandler>
where TElement : Element
where TControl : UIElement
where THandler : V1Protocol.IElementHandler<TElement, TControl>, new()
{
internal static readonly byte Done = Init();
static byte Init()
{
ControlRegistry.Register<TElement, TControl>(static () => new THandler());
return 0;
}
}Each factory method gains one line:
public static ButtonElement Button(string label, Action? onClick = null)
{
_ = Reg<ButtonElement, WinUI.Button, ButtonHandler>.Done; // register once; static read after warmup
return new ButtonElement(label, onClick);
}Reg<ButtonElement, WinUI.Button, ButtonHandler> roots only Button's handler +
control; Reg<TreeViewElement, …> is a different type rooted only by TreeView().
The facade stays intact — Factories is not split — because ILLink trims unused
static methods member-by-member: an uncalled Factories.TreeView() (and the
Reg<> touch in its body) is removed even though Factories.Button() is kept.
This pattern is also available to 3P authors with large control libraries; Pattern A is just the lower-ceremony choice for one or two controls.
public static class ControlRegistry
{
// Idempotent, lock-free. Keyed by typeof(TElement). First registration wins;
// a repeat for the same element type is a silent no-op (it must NOT throw —
// see §12). Backed by ConcurrentDictionary<Type, Func<IV1HandlerEntry>>.TryAdd.
public static void Register<TElement, TControl>(Func<IElementHandler<TElement, TControl>> handlerFactory)
where TElement : Element where TControl : UIElement;
}It roots nothing. The registry holds Type → Func<…> entries. The only static
references to handler/control types live in the callers of Register — the
per-control factory cctors (Pattern A) and Reg<> instantiations (Pattern B), each
on a per-control rooted path. ControlRegistry.Default itself is trim-neutral.
Dispatch / precedence. The Reconciler resolves a handler for element in
this order, caching the resolved entry into its own per-host _v1Handlers
(Reconciler.cs:537) on first hit so steady-state dispatch is the existing fast
per-host lookup:
- per-host
_v1Handlers— built-ins resolved on this host so far, plus explicit per-host registrations; - per-host
_typeRegistry(Reconciler.cs:35) — legacyRegisterTypecallbacks; ControlRegistry.Default— the global lazy table; on a hit, invoke the factory once, adapt toIV1HandlerEntry, cache into_v1Handlers;- composition primitives (the carved-out arms above the protocol).
The explicit per-host API survives as an override + test hatch. Reconciler. RegisterHandler<TElement, TControl> (Reconciler.cs:972) stays public and is
consulted first (step 1), so a host can shadow the global default (sandboxed XAML
islands, tests that want a clean table). It keeps the strict throw-on-duplicate of
047 §13 Q17, because there the registration is explicit and the throw is
deterministic and greppable — unlike the global table (§12).
After warmup, a Button() / Marquee.Of() call costs:
| Step | Cost |
|---|---|
class-init / Reg<>.Done static-field read |
one static read + one predicted branch (~sub-ns) |
| delegate allocation | none — the static lambda is cached once |
registry lookup / TryAdd |
none — short-circuited once initialized |
| lock | none — the CLR type-init lock fires only on the first call |
element record new |
the allocation paid today, unchanged |
Net new per-call cost: one predicted branch, zero allocations, zero locks — and
that branch is noise next to the element-record allocation the factory already does.
Registration work (the Register call, the one delegate allocation) happens exactly
once per control type per process, on the cold first-use path.
Because the built-in migration (§10) adds this branch to the M1/M2 micro-bench path, it must be measured there before landing — the expectation is that it disappears into the allocation, but 047's perf gates make confirmation mandatory.
The work is to delete the eager central registrar and distribute it:
- Remove
RegisterV1BuiltInHandlers()from theReconcilerctor (Reconciler.cs:335). Move each control's registration into its factory via theReg<>touch (§7). - Preserve the "no type-level aggregation on
Factories" invariant.Factoriestoday is pure methods returning element records — no static constructor, nostatic readonlyfields referencing controls. That must stay true: a static cctor or a static field initializer on the partial class is kept whenever any factory is used, and if it references controls it re-roots the whole catalog, reintroducing the exact problem this spec removes. BecauseFactoriesis partial across files, the audit covers every partial. - Multiple factories → one element is fine.
TextBlock(),Heading(),Subheading()all produceTextBlockElement; each touchesReg<TextBlockElement, WinUI.TextBlock, TextBlockHandler>. Idempotent registration absorbs the repeat, and the control is kept iff any of them is used. - Enforce the invariant mechanically. Two complementary guards:
- a trim test in CI (§11) that publishes a minimal app and asserts unused controls are absent from the output;
- optionally a Roslyn analyzer that flags a static constructor or
control-referencing static field initializer added to
Factories.
Carved composition primitives (Component/Func/Memo/ErrorBoundary, validation, interop bridges — see 047 §14) are unaffected; they sit above the handler protocol and were never in the V1 registry.
The win is a full-trim / NativeAOT story. Unused public factory methods are
removed when the app is trimmed from its entry point (PublishTrimmed with
TrimMode=full, or NativeAOT). The conservative library-preserving trim mode keeps
the public API surface and will not drop them — so the binary-size reduction is
specifically tied to aggressive whole-app trimming, which matches the goal.
WinUI/WinAppSDK framework trimmability is a separate, evolving factor. This design removes Reactor-side rooting — the part we control — so the trimmer is allowed to drop an unused control. Whether a given WinUI control type then trims cleanly depends on the SDK's own NativeAOT/trim readiness, which is improving but historically limited. The spec's claim is bounded accordingly: we stop pinning the catalog; we do not promise the SDK trims everything.
Verification trim test (the regression guard). Stand up a throwaway app that
calls only Button() and TextBlock(); publish NativeAOT / full-trim; assert the
output contains no TreeView / GridView / WinUI.TreeView symbols. A failure
means something re-rooted the catalog (a stray Factories cctor, a surviving
central registrar, an accidental fallback resolver). This single test guards the
entire "no type-level aggregation" rule and should land with the migration.
-
Duplicate policy for the global table vs. 047 §13 Q17. 047 mandates throw-on-duplicate. Under the lazy global table a hard throw is the wrong behavior: multiple factories legitimately register the same element type (§10.3), and a throw from a cctor surfaces as a module-poisoning
TypeInitialization Exceptionat a nondeterministic first-use point. Proposed resolution: the globalControlRegistry.Registeris idempotent first-wins, no throw; the explicit per-hostRegisterHandlerkeeps the strict throw (deterministic, greppable). This needs ratification as a §13 Q17 amendment. -
Can a different host override a globally-registered control? Step 1 of the precedence order (§8) says yes — a per-host
RegisterHandlershadows the global default. Confirm this is the desired isolation primitive for XAML-island / sandboxed embeds, and whether a host needs an explicit "ignore global defaults" switch. -
Closing element constructors. Pattern A/B both rely on the factory being the sole construction path (no fallback resolver). That implies built-in element records move to
internalconstructors. Audit for current call sites thatnewan element record directly (tests, samples) and route them through factories. -
Source-generated
Reg<>touches. TheReg<>line is mechanical; a source generator could emit it (and the closed-ctor) from eachIElementHandler<E, C>implementation, eliminating the one-line-per-factory edit and making "a handler exists ⇒ its factory registers it" a compile-time guarantee. This is the 047 §7 source-gen surface; it is an additive ergonomic layer over the same runtime contract, not a prerequisite.
- Runtime contract. Add
ControlRegistry(idempotent, lock-freeTryAdd) and theReconcilerdispatch-miss resolution + per-host caching (§8). No behavior change yet — built-ins still register eagerly. - Prove the patterns on the external proof. Convert
Reactor.External.TestControlto Pattern A; add the §11 trim test asserting an unused external control is dropped. This validates the mechanism end-to-end before touching built-ins. - Built-in migration. Introduce
Reg<>, removeRegisterV1BuiltInHandlers, convert the ~50 factories, close element constructors, add theFactoriesno-aggregation analyzer/test (§10). Gate on the M1/M2 perf bench (§9) and the trim test (§11). - Ergonomic layer (optional). Source-generate the
Reg<>touches (§12.4) and refreshdocs/guide/extending-reactor-controls.mdto teach the factory-as- registration pattern instead of per-hostRegisterHandler.