microsoft/reactor-wrapping
Turn ANY WinUI control — a vendor/third-party control, your own custom Control, or a Windows Community Toolkit one — into a first-class declarative Reactor element with the [GenerateReactorWrapper] source generator, zero hand-written wrapper code. Covers auto-inference (props, content, two-way pairs, panel/items children, events), the [Wrap*] annotation cheat-sheet, imperative-lifecycle controls, diagnostics, and when to fall back to a hand-written ControlDescriptor.
npx skills add https://github.com/microsoft/microsoft-ui-reactor --skill reactor-wrapping
Use this skill when the app needs a control Reactor has **no built-in factory
for** — a Windows Community Toolkit control, a third-party vendor control, or the
app's own custom Control subclass. The Reactor.Wrappers.Generator source
generator turns the control into a first-class declarative element (init-props +
ControlDescriptor + Pattern-A registration + a parameterized factory named after
the control) from a single annotated partial record. **No hand-written wrapper,
handler, or registration code.**
> Canonical worked example: samples/apps/wct-controls/ (declarations in
> WctControls.cs, usage in Program.cs). Design spec: docs/specs/058-control-wrapper-generator.md.
> For controls the generator still can't express, drop to the hand-written
> ControlDescriptor / IElementHandler path in docs/guide/extending-reactor-controls.md.
using Microsoft.UI.Reactor.Wrappers; // the [Wrap*] authoring attributes
namespace MyApp;
// 1. Declare an empty partial record, annotated with the control to wrap.
[GenerateReactorWrapper(typeof(CommunityToolkit.WinUI.Controls.SettingsCard))]
public partial record SettingsCardElement;
// 2. Consume the generated factory via `using static` (named after the control).
using static MyApp.SettingsCardElement;
SettingsCard(
header: "Wi-Fi",
description: wifiOn ? "Connected" : "Disconnected",
content: ToggleSwitch(isOn: wifiOn, onIsOnChanged: setWifiOn)) // a Reactor child INSIDE a WCT control
The generator fills the rest of that partial: one init-property per surfaced
control property, the single-content/children slot, On{Event} callbacks, the
ControlDescriptor, the trim-rooting static cctor registration, and the
SettingsCard(...) factory. Setters is always emitted as an escape hatch
(.Set(c => c.Foo = …)) for anything not surfaced.
You never call ReactorApp.RegisterControlAssembly yourself — the generator
emits it in the element's static constructor, so a control library's
Themes/Generic.xaml resolves before first realization (avoids the 0xC000027B
crash, spec 058 §9a).
Microsoft.UI.Reactor (carries the generator in analyzers/dotnet/cs/and the authoring attributes in Reactor.Wrappers.Abstractions.dll).
CommunityToolkit.WinUI.Controls.* packages).
FrameworkElement with a **publicparameterless constructor** (else REACTORGEN001).
partial (the generator fills the other half) and in anamespace you control.
The generator walks members from the most-derived type up to but excluding
Control/FrameworkElement (Reactor already models that layout plumbing via
generic modifiers like .Margin(), .Width()):
| Control shape | Becomes |
|---|---|
| Settable value prop (string/object→text, bool/int/double/enum, structs like Thickness/Color, Brush/FontFamily, bool?/DateTimeOffset? tri-state) | a nullable / Optional<T> one-way init-prop (skipped when unset) |
| Value prop P paired with a {P}Changed event | a two-way controlled Optional<T> prop + On{P}Changed callback, with echo suppression |
| The control's [ContentProperty] (a UIElement child, or object named exactly Content) | the single content: child slot |
| A public Children of UIElementCollection (a Panel) | a params Element[] children slot |
| A public Items whose type is/implements IList<object> (an ItemsControl) | a params object[] items slot |
| A fire-and-forget RoutedEventHandler / TypedEventHandler<,> / EventHandler<A> event | an On{Event} callback (typed events auto-surface the whole args as Action<A>) |
Two-way auto-pairing keys strictly on the {P}Changed name. Controls whose
change event breaks that convention (ToggleSwitch.IsOn↔Toggled,
Segmented.SelectedIndex↔SelectionChanged) stay one-way until you add
[WrapControlled].
All live in Microsoft.UI.Reactor.Wrappers. All are repeatable unless noted.
| Attribute | Use it to… |
|---|---|
| [GenerateReactorWrapper(typeof(T), Exclude/Include/AutoDiscover)] | wrap T; prune noisy inherited props (Exclude = new[]{ "CommandParameter" }) or switch to explicit opt-in (AutoDiscover = false, Include = new[]{ … }). |
| [WrapControlled("P", ChangedEvent = "…")] | make P two-way when its event isn't {P}Changed. Use Events = new[]{ "Checked","Unchecked" } for multi-event values (read back after any fire). Deferred = true for deferred/coercing string boxes (PasswordBox.Password, AutoSuggestBox.Text). A control has one controlled slot — two [WrapControlled] on one control warns REACTORGEN012. |
| [WrapOneWay("P")] | opt a prop out of two-way auto-pairing (display-only despite a {P}Changed, e.g. ProgressBar.Value). |
| [WrapAlias("FriendlyName", "ControlProperty")] | surface a control prop under a nicer name ("Min"→"Minimum"). Aliasing the content property turns it into a string value prop (no child slot). |
| [WrapContent("Prop")] | override which property is the content: child slot. |
| [WrapConvert("Prop")] | surface a struct prop (CornerRadius, Thickness) as an ergonomic scalar via its single-arg constructor (double? → new CornerRadius(v)). |
| [WrapEvent("Foo", Arg = "ErrorMessage")] | project event args into a typed On{Event}(args.Arg) callback; Args = new[]{ … } for multi-param. |
| [WrapLifecycle(nameof(Start), OnUnmounted = nameof(Stop))] | run a static void M(TControl) on mount/unmount for imperative controls (CameraPreview.StartAsync) — no call-site boilerplate. |
| [WrapPanelChildren(PerChild = …)] | wire per-child attached props (Grid.SetRow, Canvas.SetLeft) onto a panel; AfterAll for two-pass (RelativePanel). |
| [WrapManual("P")] + static partial Customize(d) | hand-chain one entry the generator can't infer while it still generates the rest. |
| [WrapElementSlot("Prop", ControlProperty = "…")] | auto-wire a secondary single-element slot that writes a dedicated control property (TabView.TabStripHeader, SettingsCard.HeaderIcon) — mounts via ctx.MountChild, updates via state-preserving ctx.ReconcileChild. Use ControlProperty= when the element-facing name differs from the control property. The control property must be public-settable and object/UIElement-typed. For the primary child use [WrapContent]; for a slot that shares a control prop with a string fallback (Expander.Header) stay manual. |
| [WrapPolymorphic(nameof(Resolve))] / [WrapDecorator(nameof(Create))] | controls whose concrete type is chosen at runtime, or that need a fully custom mount/update/unmount lifecycle (emit an IDecoratorElementHandler, not a descriptor). |
// CameraPreview must be StartAsync'd after mount and Stop'd on unmount. Declared
// ONCE here, so every call site is a plain declarative `CameraPreview(...)`.
[GenerateReactorWrapper(typeof(CommunityToolkit.WinUI.Controls.CameraPreview))]
[WrapEvent("PreviewFailed", Arg = "Error")]
[WrapLifecycle(nameof(StartPreview), OnUnmounted = nameof(StopPreview))]
public partial record CameraPreviewElement
{
private static async void StartPreview(CommunityToolkit.WinUI.Controls.CameraPreview cp)
{
try { await cp.StartAsync(cp.CameraHelper); } catch { /* surfaced via PreviewFailed */ }
}
private static void StopPreview(CommunityToolkit.WinUI.Controls.CameraPreview cp)
{
try { cp.Stop(); } catch { /* already torn down */ }
}
}
// SelectedIndex changes through SelectionChanged, not SelectedIndexChanged.
[GenerateReactorWrapper(typeof(CommunityToolkit.WinUI.Controls.Segmented))]
[WrapControlled("SelectedIndex", ChangedEvent = "SelectionChanged")]
public partial record SegmentedElement;
// → Segmented(selectedIndex: idx, onSelectedIndexChanged: setIdx, items: …)
Write the member yourself in your half of the partial; the generator skips any
member you declare. Example — treat Header as templated Reactor content instead
of plain text:
public partial record SettingsCardElement
{
public Element? Header { get; init; }
}
REACTORGEN001 (target isn't a non-static FrameworkElement with a public
parameterless ctor) · …002 (bad Include/Exclude name) · …003/…004
(WrapControlled property / change-event) · …005 (WrapAlias control property) ·
…006 (WrapOneWay) · …007 (WrapContent) · …008 (WrapConvert) ·
…009/…010 (WrapEvent target / arg) · …011 (WrapLifecycle method) · …012 (two
controlled props share one control) · …013 (WrapElementSlot control property
missing/not-settable — set ControlProperty=) · …014 (WrapElementSlot control
property not UIElement-assignable) · …015 (WrapElementSlot slot name not a valid
identifier). Run mur check <path> — it surfaces these
with skill pointers.
The generator targets the common case. Reach for the manual
ControlDescriptor / IElementHandler path (docs/guide/extending-reactor-controls.md,
registered via ControlRegistry.RegisterHandler) only when a control's contract
is something the attributes above can't yet express — and prefer adding a reusable
[Wrap*] capability over a one-off handler when the shape is general.
Take microsoft/reactor-wrapping from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.