A declarative, React-style UI library for Rust, backed by WinUI 3.
- 📦 Not published to crates.io
- 🚀 Getting started
- 📁 Source
- 🧩 Samples
windows-reactor lets you describe a WinUI 3 user interface as a function of
state. You write a render function that takes a RenderCx and returns an
Element; the reactor diffs the result against the live visual tree and applies
only the changes. State lives in hooks such as cx.use_state, and updating it
schedules a re-render.
A reactor app needs three things: the crate dependency, a render function, and a
build.rs that stages the Windows App SDK runtime via
windows-reactor-setup.
Cargo.toml:
[dependencies]
windows-reactor = "..."
[build-dependencies]
windows-reactor-setup = "..."build.rs — pick the helper that matches your deployment model:
fn main() {
// For a self-contained app that carries its own runtime:
windows_reactor_setup::as_self_contained();
// Other options: as_framework_dependent(), as_example().
}src/main.rs — a render function plus App:
use windows_reactor::*;
fn app(cx: &mut RenderCx) -> Element {
let (count, set_count) = cx.use_state(0_i32);
vstack((
text_block(format!("Count: {count}")).font_size(28.0).bold(),
button("+").on_click(move || set_count.call(count + 1)),
))
.spacing(12.0)
.into()
}
fn main() -> Result<()> {
bootstrap()?;
App::new().title("Counter").render(app)
}bootstrap() initializes the Windows App SDK runtime and must be called once at
startup. App::new() is then a builder — title, inner_size, backdrop
(e.g. Backdrop::Mica), icon (path to an .ico file), fullscreen, and
presenter are common. render(app)
takes your Fn(&mut RenderCx) -> Element and runs the message loop.
Reactor catches panics at the FFI boundaries it owns (render/event callbacks and
ErrorBoundary), converting them to errors so they never unwind across the WinUI
ABI. It deliberately does not install a global panic hook. For panics that
escape outside those boundaries, add panic = "abort" to your release profile so
the process terminates cleanly instead of unwinding into WinUI's C++ frames (which
is undefined behavior):
[profile.release]
panic = "abort"Set RUST_BACKTRACE=1 when you want a backtrace — reactor leaves that to you.
Hooks are methods on RenderCx. They give a render function persistent state
without globals or thread_local!. The most common:
use_state(initial)→(value, SetState)— a value plus a setter. Callingset.call(new_value)updates the slot and schedules a re-render.use_ref(initial)→HookRef— mutable storage that does not trigger a re-render; read with.borrow(), write with.borrow_mut()or.set(v). Good for animation frame counters and cached resources.use_memo(deps, factory)— recompute a value only whendepschange.use_effect(deps, f)/use_effect_with_cleanup— run side effects whendepschange.use_reducer/use_reducer_fn— state driven by an update or action/reducer instead of a plain setter.use_resource(fetcher, deps)→Resource<T>— async data loading with loading/error states; aResourceconverts straight into anElement.use_context(&context)— read a value provided higher in the tree.
fn counter(cx: &mut RenderCx) -> Element {
let (count, set_count) = cx.use_state(0_i32);
button(format!("Clicks: {count}"))
.on_click(move || set_count.call(count + 1))
.into()
}The apps/examples and minimal/examples directories include focused samples for
each hook (use_state, use_ref, use_memo, use_effect, use_reducer,
use_resource, use_callback, use_color_scheme, …).
Elements are built with plain builder functions, each returning a widget that
becomes an Element via .into(). Containers take a tuple of children:
- Text:
text_block(content)with.bold(),.semibold(),.font_size(..),.wrap(),.selectable(), and type-ramp helpers (title,subtitle,body,caption, …). - Buttons:
button(content)with.on_click(..),.accent(),.subtle(),.enabled(..),.icon(..),.flyout(..),.menu_flyout(..). - Layout:
vstack((..))/hstack((..))with.spacing(..);grid((..))with.rows([..])/.columns([..])(usingGridLength::STAR,GridLength::Auto) and per-child.grid_row(n)/.grid_column(n).
Roughly 60 WinUI controls are wrapped, including check_box, combo_box,
slider, list_view, tree_view, navigation_view, tab_view, pivot,
text_box, number_box, color_picker, calendar_view, content_dialog,
info_bar, teaching_tip, command_bar, and more — see the
full catalog.
Layout and appearance modifiers are available on any Element (the ElementExt
trait): .margin(..), .padding(..), .width(..) / .height(..),
.horizontal_alignment(..) / .vertical_alignment(..) (with
HorizontalAlignment / VerticalAlignment), .background(..),
.foreground(..), .opacity(..), and transition helpers such as
.with_opacity_transition(..). Spacing values use Thickness (with
Thickness::uniform(..)).
Event handlers take closures. button(..).on_click(move || …) is the most
common; pointer and keyboard handlers live on ElementExt: .on_tapped(..),
.on_pointer_pressed(..), .on_pointer_released(..), .on_pointer_moved(..),
.on_pointer_entered(..), .on_pointer_exited(..), .keyboard_accelerator(..).
A SetState or Dispatch can be passed directly
wherever a handler is expected (via IntoCallback).
When a value-carrying event just forwards its argument to a setter, pass the setter directly instead of wrapping it in a closure:
// Prefer this — shorter, and the handler keeps a stable identity:
text_box(text).on_text_changed(set_text)
// Avoid this — a fresh closure is allocated every render:
text_box(text).on_text_changed(move |value| set_text.call(value))This is not just cosmetic. Setters from use_state/use_reducer are memoized
per hook slot, so passing one straight through hands the reconciler the same
handler identity each render. The diff can then skip the whole control. An inline
closure (move |v| set.call(v)) allocates a fresh identity every render, so the
control is always re-diffed and its WinUI event re-bound.
When a handler needs to compute a value or run extra logic — so a setter can't be
passed directly — wrap it in cx.use_callback(deps, …) to memoize it and recover
the same stable identity for hot paths. For the common case of a unit event
(on_click) that stores a fixed or pre-computed value, SetState::setter(value)
is shorthand for move || set.call(value):
button("Reset").on_click(set_count.setter(0)).
For custom 2D drawing, host a windows-canvas surface with
animated_canvas(draw) (enable the reactor feature on windows-canvas). It
returns a SwapChainPanel element that redraws every frame and recovers from
device loss automatically — see the canvas samples. For raw Direct3D, the
swap_chain_panel sample drives a SwapChainPanel with on_rendering.
To host a browser, use windows-webview's webview(on_ready)
(enable its reactor feature). It returns a WebView2 element backed by the WinUI
XAML WebView2 control and hands you a ready-to-drive WebView once the browser
initializes — see the reactor/webview sample. The as_self_contained() setup
carries the required Microsoft.Web.WebView2.Core.dll automatically. (How the
widget bridges the WinRT control to the COM ICoreWebView2 is covered in
windows-webview.)
The crates/samples/reactor
tree is the best reference:
samples— the smallest app plus anexamples/folder with ~90 focused per-control and per-hook examples (counter,calculator,navigation_view,list_view,content_dialog,color_picker, and many more).apps— complete applications:notepad,solitaire,minesweeper,tictactoe,dotsweeper.gallery— a WinUI-gallery-style shell with navigation across many controls.direct2d/swap_chain_panel— hosting Direct2D / Direct3D content.webview— hosting a WebView2 browser viawindows-webview'sreactorfeature.framework_dependent/self_contained— the two deployment models, differing only inbuild.rs.
The remainder of this page covers how the crate is built and maintained. It is
for contributors and is not needed to use windows-reactor.
The hooks runtime, element tree, reconciler, and WinUI backend are hand-written.
The per-widget dispatch is generated by tool_reactor from
crates/tools/reactor/src/winui.toml plus the WinUI .winmd metadata:
| Generated file | Contents |
|---|---|
src/generated.rs |
per-widget bindings() helpers |
src/backend/winui/generated_set_prop.rs |
property setter dispatch |
src/backend/winui/generated_attach_event.rs |
event handler dispatch |
crates/tools/reactor/src/generated.txt |
binding filter entries |
The tool is metadata-driven: setter pattern, value type, and event-invoke
pattern are all inferred from .winmd. TOML keys are WinUI metadata names, and
only non-standard mappings need overrides. Regenerate with
cargo run -p tool_reactor, then verify with cargo check -p windows-reactor.
Generated dispatch falls through to hand-written code in the backend for cases
too complex to express declaratively (Button icon+text layout, NavigationView
menu items, ContentDialog modal popup, and similar). Never edit the generated
files or generated.txt by hand.
src/bindings.rs is generated by windows-bindgen (cargo run -p tool_bindings)
from crates/tools/bindings/src/reactor.txt using --minimal mode — list
exactly the methods you need. Raw metadata names apply: get_Prop, put_Prop,
add_Event, remove_Event.
To prune or extend bindings: edit reactor.txt, regenerate, and let the compiler
errors reveal the methods you still need (SetX → put_X, X() → get_X); add
them as Ns.IFace::{put_X, get_Y}. This also covers the Win32 COM interfaces
(DXGI, D2D, DWrite) — listed methods get full vtable entries, unlisted methods
become usize slots, and the type closure is computed automatically.
These bite anyone editing the backend by hand. The generated code already follows them.
- Classes Deref to their default interface. Don't
castto it — call the method directly (button.SetFlyout(&flyout), notbutton.cast::<IButton>()?.SetFlyout(...)). This applies to event-handlersender/argstoo: the delegate hands you the concrete arg class and thesenderis the control, both of which alreadyDerefto their default interface — soargs.SelectedItem()and a control captured at attach (let control = h.clone();) read at zero per-event QI, versusargs.cast::<I…Args>()/sender.cast::<TextBox>()on every fire. Only cast to non-default parent interfaces (e.g.Button→IContentControl/IControl). Watch the static type, not the name:DropDownButton.cast::<IButton>()looks redundant butIButtonis a parent there (the default isIDropDownButton), so it is a genuine cast. Param<T>eliminates parent-class casts. A method takingimpl Param<Brush>accepts aSolidColorBrushdirectly — nocast::<Brush>().- Use
From/into(), notcast, forIInspectable, and plainNonefor optional inspectable parameters. put_IsChecked(CheckBox) takesOption<bool>— it is a tri-state nullable boolean.- TextBox/PasswordBox need get-before-set to avoid resetting the caret.
- ProgressBar uses
IRangeBasefor Value/Min/Max; ProgressRing has direct setters. - ContentDialog needs a
XamlRootfrom a live element, so it requires backend access. - Font properties are shared across
IControl/ITextBlock/IRichTextBlock.
Padding has no single owning interface: Control, Border, StackPanel,
TextBlock, and RichTextBlock each declare their own. set_padding
(backend/winui/mod.rs) therefore dispatches on the Handle variant — calling
the setter directly on Border, StackPanel, TextBlock, and RichTextBlock
through their default interface, and falling back to a single IControl cast for
everything else — so .padding(...) works on controls, borders, stack panels,
and text blocks. Containers that genuinely lack a Padding property (e.g. bare
Panel/Grid) still fall through to diag::unhandled_modifier, which warns
under debug builds; use .margin(...) there instead.
Background and Foreground follow the same pattern and are exposed as the
universal ElementExt modifiers .background(...) / .foreground(...): Border
handles them through its default interface while every other handle falls back to
a single IControl cast (set_background / set_foreground in
backend/winui/mod.rs), so they work on any Control. BorderBrush /
BorderThickness use the same IControl fallback in the backend
(set_border_brush / set_border_thickness), but are not ElementExt
modifiers — they are opt-in per-widget builders. Only Border and TextBox
currently expose .border_brush(...) / .border_thickness(...); the shared
backend dispatch means any other widget could expose them without new backend
work.
Reactor runs on a WinUI STA thread and keeps per-thread state in thread_local!
slots. Two categories exist, and the distinction matters when refactoring:
- STA-affine COM handles and caches (the host, application, root window and
framework element, and the shared
DataTemplate) must stay thread-local — they hold COM objects that are only valid on the UI thread. - One-shot latches and per-thread scalars (pending theme/title-bar requests,
current color scheme) are thread-local only because the public API exposes them
as free functions (
set_requested_theme, etc.). They could move onto the host struct if those functions took a host reference.
Reactor sits between the developer's Rust closures and WinUI's COM/extern "system" delegates. Failures can originate on either side, and where a failure
happens dictates what can be done with it. This section records the current
behavior, the inconsistencies it creates, and the target design.
| Boundary | Where it runs | Current handling | Reaches the developer? |
|---|---|---|---|
Synchronous setup — bootstrap, init_app_platform, icon validation |
main thread, before the message loop | Result from run/bootstrap |
Yes — genuinely propagates |
OnLaunched / setup callback — host creation, activate |
UI thread, inside Application::Start |
run_callback (catch_unwind → Result) → diagnostics::emit |
No — the Result dies inside OnLaunched; Application::Start never returns it, the loop keeps pumping |
Render pass — root.render, component renders |
dispatcher-posted render_loop |
uncaught, except subtrees under error_boundary (catch_unwind → fallback UI) |
Only if wrapped in error_boundary |
Event handlers / timer ticks / on_rendering |
invoked directly from WinUI delegates | uncaught | No — a panic aborts the process |
| Backend prop / COM application | applying props to controls | four different ways (see below) | mostly debug-only or silent |
The structural reason the last two boundaries abort: the generated delegate
Invoke thunks in bindings.rs are extern "system" with no catch_unwind.
A panic in an on_click, a DispatcherTimer tick, or on_rendering unwinds into
that boundary and aborts (after the default panic hook prints). The only two
places reactor catches panics today are run_callback (setup) and error_boundary
(render subtrees).
Resultthat cannot propagate.App::run/activatereturnResult, but every failure insideOnLaunchedis caught, logged, and then the app limps on (often windowless). The signature promises propagation the runtime cannot honor. This is distinct from the COM-plumbingResults inbackend/winui/mod.rs,app_shim.rs, andconvert.rs, which are mandatory and correct.- Same failure, different fate. A panic during render under an
error_boundarydegrades to fallback UI; the identical panic in that component'son_clickaborts the process. Developers cannot predict which, and nothing documents it. - Best-effort backend drops handled four ways:
let _ = fe.SetStyle(...)(silent),diag::com_error(debug log),diag::unhandled_prop/unhandled_modifier(debug log), and inlineif cfg!(debug_assertions) { eprintln! }(debug log). Three express the same intent with different code. - Three logging conventions:
diagnostics::emit(unconditional stderr, FFI/app level),diag::*(debug-only, backend), and raweprintln!(debug-only, scattered). The first two overlap in purpose. - No documented contract for what panics, what returns
Result, and what is silently dropped.
panic! usage itself is already consistent — the sites are rules-of-hooks and
invariant violations (hook order, type mismatch, EventHandler variant mismatch)
in engine.rs and backend/mod.rs. That is the correct use of panic! and stays.
The unifying principle: Result only where it can propagate (synchronous,
pre-loop); one fault hook for everything inside WinUI callbacks; panic! only for
bugs; one debug-log helper for best-effort drops.
- Programmer errors / invariants →
panic!(now caught, not fail-fast). The panic sites are unchanged (rules-of-hooks, type mismatches), but the outcome changed: a panic at a reactor-owned boundary unwinds into the fault boundary and is reported toon_fault(default: log-and-continue) rather than aborting the process.panic!is therefore decoupled from fail-fast — it now means "isolate and report this callback," not "kill the process." This relies onpanic = "unwind"(the Cargo default); under apanic = "abort"profile the entire model is bypassed (catch_unwindnever runs) and every panic aborts — the traditional whole-binary fail-fast posture, chosen by the app, not the library. When a specific fault is genuinely unrecoverable, escalate with the uncatchable primitives —std::process::abort()/exit(), either directly in the callback or as a branch insideon_fault— becausecatch_unwindcannot intercept those. - Synchronous, pre-loop configuration errors →
Resultfromrun/bootstrap(implemented). Validate up front so theResultis meaningful (asApp::iconpath validation already does — it runs on the calling thread beforeApplication::Start). - Failures inside UI-thread callbacks (render, event handlers, timers,
on_rendering) → one reactor-owned fault boundary, notResult(implemented). Reactor catches panics at the entry points it owns —Callback::invoke(every event handler), theDispatcherTimertick,on_rendering, and the render pass (render_once) — turning a panic into a controlled, logged fault instead of an abort, and delivering it to a developer-suppliedApp::on_fault(|fault| ...)hook (default: log-and-continue). The catch is context-aware: a callback that panics during a render pass is left to propagate soerror_boundarycan recover the subtree first; only panics outside render (or escaping every boundary) are reported toon_fault. Implemented as thefaultmodule (fault.rs): a thread-localIN_RENDERguard makesfault::catchtransparent during render and active outside it, whilefault::render_scopewraps the render pass. This makes "panic for bugs" safe (panics stop aborting) and makes the event-handler / render split predictable. - Best-effort backend prop application → one helper, one policy (implemented).
Collapsed the
let _ =,diag::com_error, and ad-hoceprintln!variants into a single debug-warn / release-noop helper (diag::warncore plusdiag::dropped, which reports the droppedResult's call site via#[track_caller]). Within the backend apply path (backend/winui/mod.rs) the silent and raw-eprintlnforms are gone; the only barelet _ =sites left there drop a non-Result(an unused parameter and a fire-and-forget event token). (host.rsretains a fewlet _ =drops on genuinely fire-and-forget window plumbing —WindowHandle,Activate, the cursorPostMessageW— which are not developer-requested configuration.) - Make
activate()honest (implemented).activatekeepsResultonly for its genuinely synchronous failures — the dispatcher lookup (DispatcherQueue::GetForCurrentThread) and enqueue (TryEnqueueWithPriority), both of which run on the calling thread and propagate to the caller. The deferred work that runs later inside the enqueued UI-thread callback (presenter / icon / backdrop) can no longer return aResultto anyone, so it routes to the fault path instead: the whole callback is wrapped infault::catch("activate", …)so a panic is a controlled fault rather than a process abort, and each best-effort configuration failure is delivered toon_faultviafault::report(replacing the swallowed innerResultand the ad-hoceprintln!). This required afault::report(context, message)companion tofault::catchfor reporting an explicit failure (not a panic) through the same handler. - Document the contract (this section) and surface it in the readme.
Answering the three framing questions directly: panic more? yes for bugs, but
only once the callback catch boundaries exist (otherwise more panics = more
aborts). println more? consolidate to one helper, but logging alone is invisible
in release — a fault hook, not just logging, is what makes failures actionable.
stop using Result? trim the semi-functional app/host-boundary Result; keep the
COM-plumbing and synchronous-setup Results, which work.
The reconciler skips unchanged controls (kind-matching plus shallow compare), so at steady state it creates no new WinUI controls — the diff/patch cost is dominated by COM property-set calls. Two deliberate non-features follow from this:
- No element pooling. With zero controls created at steady state there is nothing to recycle; COM creation is a fixed-cost FFI call with no GC pressure.
- No rerender depth guard. The render loop is non-recursive —
set_stateduring a render sets a dirty flag and enqueues the follow-up render through the dispatcher rather than re-entering, so unbounded recursion is impossible.
State writes are coalesced through the dispatcher (many set_state calls in one
turn produce a single render).
The test_reactor_perf app (crates/tests/libs/reactor_perf) is a deliberate
port of the C# microsoft-ui-reactor stress_perf harness: same stocks-grid
workload (~4,800 cells here, 80×60; ~4,900 in C#, 70×70), the same
--headless --percent N --duration S CLI, and the same report — both write
<AppName>.report.txt with Total Renders and Renders/sec, so the numbers are
directly comparable on the same machine and power state.
The C# harness ships two reactor variants; the Rust crate has only one because its idioms (cached cells, memoized setters) are always on:
StressPerf.Reactor— naive baseline, "what an unaware user writes."StressPerf.ReactorOptimized— spec-034 perf idioms (direct record-initializer construction +UseMemoCells). This is the fair comparison for Rust, sincewindows-reactoralready caches cells and skips unchanged subtrees by default.
Run both (from each repo root) and diff the Renders/sec line:
# Rust (this repo)
cargo run -q --release -p test_reactor_perf --bin test_reactor_perf -- `
--headless --percent 50 --duration 10
# C# (D:\git\microsoft-ui-reactor) — match your platform (x64 / ARM64)
dotnet run --project tests/stress_perf/StressPerf.ReactorOptimized -c Release `
-p:Platform=x64 -- --headless --percent 50 --duration 10--percent is the fraction of cells mutated per tick; hold it (and --duration)
equal across both runs. Renders/sec is a render-count throughput proxy — good for
cross-framework comparison but not the same as the user-perceived ETW Present rate;
see the C# stress_perf/METHODOLOGY.md for that distinction and the admin-mode
present-tracer harness.
A point-in-time snapshot (same x64 Release box, --percent 50 --duration 10,
median of two runs) — refresh when the workload or either framework changes:
| Metric | Rust windows-reactor |
C# ReactorOptimized |
|---|---|---|
| Renders/sec | ~8.7 | ~4.1 |
| Avg Reconcile | ~7.9 ms | ~46 ms |
| Avg Diff | ~7.1 ms | ~39 ms |
| Avg Memory | ~190 MB | ~285 MB |
Both hold renders/tick ≈ 1.0, so the throughput gap tracks reconcile cost: the
Rust diff fits inside the frame tick while the C# reconcile gates its frame rate.
Both stacks are framework-dependent and bootstrap into the same installed
WinAppSDK 2.0 runtime (Microsoft.WindowsAppRuntime.2), so the XAML/WinUI layer is
identical — only the language runtime above it (native Rust vs .NET CoreCLR) differs.
Inline handlers (button("x").on_click(|| …)) allocate a fresh Callback every
render, so their identity always differs and the reconciler rebinds the WinUI
event each time a control is diffed: revoke the old delegate, QI-cast to the
event interface (e.g. IButtonBase), and add a new one. A trampoline — bind the
WinUI delegate once and have it read the current handler from an
Rc<RefCell<…>> slot, so a change becomes a slot write — was prototyped and
measured against this cost.
An isolated microbenchmark (1000 buttons, handler-only churn, no layout) put the
WinUI rebind at ~2.7 µs and the slot-write at ~1.7 µs: ~0.95 µs saved per rebind.
That only matters under pathological churn (thousands of inline handlers
re-binding per frame); a normal app rebinds a handful per frame, where the
absolute saving is negligible. The trampoline was rejected — it adds
per-control slot state and codegen across every event arm without a practical
win. The real lever for hot handlers is a stable handler identity, which lets
can_skip_update skip the control's whole diff, not just the rebind. Two sources
of stable identity exist: use_callback (memoized closure) and — because state
setters are themselves memoized per hook slot — a use_state/use_reducer setter
passed straight to an on_* handler.
Each control is held as a Handle enum whose variant is the concrete WinUI
class (Handle::TextBlock(bindings::TextBlock), …). cast_inner::<T>() matches
the variant and calls windows_core::Interface::cast, which is a COM
QueryInterface — and on XAML's aggregated objects a QI is comparatively
expensive, especially a failing one.
Shared modifiers that apply across many control families (padding, foreground,
font_size/font_weight/font_family) used to probe interfaces with an
if let Ok(_) = cast_inner::<IControl>() … else if … ITextBlock … else … chain.
For a TextBlock that meant 1–2 failed QIs before the successful one. A perf
run (80×60 grid of text cells whose foreground flips each frame) showed ~44 % of
all cast_inner calls failing — ~9,400 wasted QIs/second, every one an
IControl probe against a TextBlock (which derives from FrameworkElement,
not Control).
Because the variant already names the concrete type, these setters now match
the handle instead of probing. A class derefs to its default interface
(bindings::TextBlock: Deref<Target = ITextBlock>), so the common text cases call
the setter directly at zero QI; everything else falls through to a single
IControl cast. After the change the same perf run drops to <100 failed QIs in
5 seconds (effectively zero), with no behavior change (all self-test fixtures
pass). set_background keeps a short probe: its targets span IPanel (five panel
variants), IControl, and IBorder, and it is not on a measured hot path.
Health/efficiency leads surfaced while profiling event-handler churn. Items 1, 2, and 6 have since landed; the rest are not committed work and each needs measurement on representative trees before investing.
use_callbacknow skips unchanged controls (fixed). Previously everyon_*builder re-wrapped its closure in a freshCallback::new, so even a stableuse_callbackresult got a new identity each render andcan_skip_updatenever skipped the control —use_callbackwas inert for widget events. The setters now acceptimpl IntoCallback<T>(orimpl IntoUnitCallbackfor parameterless handlers likeButton::on_click) and call.into_callback(), so an existingCallbackflows through with its identity preserved while bare closures still work unchanged. Pass ause_callbackresult straight toon_clickto get the win. Measured on a 1000-button handler-churn bench (reconcile ms per frame): inlineon_click2.50 ms / 1001 diffed;use_callbackwrapped in a closure 2.49 ms / 1001 diffed (still re-wrapped, so no effect);use_callbackpassed directly 0.10 ms / 0 diffed (the unchanged subtree is pruned at the root in one compare — ~24×).- State/reducer setters are memoized so passing them directly skips too
(fixed).
make_state_setter/make_updaterused to allocate a freshRcevery render, so even thoughIntoCallback for SetStatereuses thatRc(from_rc, not a freshCallback::new), the identity still changed each frame andon_text_changed(set_name)never skipped. Each slot now caches its handle (HookSlot::State { handle, .. }) and a single genericmemo_handlehelper builds it once and clones it each render, so the common "handler just calls a setter" pattern skips withoutuse_callback. The same helper backs bothuse_state(SetState) anduse_reducer(Updater).use_reducer_fn'sDispatchis deliberately not memoized: it captures the userreducer(memoizing would pin the first render's closure), and it is almost always wrapped at the call site (on_click(move || dispatch.call(Action::X))), so a stable identity would not help skipping anyway. Reconcile bench (settermode,on_text_changed(set_text)): 0.10 ms / 0 diffed — matching theuse_callbackpath. Thealloc_benchtool (counting global allocator +RenderCx, no WinUI) measures steady-state heap allocations per render:use_state0,use_reducer0 (was 1 before memoization),use_callback/use_ref0,use_reducer_fn2 (the un-memoized reducer + dispatchRcs).use_callbackis now only needed for handlers that close over render-derived data (where deps must gate the rebuild). diff_propsis O(n²) per control (low–medium).find_prop/find_event(widget.rs) are linear scans called inside the per-binding loop. Fine for the handful of bindings most controls carry; profile the binding-count distribution on real trees before considering sorted bindings / a small-map.- Silently-dropped props (partly fixed). Unsupported
(prop, control)combos hitdiag::unhandled_modifier(backend/winui/diag.rs), which both floods debug output per control creation and silently no-ops a prop the author thinks applies. ThePadding-on-TextBlock/StackPanelcase was a real gap — both types own aPaddingproperty WinUI supports, but the bindings filter andset_paddingonly coveredControl/Border;set_paddingnow also casts toITextBlock/IRichTextBlock/IStackPanel. Remaining work: audit the rest of the dropped set and either support each combo or surface it once rather than per-control. - Per-prop WinUI set cost (measured — no steady-state lever).
set_propwas instrumented to time each call keyed byPropand run against thetest_reactor_perfgrid (the headless self-test does not commit to the WinUI backend, so it never callsset_prop— the perf app is the only set-prop workload). Findings: steady state is dominated entirely byTextandForegroundvolume (~2.4 µs/call each, one set per genuinely-changed cell — no redundant sets), and those are irreducible thin COM property sets, so there is no batching/deferral win on the hot path. The expensive-per-call props are all one-shot at creation:Button.Content~300 µs/call (confirms the measure/arrange hypothesis — ~125× aTextset, but only paid when a button's label changes), andGridRows/GridColumns~800 µs/call (each rebuilds N row/column definitions in a loop). None sit on a steady-state path, so the lead is closed: the reconciler already issues the minimum number of sets and the costly props are rare creation-time operations. - Redundant default-interface QIs eliminated (fixed). A sweep of the WinUI
backend removed
casts that re-QueryInterfacean object to an interface it alreadyDerefs to (see COM pitfalls). Three buckets: (a) event-handlersendercasts — value-change handlers (TextChanged,PasswordChanged,Toggled,SelectionChanged,ValueChanged, RichEditBox, Pivot/ComboBox, etc.) castsenderto the control on every fire; they now capture the typed handle at attach and read throughDerefat 0 QI. The generator (gen_attach.rs::gen_sender_getter) emits this capture pattern, so the five generated handlers are fixed at the source. (b) event-handlerargscasts —DragEventArgs/NavigationViewSelectionChangedEventArgs/KeyboardAcceleratorInvokedEventArgseachDerefto the interface the code was casting to; the drag path (build_drag_context/accept_or_reject, hot on everyDragOver, plusDragEnter/Drop) dropped itsIDragEventArgs/IDataPackageView/IDragOperationDeferralcasts and two private helpers were retyped to the concrete classes. (c) hand-writtenset_prop/build casts —RowDefinition/ColumnDefinition/BitmapImagecast to their own default interface. Capturing a control in its own handler creates a delegate→control cycle; this is severed by the existing revoker teardown (validated by theevent_detachmentandrepro_leak_header_paneself-test fixtures). The remaining casts are all genuine: parent interfaces (IPanel,IControl,IFrameworkElement,DropDownButton→IButton), versioned interfaces (ICompositor2,INavigationView2),IInspectable→class downcasts, and collection interfaces — none are removable. Keep new hand-written handlers to the capture-at-attach pattern so this does not regress. To hunt for redundant casts dynamically, enable the opt-inwindows_cast_diagnosticscfg onwindows-core(debug-only, off by default, zero release impact): it warns to stderr — with the exact#[track_caller]call site — whenevercast'sQueryInterfacereturns the same interface pointer it started from (i.e. the source already exposes that interface). Set it viaRUSTFLAGS(or uncomment the line in.cargo/config.toml), e.g.$env:RUSTFLAGS = "--cfg windows_cast_diagnostics"; cargo run -p test_reactor_selftest -- --headlessand grep the output. A hit is usually a class cast to its own default interface (replace withDeref); a cast toIUnknown/IInspectableis reported too (prefer.into()), though in practice this codebase has ~none since it already uses.into(). The report counts every invocation, so sort/unique by call site. Acting on it once took the self-test from ~1000 hits across ~36 sites down to 4: the leads it surfaced beyond the backend —app_shim.rs(IXamlMetadataProvider) and the self-test harness/exec.rs(IDispatcherQueueand ~30 builder casts) — are all cleaned. The 4 residual hits are genuine false positives the heuristic cannot distinguish:factory_cache.rs's runtimeIAgileObjectagility probe andgeneric_factory.rs's type-erasedIInspectable→class activation, where the same-pointer result is incidental and the cast is not statically removable. - Casts hidden behind
Param<T>/required_hierarchy!(examined, nothing to reduce). Besides explicit.cast()s, conversions also happen implicitly when a value is passed asimpl Param<T>.windows-bindgenstatically classifies every conversion inbindings.rsinto one of two macros (crates/libs/core/src/imp/mod.rs):interface_hierarchy!(CanInto::QUERY = false) for the free relationships — every type →IUnknown/IInspectable, and a class → its default interface — whichParamresolves with atransmute_copy(zero cost), versusrequired_hierarchy!(QUERY = true) for a class → a non-default required interface and an interface → a required (sibling) interface, whichParamresolves with a realQueryInterface(crates/libs/core/src/param.rs). Therequired_hierarchy!entries are therefore the implicit form of the genuine "cast to a non-default parent" calls in item 6 (e.g.SolidColorBrush→Brush,Button→IControl) — one QI per call, not redundant, and not statically removable (transmuting to a vtable the object does not expose would be unsound). bindgen has already done the removable work by routing every transmute-able conversion throughinterface_hierarchy!/Derefinstead (the class default interface is explicitly filtered out ofrequired_hierarchy!). The only lever left is the same hot-path rule as item 6: if aParam-driven non-default conversion lands on a steady-state path, capture the converted handle once rather than re-converting each call. Thewindows_cast_diagnosticsprobe instruments thisParam/self.cast()path too, and (as expected) never reports these as same-pointer hits — confirming they are genuine QIs, not redundant ones.
windows-reactor and windows-canvas define some of the same short names for
different domains. The rule: canvas keeps the short name (it owns user-facing draw
loops) and reactor takes a domain-prefixed alternative. Color → ColorF in
canvas is done; Brush, Ellipse, and FontWeight still overlap.
Unit tests live in test_reactor (headless). Integration tests live in
test_reactor_selftest, which launches a real WinUI window — pass --headless
for CI.
Most pointer handler behavior (attach/detach, memoization, slot changes) is
covered headlessly in test_reactor via the RecordingBackend. The one path
that needs a live PointerRoutedEventArgs — the backend's set_pointer_handlers
wiring and pointer_event_info extraction — is exercised end-to-end by the
Pointer_Injection_Gesture selftest fixture, which drives real OS mouse input
through the WinRT InputInjector (move → press/release left and right → exit)
and asserts the reactor's on_pointer_* callbacks fire with the right position
and button flags. Because OS input injection requires the harness window to be
foreground at the injected screen point, the fixture records a TAP # SKIP
(never a failure) when the host can't deliver input (locked session, foreground
lock, no interactive desktop), so it can't flake CI.
The dispatcher-thread hooks in hooks.rs — DispatcherTimer (repeating and
one-shot) and the CompositionTarget::Rendering subscription (on_rendering) —
are likewise only meaningful on a live WinUI thread, so they are covered by the
Timer_* and Rendering_Subscription_* selftest fixtures. These wait on a
wall-clock-bounded Harness::pump_until (not the iteration-bounded render_until)
so a real DispatcherQueueTimer interval can elapse, and assert both that the
callback fires and that dropping the RAII handle stops it. The rendering fixture
soft-SKIPs when the agent delivers no composition frames.
The RecordingBackend harness (and its Op log) lives in the test_reactor
crate, not in windows-reactor, so it adds no weight to normal builds. The few
engine/reconciler/widget inspectors that need access to private fields stay in
windows-reactor behind the test feature, which the test crates enable and
normal/published builds leave off. This is the rule for all tests: no
#[cfg(test)] modules inside the published library crates — put the test in the
matching test_* crate. If it needs an internal item, expose that item behind the
existing test feature rather than adding bespoke scaffolding (the engine/
reconciler/widget inspectors work this way). Don't invent a feature — or a public
helper — just to test a trivial pure function; leave it private and untested.
Concrete friction hit by an external app built on a pinned reactor rev
(netmon-rs, a WinUI 3 network monitor;
see its reactor-notes.md). Unlike the C# parity catalog below, these are
specific bugs/gaps with a known reproduction. Recorded so we can track progress
and, where possible, close the "had to drop to the raw windows crate" cases.
-
Nested component doesn't re-render from its own
use_state(fixed). A component that mutates only its ownuse_state, buried under structurally unchanged non-component parents (e.g.scroll_viewer→grid), never re-rendered: itsstate_dirtyflag was set andrequest_rerenderfired, but the pass pruned the subtree before descending to it. Root cause:peek_state_dirty(engine.rs) was consulted only viais_component_state_dirtyat the node currently being visited (reconciler.rsupdate,reconciler/child.rs), so a dirty component below a pruned parent was skipped. Context changes avoided this becauseforce_context_subscriberssets the globalforce_component_rerenderflag that punches through pruning (reconciler.rs); plainuse_statewrites had no equivalent path — that asymmetry was the whole bug (the root component itself was never affected: the host re-renders it every pass via its ownRenderCx, and the symptom was also masked whenever some prop on the path changed anyway, e.g. a sibling chart's per-tickrevision, forcing descent for an unrelated reason). Fixed:Reconciler::reconcilenow callsforce_state_dirty_components, which scanscomponent_instancesfor any entry reportingpeek_state_dirty()and seedsforced_components/ setsforce_component_rerenderthe same way the context path does, plus adebug_assert!that no seeded instance is still dirty after the pass (a dropped re-render failed silently before). Regression:test_reactor::nested_state_rerender. Related trap still worth documenting: reactor'sTextBoxis controlled (pushesProp::Valueevery render), so round-tripping a controlled field through global state on every keystroke races persistence and can truncate long values — add uncontrolled-input guidance or a first-class uncontrolled text mode. -
Window icon (fixed). WinUI 3 doesn't adopt the exe's embedded icon for the title-bar/taskbar icon, and reactor previously exposed no way to set it —
AppWindow.SetIconwas stubbed out of the bindings andIAppWindow/IWindowNative::WindowHandlearepub(crate), forcing apps to addWin32_System_LibraryLoader+Win32_System_Threading+Win32_UI_WindowsAndMessagingto the rawwindowsdependency and re-find their own window (EnumThreadWindowsby title —FindWindowWdoesn't find WinUI windows) toSendMessageW(WM_SETICON). Now:SetIconis un-stubbed in the reactor bindings filter (crates/tools/reactor/src/base.txt, theIAppWindowmethod list), andApp::icon(path)sets the window icon from a path to an.icofile.ReactorHost::set_iconapplies it on the UI thread inactivate()viaAppWindow.SetIcon(host.rs), alongside the presenter/backdrop application. The application is best-effort (a failure is logged, not fatal), matching how presenter/backdrop degrade — surfacing it as aResultfromactivate()is not viable because host setup runs inside WinUI'sOnLaunched, whose error does not propagate out ofApplication::Start(the loop keeps pumping).AppWindow.SetIconalso silently tolerates a missing file, soApp::runinstead validates the path up front on the calling thread — beforeApplication::Start— and returns a realResulterror for a missing icon, which is the only architecturally sound place to give the caller one. Sample:cargo run -p reactor_samples --example icon. -
Swapping a canvas out of the tree leaks its render loop (fixed). A field report described conditionally swapping one keyless subtree for a shorter, differently shaped one (a per-target card
vstackwith a nestedcomponent(spark_view)→ a compact legendhstack), which left orphaned sparkline surfaces on screen; adding distinct.with_key(...)to each layout appeared to fix it. Investigation showed the reconciler is not at fault — keyless positional reconcile correctly unmounts and destroys nestedSwapChainPanels when a container shrinks (reconciler/child.rs; verified against the exact card/legend shape). The real leak was inwindows-canvas'sanimated_canvas: its per-frameRenderStateholds theCompositionTarget::Renderingsubscription and the swap chain in a reference cycle (the rendering callback captures anRcback to the cell that owns it), and it registered noon_unmountedhandler — so refcounting alone could never drop it. When the panel left the tree the render loop kept firing and presenting to the detached swap chain (the "leftover mini-charts"). Keying only masked it by reshuffling which panels survived. Fixed by adding anon_unmountedteardown that clears the state cell, dropping theRenderStatein place (revoking the subscription and releasing the swap chain). Regression:test_canvas::animated_canvas_installs_unmount_teardown. -
On-demand D2D drawing surface pulls in the raw
windowscrate (gap). The app draws a once-per-second chart into reactor'sSurfaceImageSourcewith hand-rolled D3D11/D2D/DXGI/DirectWrite, plus a hand-managed sharedID2D1Device.windows-canvasalready wraps D2D safely (device, drawing session, text) but only targets a continuously-renderedSwapChainPanelviaanimated_canvas— there is no canvas-backed on-demandSurfaceImageSourcepath, and reactor'sSurfaceImageSourcewidget requires a caller-suppliedID2D1Device. For content that redraws on a data change rather than every frame, a continuous swap chain is the wrong tool, so raw D2D was the only option. A canvas-backed on-demand surface (shared device + safe drawing into aSurfaceImageSource) would let such apps avoid thewindowscrate entirely.
windows-reactor is the Rust counterpart of the C# microsoft-ui-reactor
framework (microsoft-ui-reactor), already referenced above for the
stress_perf performance benchmarks. The C# project covers considerably more
surface area — ~50 hooks, ~80 control factories, and whole subsystems for
validation, charting, docking, and localization. This section catalogs those gaps
so contributors can see the intended direction. As with canvas, the goal is
idiomatic Rust coverage of what real apps need, not a mechanical 1:1 port.
Ordered roughly by user impact; "present" notes what already exists.
Present: use_state, use_ref, use_memo, use_effect
(+use_effect_with_cleanup), use_reducer/use_reducer_fn, use_resource,
use_context, use_callback, use_color_scheme — the core ~11.
The C# framework exposes ~50. Notable missing groups:
- Async / data —
use_mutation,use_infinite_resource,use_data_source. - Observable binding —
use_observable,use_observable_tree,use_observable_property,use_collection(INPC / observable-collection integration). - Window / system —
use_window_size,use_breakpoint,use_dpi,use_window/use_window_state/use_is_active,use_window_position,use_displays,use_closing_guard,use_file_picker/use_folder_picker,use_window_drag_move,use_tray_icon,use_open_window. - Theme / a11y / environment —
use_is_dark_theme,use_high_contrast,use_reduced_motion,use_announce,use_intl,use_persisted. - Focus —
use_focus,use_focus_trap,use_element_focus,use_element_ref. - Commanding —
use_command(see §3). - Tooling —
use_devtools, theuse_memo_cellsfamily.
Each is independently shippable.
Present: a single App window builder (title, inner_size, backdrop,
presenter, fullscreen).
Missing: secondary windows (ReactorWindow / use_open_window), tray icons,
window drag-move, multi-display awareness, window position/state persistence,
close-guard confirmation, and file/folder pickers. These turn the crate from
"single-window app shell" into a general desktop-app framework.
Present: direct on_click and keyboard_accelerator per control.
Missing: a Command abstraction (Command, StandardCommand,
CommandBindings, use_command) — a reusable, enable/disable-aware action bound
once and shared across buttons, menu items, and accelerators. The C# control
factories all accept a command (button(command), menu_item(command), …); the
Rust equivalents accept only closures.
Present: state hooks and one-way rendering from state.
Missing the entire form/validation and observable stack: FormField,
ValidationRule, ValidationContext, ValidationVisualizer, and the
observable-collection hooks from §1. This is what makes data-entry UIs ergonomic.
Present: ~60 wrapped WinUI controls (text, buttons, inputs, layout, navigation, lists, dialogs).
Missing the C# composite controls built on top of WinUI:
VirtualList— large-collection virtualization beyond rawListView.DataGridandPropertyGrid— tabular / reflection-driven editing (TypeRegistry,TypeMetadata).MaskedTextBox/AutoSuggestinput controls and input formatters.- Flexbox layout — the C# project embeds a Yoga flexbox engine
(
Flex/FlexRow/FlexColumn,UniformGrid,WrapGrid); Rust currently relies on WinUIStackPanel/Gridonly.
Present: implicit transitions (with_opacity_transition, with_scale_transition).
Missing the richer animation system: keyframe animations, layout animations,
interaction-state animations, stagger configuration, and connected / scroll-linked
animations (AnimationConfig, KeyframeAnimations, InteractionStates,
StaggerConfig). These belong on Windows.UI.Composition (the backend behind
reactor's existing transitions), which animates retained visuals off-thread;
sampling-based windows-animation is intentionally not
the fit here — it targets immediate-mode canvas drawing (see that crate's Future
work).
Present: the navigation_view control wrapper.
Missing: a navigation/routing layer — NavigationHandle, use_navigation,
use_navigation_lifecycle, use_system_back_button — that manages a page stack
and lifecycle rather than just rendering the chrome.
Present: background/foreground brush bindings and use_color_scheme.
Missing: a general theme/resource system — theme-keyed bindings (ThemeRef,
ThemeBindings), per-control resource overrides, and a resource builder
(ResourceBuilder, ResourceOverrides).
Present: pointer and tap handlers (and the coordinate/keyboard work tracked in
windows-canvas's Input and hit-testing).
Missing: a gesture-recognition and drag-and-drop layer (the C# Reconciler.Gestures
/ DragDrop / DragAttached machinery).
- Localization —
LocaleProvider, intl accessors,.reswproviders, pseudo-localization. - Accessibility tooling —
use_announce, an accessibility scanner, semantic panels. - Feature modules — Markdown rendering, charting, and docking are whole optional subsystems in the C# project with no Rust equivalent yet.
The hook breadth (§1) is the highest-leverage area and decomposes into many small, independent additions — start with the window/system and focus groups. Multi-window and pickers (§2) and commanding (§3) complete the desktop-app shell; the validation/observable stack (§4) and composite controls (§5) are larger efforts best taken once those foundations exist. Animations (§6), navigation (§7), theming (§8), and the remaining subsystems can land independently as demand arises.