Skip to content

Commit 789bc48

Browse files
docs: under-the-hood guide to the V1 Protocol + extension cookbook
Two new comprehensive-tier pages in the Under-the-hood track (Section 9 of the docset): - docs/_pipeline/templates/v1-protocol.md.dt (order 33.5) — reference for the V1 handler protocol. Dispatch model, IElementHandler contract, ref-struct contexts + ReactorBinding, the mount-then-subscribe ordering invariant, ChildrenStrategy survey (11 shapes), pool integration, echo suppression / WriteSuppressed, element-identity tag pattern, descriptors as declarative sugar, full PropEntry builder vocabulary, registration variants. Patterns, Common Mistakes, Tips, Next Steps. - docs/_pipeline/templates/extending-reactor-controls.md.dt (order 33.7) — cookbook. Walks through porting WinUI RatingControl as a new StarMeterElement descriptor end-to-end: element record → descriptor-vs-handler decision → prop wiring → events → children strategy → registration → use. Locks in the perf bar (pool reuse, echo suppression, static trampolines) external authors should hit. Each page ships with a compilable doc app: - docs/_pipeline/apps/v1-protocol/ — LedIndicatorElement custom element registered via a ControlDescriptor (joint-field OneWay over Border.Background). - docs/_pipeline/apps/extending-reactor-controls/ — StarMeterElement custom element wrapping WinUI RatingControl with Controlled + OneWay + OneWayConditional props and a gated callback subscription. Prose is written to reflect the post-cleanup state (non-V1 paths removed): V1 is the only protocol, RegisterHandler is GA, no [Experimental] surface, no UseV1Protocol flag mentioned, no Path-B-delegate handlers as a stable shape. The doc apps still call AppContext.SetSwitch today because that's what V1 dispatch requires in the current codebase — that line goes away with the cleanup. Five small <snippet:...> markers added in src/Reactor/Core/V1Protocol/ so the reference page can cite the authoritative source for the handler contract, MountContext, ChildrenStrategy survey, the adapter Mount body, and the descriptor's interpretation phase ordering. Framework still builds clean. Generated outputs included so the docset is publishable from this PR: - docs/guide/v1-protocol.md - docs/guide/extending-reactor-controls.md - docs/guide/images/v1-protocol/led-indicator.png - docs/guide/images/extending-reactor-controls/star-meter.png Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7a370d6 commit 789bc48

17 files changed

Lines changed: 2333 additions & 5 deletions

File tree

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
using Microsoft.UI.Reactor;
2+
using Microsoft.UI.Reactor.Core;
3+
using Microsoft.UI.Reactor.Core.V1Protocol;
4+
using Microsoft.UI.Reactor.Core.V1Protocol.Descriptor;
5+
using static Microsoft.UI.Reactor.Factories;
6+
using Microsoft.UI.Xaml;
7+
using WinUI = Microsoft.UI.Xaml.Controls;
8+
9+
System.AppContext.SetSwitch("Reactor.UseV1Protocol", true);
10+
ReactorApp.Run<ExtendingApp>(
11+
"Extending Reactor", width: 540, height: 360, devtools: true,
12+
configure: host => StarMeterInterop.Register(host.Reconciler));
13+
14+
// ════════════════════════════════════════════════════════════════════════
15+
// Step 1 — Define the Element record
16+
// ════════════════════════════════════════════════════════════════════════
17+
18+
// <snippet:star-meter-element>
19+
// An Element subclass with one controlled prop (Value), three one-way
20+
// props (MaxRating, Caption, IsClearEnabled), and one callback (OnValueChanged).
21+
// Records give the reconciler value-equality for free — two StarMeterElement
22+
// instances with identical fields compare equal and Update becomes a no-op.
23+
public sealed record StarMeterElement : Element
24+
{
25+
public double Value { get; init; }
26+
public int MaxRating { get; init; } = 5;
27+
public string? Caption { get; init; }
28+
public bool IsClearEnabled { get; init; } = true;
29+
public System.Action<double>? OnValueChanged { get; init; }
30+
}
31+
// </snippet:star-meter-element>
32+
33+
// ════════════════════════════════════════════════════════════════════════
34+
// Step 2 — Wire the descriptor
35+
// ════════════════════════════════════════════════════════════════════════
36+
37+
// <snippet:star-meter-descriptor>
38+
public static class StarMeterDescriptor
39+
{
40+
public static readonly ControlDescriptor<StarMeterElement, WinUI.RatingControl> Descriptor =
41+
new ControlDescriptor<StarMeterElement, WinUI.RatingControl>
42+
{
43+
// Leaf control — no children. (See ChildrenStrategy survey for
44+
// the other shapes: SingleContent, Panel, NamedSlots, ItemsHost…)
45+
Children = new None<StarMeterElement, WinUI.RatingControl>(),
46+
}
47+
// OneWay props: written on Mount, diff-and-written on Update.
48+
.OneWay(
49+
get: static e => e.MaxRating,
50+
set: static (c, v) => c.MaxRating = v)
51+
.OneWay(
52+
get: static e => e.IsClearEnabled,
53+
set: static (c, v) => c.IsClearEnabled = v)
54+
// OneWayConditional skips the write when the predicate is false —
55+
// leaves Caption at the control's default for elements that didn't
56+
// supply one, rather than forcing it to null and losing a style.
57+
.OneWayConditional(
58+
get: static e => e.Caption,
59+
set: static (c, v) => c.Caption = v!,
60+
shouldWrite: static e => e.Caption is not null)
61+
// Controlled is the two-way binding shape: the framework writes the
62+
// element's value at Mount (and on diff), suppresses the echo when
63+
// the framework is the writer, and forwards user input back through
64+
// OnValueChanged. Subscription is gated on the callback being non-
65+
// null — if the caller didn't pass OnValueChanged, no trampoline
66+
// is wired and the per-fire dispatch cost stays at zero.
67+
.Controlled<double, object>(
68+
get: static e => e.Value,
69+
set: static (c, v) => c.Value = v,
70+
subscribe: static (fe, h) => ((WinUI.RatingControl)fe).ValueChanged += (s, e) => h(s, e!),
71+
unsubscribe: static (fe, h) => { /* trampoline anchored for control lifetime */ },
72+
callback: static e => e.OnValueChanged,
73+
readBack: static c => c.Value);
74+
}
75+
// </snippet:star-meter-descriptor>
76+
77+
// ════════════════════════════════════════════════════════════════════════
78+
// Step 3 — Register
79+
// ════════════════════════════════════════════════════════════════════════
80+
81+
// <snippet:star-meter-registration>
82+
public static class StarMeterInterop
83+
{
84+
// One call per Reactor host. RegisterHandler accepts any IElementHandler,
85+
// and DescriptorHandler<TElement,TControl> is the canonical interpreter
86+
// for a ControlDescriptor. Duplicate registration for the same element
87+
// type throws — register exactly once on each host you mount.
88+
public static void Register(Reconciler reconciler) =>
89+
reconciler.RegisterHandler<StarMeterElement, WinUI.RatingControl>(
90+
new DescriptorHandler<StarMeterElement, WinUI.RatingControl>(
91+
StarMeterDescriptor.Descriptor));
92+
}
93+
// </snippet:star-meter-registration>
94+
95+
// ════════════════════════════════════════════════════════════════════════
96+
// Step 4 — Use the element
97+
// ════════════════════════════════════════════════════════════════════════
98+
99+
// <snippet:star-meter-usage>
100+
class ExtendingApp : Component
101+
{
102+
public override Element Render()
103+
{
104+
var (rating, setRating) = UseState(3.5);
105+
106+
return VStack(16,
107+
TextBlock("StarMeter — custom element wrapping WinUI RatingControl")
108+
.FontSize(14).SemiBold(),
109+
110+
new StarMeterElement
111+
{
112+
Value = rating,
113+
MaxRating = 5,
114+
Caption = "Rate this page",
115+
OnValueChanged = setRating,
116+
},
117+
118+
TextBlock($"current rating: {rating:0.0}"),
119+
120+
HStack(8,
121+
Button("Reset", () => setRating(0)),
122+
Button("5 stars", () => setRating(5)))
123+
).Padding(20);
124+
}
125+
}
126+
// </snippet:star-meter-usage>
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
app:
2+
title: "Extending Reactor"
3+
width: 540
4+
height: 360
5+
startup-delay: 1500
6+
7+
screenshots:
8+
- id: star-meter
9+
description: "Custom StarMeter element wrapping WinUI RatingControl, registered via a descriptor and used like any built-in."
10+
component: ExtendingApp
11+
region: client
12+
format: png
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
<PropertyGroup>
3+
<OutputType>WinExe</OutputType>
4+
<TargetFramework>net10.0-windows10.0.22621.0</TargetFramework>
5+
<Platforms>x64;ARM64</Platforms>
6+
<ImplicitUsings>enable</ImplicitUsings>
7+
<Nullable>enable</Nullable>
8+
<UseWinUI>true</UseWinUI>
9+
<WindowsPackageType>None</WindowsPackageType>
10+
<NoWarn>$(NoWarn);REACTOR_V1_PREVIEW</NoWarn>
11+
</PropertyGroup>
12+
<ItemGroup>
13+
<PackageReference Include="Microsoft.WindowsAppSDK" Version="$(WindowsAppSDKVersion)" />
14+
</ItemGroup>
15+
<ItemGroup>
16+
<ProjectReference Include="..\..\..\..\src\Reactor\Reactor.csproj" />
17+
</ItemGroup>
18+
</Project>
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
using Microsoft.UI.Reactor;
2+
using Microsoft.UI.Reactor.Core;
3+
using Microsoft.UI.Reactor.Core.V1Protocol;
4+
using Microsoft.UI.Reactor.Core.V1Protocol.Descriptor;
5+
using static Microsoft.UI.Reactor.Factories;
6+
using Microsoft.UI.Xaml;
7+
using Microsoft.UI.Xaml.Media;
8+
using WinUI = Microsoft.UI.Xaml.Controls;
9+
using Windows.UI;
10+
11+
System.AppContext.SetSwitch("Reactor.UseV1Protocol", true);
12+
ReactorApp.Run<V1ProtocolApp>(
13+
"V1 Protocol Demo", width: 520, height: 360, devtools: true,
14+
configure: host => LedIndicatorRegistration.Register(host.Reconciler));
15+
16+
// <snippet:element-record>
17+
// An Element record describes what you want on screen — no WinUI types, no
18+
// mutable state. The reconciler diffs Element values across renders;
19+
// records get value-equality for free, so unchanged subtrees skip Update.
20+
public sealed record LedIndicatorElement : Element
21+
{
22+
public required Color Color { get; init; }
23+
public bool IsOn { get; init; } = true;
24+
public double Size { get; init; } = 16;
25+
}
26+
// </snippet:element-record>
27+
28+
// <snippet:descriptor>
29+
// A descriptor declares property bindings against the WinUI control the
30+
// element targets. The framework's DescriptorHandler interprets the entries
31+
// during Mount and Update — there is no per-element interpreter overhead
32+
// beyond a dictionary lookup and the entry-loop iteration.
33+
public static class LedIndicatorDescriptor
34+
{
35+
public static readonly ControlDescriptor<LedIndicatorElement, WinUI.Border> Descriptor =
36+
new ControlDescriptor<LedIndicatorElement, WinUI.Border>
37+
{
38+
// The descriptor's children strategy says "no children" — this is
39+
// a leaf control. See ChildrenStrategy survey in the prose.
40+
Children = new None<LedIndicatorElement, WinUI.Border>(),
41+
}
42+
// OneWay: write on Mount, diff-and-write on Update. Equality skips
43+
// the write when the element value didn't change.
44+
.OneWay(
45+
get: static e => e.Size,
46+
set: static (c, v) => { c.Width = v; c.Height = v; c.CornerRadius = new Microsoft.UI.Xaml.CornerRadius(v / 2); })
47+
// The IsOn → Background mapping coerces both inputs onto one WinUI
48+
// property. A single OneWay entry observes (Color, IsOn) jointly
49+
// by reading both off the element in the get/set lambdas.
50+
.OneWay(
51+
get: static e => (e.Color, e.IsOn),
52+
set: static (c, v) =>
53+
c.Background = new SolidColorBrush(
54+
v.IsOn ? v.Color : Color.FromArgb(0x40, v.Color.R, v.Color.G, v.Color.B)));
55+
}
56+
// </snippet:descriptor>
57+
58+
// <snippet:registration>
59+
// Registration is one call per Reactor host. RegisterDescriptor wraps
60+
// RegisterHandler<...>(new DescriptorHandler<...>(descriptor)) — both shapes
61+
// land on the same dispatch table. Duplicate registrations for the same
62+
// element type throw.
63+
public sealed class LedIndicatorRegistration
64+
{
65+
public static void Register(Reconciler reconciler)
66+
{
67+
reconciler.RegisterHandler<LedIndicatorElement, WinUI.Border>(
68+
new DescriptorHandler<LedIndicatorElement, WinUI.Border>(
69+
LedIndicatorDescriptor.Descriptor));
70+
}
71+
}
72+
// </snippet:registration>
73+
74+
// <snippet:usage>
75+
// Once registered, the element is used the same way as any built-in. The
76+
// reconciler dispatches LedIndicatorElement to the registered handler;
77+
// every other element type continues to flow through the built-in path.
78+
class V1ProtocolApp : Component
79+
{
80+
public override Element Render()
81+
{
82+
var (level, setLevel) = UseState(2);
83+
84+
return VStack(12,
85+
TextBlock("LED indicator (custom V1 element)").FontSize(14).SemiBold(),
86+
HStack(8,
87+
Led(Colors.Red, isOn: level >= 1),
88+
Led(Colors.Orange, isOn: level >= 2),
89+
Led(Colors.Yellow, isOn: level >= 3),
90+
Led(Colors.Green, isOn: level >= 4)),
91+
HStack(8,
92+
Button("-", () => setLevel(System.Math.Max(0, level - 1))),
93+
Button("+", () => setLevel(System.Math.Min(4, level + 1))),
94+
TextBlock($"level = {level}").VAlign(VerticalAlignment.Center))
95+
).Padding(20);
96+
97+
Element Led(Color c, bool isOn) =>
98+
new LedIndicatorElement { Color = c, IsOn = isOn, Size = 28 };
99+
}
100+
}
101+
// </snippet:usage>
102+
103+
// Bootstrap: register the descriptor against the host's reconciler once,
104+
// before the first render. ReactorApp.Run exposes the host via a startup
105+
// callback; we use the simpler ConfigureHost extension that matches the
106+
// pattern most app authors will follow.
107+
//
108+
// (In a real app this lives next to other interop registrations, e.g.
109+
// DockingNativeInterop.Register. The doc-app shape keeps it inline.)
110+
public static class Bootstrap
111+
{
112+
static Bootstrap() { /* registration happens via app callback below */ }
113+
}
114+
115+
public static class Colors
116+
{
117+
public static readonly Color Red = Color.FromArgb(0xFF, 0xE8, 0x1A, 0x1A);
118+
public static readonly Color Orange = Color.FromArgb(0xFF, 0xF5, 0x9E, 0x0B);
119+
public static readonly Color Yellow = Color.FromArgb(0xFF, 0xFA, 0xCC, 0x15);
120+
public static readonly Color Green = Color.FromArgb(0xFF, 0x22, 0xC5, 0x5E);
121+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
app:
2+
title: "V1 Protocol Demo"
3+
width: 520
4+
height: 360
5+
startup-delay: 1500
6+
7+
screenshots:
8+
- id: led-indicator
9+
description: "Custom LED indicator element registered via a descriptor and rendered alongside built-in controls."
10+
component: V1ProtocolApp
11+
region: client
12+
crop: none
13+
format: png
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
<PropertyGroup>
3+
<OutputType>WinExe</OutputType>
4+
<TargetFramework>net10.0-windows10.0.22621.0</TargetFramework>
5+
<Platforms>x64;ARM64</Platforms>
6+
<ImplicitUsings>enable</ImplicitUsings>
7+
<Nullable>enable</Nullable>
8+
<UseWinUI>true</UseWinUI>
9+
<WindowsPackageType>None</WindowsPackageType>
10+
<NoWarn>$(NoWarn);REACTOR_V1_PREVIEW</NoWarn>
11+
</PropertyGroup>
12+
<ItemGroup>
13+
<PackageReference Include="Microsoft.WindowsAppSDK" Version="$(WindowsAppSDKVersion)" />
14+
</ItemGroup>
15+
<ItemGroup>
16+
<ProjectReference Include="..\..\..\..\src\Reactor\Reactor.csproj" />
17+
</ItemGroup>
18+
</Project>

0 commit comments

Comments
 (0)