Skip to content

Commit b5352d3

Browse files
fix(hooks): auto-marshal UseState/UseReducer setters to UI thread (#212)
UseState and UseReducer setters called from a background thread (Task.Run, PeriodicTimer, callbacks after ConfigureAwait(false)) previously hit a [Conditional("DEBUG")] AssertUIThread that either threw into a discarded Task (DEBUG) or compiled out entirely (RELEASE), producing silent UI update loss in both flavors. Replace the DEBUG-only assert with an always-on UI-thread check that auto-marshals the write and the resulting rerender onto the captured ReactorApp.UIDispatcher when called off-thread. The hot path adds one TLS read + int compare + branch (~1-2 ns); the marshal allocation is only paid on the off-thread path, where it's swamped by cross-thread dispatch cost. When no UI dispatcher has been captured (test/headless contexts), the off-thread call now throws a loud InvalidOperationException in both DEBUG and RELEASE instead of silently racing. threadSafe: true retains its existing locked-in-place semantics for callers that need many concurrent writers to serialize without an intervening UI tick. Adds a NonThreadSafeAutoMarshal selftest that mounts a real WinUI host and reproduces the issue's Task.Run + PeriodicTimer pattern with default hooks. Documents the auto-marshal contract in hooks.md and effects.md (first mention of `threadSafe` in the guide). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 242b8e2 commit b5352d3

8 files changed

Lines changed: 246 additions & 18 deletions

File tree

docs/_pipeline/templates/effects.md.dt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,15 @@ The `Func<Action>` overload of `UseEffect` returns a cleanup function. Here
5555
the cleanup disposes the timer, preventing leaks when the component unmounts
5656
or when `isRunning` changes.
5757

58+
The timer body runs on a background thread (it's inside `Task.Run`) and calls
59+
the `updateSeconds` reducer from there. That works out of the box — every
60+
`UseState` / `UseReducer` setter automatically marshals onto the UI dispatcher
61+
when called from a non-UI thread, so timers, `PeriodicTimer`, network
62+
callbacks, and code after `await ... ConfigureAwait(false)` can all call the
63+
returned setter without any extra opt-in. Pass `threadSafe: true` to the hook
64+
only when you need many concurrent setters to apply in-place (locked) instead
65+
of being queued one-by-one onto the UI thread.
66+
5867
## Async Data Loading
5968

6069
Use `UseEffect` with [`UseState`](hooks.md) to load data asynchronously:

docs/_pipeline/templates/hooks.md.dt

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,57 @@ object every render. `UseCallback` returns the same delegate instance as long
120120
as the dependencies haven't changed. This matters when passing callbacks to
121121
memoized [child components](components.md).
122122

123+
## Updating State From Background Work
124+
125+
`UseState` and `UseReducer` setters are safe to call from any thread. When you
126+
invoke a setter from a background task — inside `Task.Run`, from a
127+
`PeriodicTimer` loop, from a network callback, or after
128+
`await ... ConfigureAwait(false)` — the setter automatically marshals the write
129+
and the resulting re-render onto the UI dispatcher. You write the same code
130+
you'd write on the UI thread:
131+
132+
<!-- ai:lock -->
133+
```csharp
134+
public override Element Render()
135+
{
136+
var (seconds, setSeconds) = UseState(0);
137+
138+
UseEffect(() =>
139+
{
140+
var cts = new CancellationTokenSource();
141+
_ = Task.Run(async () =>
142+
{
143+
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
144+
while (await timer.WaitForNextTickAsync(cts.Token))
145+
setSeconds(s => s + 1); // auto-marshals to the UI thread
146+
});
147+
return () => cts.Cancel();
148+
});
149+
150+
return TextBlock($"Elapsed: {seconds}s");
151+
}
152+
```
153+
<!-- /ai:lock -->
154+
155+
Each cross-thread setter call costs one `DispatcherQueue.TryEnqueue` —
156+
microseconds, not free, but vastly cheaper than the bugs you'd hit writing the
157+
field directly from a worker. If you need many concurrent setters to apply
158+
in-place rather than serialize through the UI thread (typical for ingest loops
159+
that hammer the same hook from multiple producers), pass `threadSafe: true` to
160+
the hook:
161+
162+
<!-- ai:lock -->
163+
```csharp
164+
var (count, setCount) = UseState(0, threadSafe: true);
165+
var (sum, addToSum) = UseReducer(0, threadSafe: true);
166+
```
167+
<!-- /ai:lock -->
168+
169+
`threadSafe: true` switches the hook to a per-cell lock: concurrent writers
170+
serialize on the lock instead of queuing through the UI dispatcher, and reads
171+
inside the setter (the `prev` argument of a reducer) see the latest committed
172+
write rather than a snapshot from the last UI tick.
173+
123174
## Hook Rules
124175

125176
Hooks must be called **in the same order** every render. Reactor tracks hooks by

docs/guide/effects.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,15 @@ The `Func<Action>` overload of `UseEffect` returns a cleanup function. Here
113113
the cleanup disposes the timer, preventing leaks when the component unmounts
114114
or when `isRunning` changes.
115115

116+
The timer body runs on a background thread (it's inside `Task.Run`) and calls
117+
the `updateSeconds` reducer from there. That works out of the box — every
118+
`UseState` / `UseReducer` setter automatically marshals onto the UI dispatcher
119+
when called from a non-UI thread, so timers, `PeriodicTimer`, network
120+
callbacks, and code after `await ... ConfigureAwait(false)` can all call the
121+
returned setter without any extra opt-in. Pass `threadSafe: true` to the hook
122+
only when you need many concurrent setters to apply in-place (locked) instead
123+
of being queued one-by-one onto the UI thread.
124+
116125
## Async Data Loading
117126

118127
Use `UseEffect` with [`UseState`](hooks.md) to load data asynchronously:

docs/guide/hooks.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,57 @@ object every render. `UseCallback` returns the same delegate instance as long
273273
as the dependencies haven't changed. This matters when passing callbacks to
274274
memoized [child components](components.md).
275275

276+
## Updating State From Background Work
277+
278+
`UseState` and `UseReducer` setters are safe to call from any thread. When you
279+
invoke a setter from a background task — inside `Task.Run`, from a
280+
`PeriodicTimer` loop, from a network callback, or after
281+
`await ... ConfigureAwait(false)` — the setter automatically marshals the write
282+
and the resulting re-render onto the UI dispatcher. You write the same code
283+
you'd write on the UI thread:
284+
285+
<!-- ai:lock -->
286+
```csharp
287+
public override Element Render()
288+
{
289+
var (seconds, setSeconds) = UseState(0);
290+
291+
UseEffect(() =>
292+
{
293+
var cts = new CancellationTokenSource();
294+
_ = Task.Run(async () =>
295+
{
296+
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
297+
while (await timer.WaitForNextTickAsync(cts.Token))
298+
setSeconds(s => s + 1); // auto-marshals to the UI thread
299+
});
300+
return () => cts.Cancel();
301+
});
302+
303+
return TextBlock($"Elapsed: {seconds}s");
304+
}
305+
```
306+
<!-- /ai:lock -->
307+
308+
Each cross-thread setter call costs one `DispatcherQueue.TryEnqueue`
309+
microseconds, not free, but vastly cheaper than the bugs you'd hit writing the
310+
field directly from a worker. If you need many concurrent setters to apply
311+
in-place rather than serialize through the UI thread (typical for ingest loops
312+
that hammer the same hook from multiple producers), pass `threadSafe: true` to
313+
the hook:
314+
315+
<!-- ai:lock -->
316+
```csharp
317+
var (count, setCount) = UseState(0, threadSafe: true);
318+
var (sum, addToSum) = UseReducer(0, threadSafe: true);
319+
```
320+
<!-- /ai:lock -->
321+
322+
`threadSafe: true` switches the hook to a per-cell lock: concurrent writers
323+
serialize on the lock instead of queuing through the UI dispatcher, and reads
324+
inside the setter (the `prev` argument of a reducer) see the latest committed
325+
write rather than a snapshot from the last UI tick.
326+
276327
## Hook Rules
277328

278329
Hooks must be called **in the same order** every render. Reactor tracks hooks by

src/Reactor/Core/RenderContext.cs

Lines changed: 52 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,37 @@ internal void BeginRender(Action requestRerender, ContextScope contextScope)
3030
_uiThreadId = Environment.CurrentManagedThreadId;
3131
}
3232

33-
[global::System.Diagnostics.Conditional("DEBUG")]
34-
private void AssertUIThread(string hookName)
33+
/// <summary>
34+
/// Auto-marshals <paramref name="work"/> to the captured UI dispatcher when the
35+
/// caller is not on the UI thread. Returns <c>true</c> if the work was
36+
/// scheduled cross-thread (caller should return without executing inline).
37+
/// Returns <c>false</c> when the caller is already on the UI thread (hot path).
38+
/// <para>
39+
/// When no <see cref="Microsoft.UI.Reactor.ReactorApp.UIDispatcher"/> has been
40+
/// captured (unit-test / headless render contexts), a cross-thread call throws
41+
/// instead of silently racing: there is no UI dispatcher to marshal onto.
42+
/// Production hosts always capture a dispatcher in <c>OnLaunched</c>.
43+
/// </para>
44+
/// </summary>
45+
private bool MarshalIfOffUIThread(string hookName, Action work)
3546
{
36-
if (Environment.CurrentManagedThreadId != _uiThreadId)
47+
// Hot path — same thread that ran BeginRender. ~1ns TLS read + cmp + branch.
48+
if (Environment.CurrentManagedThreadId == _uiThreadId) return false;
49+
50+
var dq = Microsoft.UI.Reactor.ReactorApp.UIDispatcher;
51+
if (dq is null)
52+
{
53+
// Test/headless context with no captured UI dispatcher AND off-thread.
54+
// The legacy [Conditional("DEBUG")] assert at this spot swallowed silently
55+
// in RELEASE; surface loudly in both flavors so the call is visible.
3756
throw new InvalidOperationException(
3857
$"{hookName} setter was called from thread {Environment.CurrentManagedThreadId}, " +
39-
$"but the UI thread is {_uiThreadId}. Use threadSafe: true to allow cross-thread calls.");
58+
$"but the captured UI thread is {_uiThreadId}, and no UI dispatcher is " +
59+
$"available to marshal the call. Run the setter on the UI thread, " +
60+
$"or pass threadSafe: true to the hook.");
61+
}
62+
dq.TryEnqueue(() => work());
63+
return true;
4064
}
4165

4266
/// <summary>
@@ -55,9 +79,18 @@ internal void UseStateSetterByIndex<T>(int index, T newValue)
5579
/// <summary>
5680
/// Declares a piece of state. Returns (currentValue, setter).
5781
/// Must be called in the same order every render (just like React hooks).
58-
/// When <paramref name="threadSafe"/> is true, the setter can be called from any thread
59-
/// and reads/writes are synchronized. When false (default), the setter must be called
60-
/// from the UI thread — in DEBUG builds, a cross-thread call throws.
82+
/// <para>
83+
/// When <paramref name="threadSafe"/> is true, reads and writes are synchronized
84+
/// with a per-hook lock so concurrent setter calls from many threads serialize.
85+
/// When false (default), cross-thread setter calls are auto-marshaled onto the
86+
/// captured UI dispatcher — the write and the rerender both run on the UI thread,
87+
/// so background-thread setters from <c>Task.Run</c>, <c>PeriodicTimer</c>, or
88+
/// callbacks after <c>await … ConfigureAwait(false)</c> work correctly without
89+
/// any extra opt-in. Use <paramref name="threadSafe"/>: <c>true</c> when you need
90+
/// many concurrent setters to apply in-place (i.e., without an intervening UI
91+
/// thread hop) or when the setter result must be visible to its caller before
92+
/// the next UI tick.
93+
/// </para>
6194
/// </summary>
6295
public (T Value, Action<T> Set) UseState<T>(T initialValue, bool threadSafe = false)
6396
{
@@ -99,7 +132,7 @@ void Setter(T newValue)
99132
}
100133
else
101134
{
102-
AssertUIThread("UseState");
135+
if (MarshalIfOffUIThread("UseState", () => Setter(newValue))) return;
103136
changed = !EqualityComparer<T>.Default.Equals(h.Value, newValue);
104137
if (changed) h.Value = newValue;
105138
if (Diagnostics.ReactorEventSource.Log.IsEnabled(
@@ -116,7 +149,10 @@ void Setter(T newValue)
116149
/// <summary>
117150
/// Declares a piece of state with a functional updater variant.
118151
/// The updater receives the previous value and returns the next.
119-
/// When <paramref name="threadSafe"/> is true, the updater can be called from any thread.
152+
/// Cross-thread updater calls are auto-marshaled onto the captured UI dispatcher
153+
/// (same semantics as <see cref="UseState{T}(T, bool)"/>); pass
154+
/// <paramref name="threadSafe"/>: <c>true</c> for locked in-place updates that
155+
/// serialize many concurrent writers without an intervening UI tick.
120156
/// </summary>
121157
public (T Value, Action<Func<T, T>> Update) UseReducer<T>(T initialValue, bool threadSafe = false)
122158
{
@@ -160,7 +196,7 @@ void Updater(Func<T, T> reducer)
160196
}
161197
else
162198
{
163-
AssertUIThread("UseReducer");
199+
if (MarshalIfOffUIThread("UseReducer", () => Updater(reducer))) return;
164200
var prev = h.Value;
165201
var next = reducer(prev);
166202
changed = !EqualityComparer<T>.Default.Equals(prev, next);
@@ -180,7 +216,10 @@ void Updater(Func<T, T> reducer)
180216
/// Declares a piece of state managed by a reducer function (like Redux).
181217
/// The reducer takes (currentState, action) and returns the next state.
182218
/// Returns (currentState, dispatch) where dispatch sends an action through the reducer.
183-
/// When <paramref name="threadSafe"/> is true, dispatch can be called from any thread.
219+
/// Cross-thread dispatch calls are auto-marshaled onto the captured UI dispatcher
220+
/// (same semantics as <see cref="UseState{T}(T, bool)"/>); pass
221+
/// <paramref name="threadSafe"/>: <c>true</c> for locked in-place dispatch that
222+
/// serializes concurrent writers without an intervening UI tick.
184223
/// </summary>
185224
public (TState Value, Action<TAction> Dispatch) UseReducer<TState, TAction>(
186225
Func<TState, TAction, TState> reducer, TState initialValue, bool threadSafe = false)
@@ -221,7 +260,7 @@ void Dispatch(TAction action)
221260
}
222261
else
223262
{
224-
AssertUIThread("UseReducer");
263+
if (MarshalIfOffUIThread("UseReducer", () => Dispatch(action))) return;
225264
var prev = h.Value;
226265
var next = reducer(prev, action);
227266
if (!EqualityComparer<TState>.Default.Equals(prev, next))
@@ -399,6 +438,7 @@ public Ref<T> UseRef<T>(T initialValue = default!)
399438

400439
void Setter(T newValue)
401440
{
441+
if (MarshalIfOffUIThread("UsePersisted", () => Setter(newValue))) return;
402442
var h = (PersistedHookState<T>)_hooks[currentIndex];
403443
if (!EqualityComparer<T>.Default.Equals(h.Value, newValue))
404444
{

tests/Reactor.AppTests.Host/SelfTest/Fixtures/ThreadSafeHookFixtures.cs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,69 @@ await Task.Run(() =>
319319
}
320320
}
321321

322+
// ════════════════════════════════════════════════════════════════════
323+
// Issue #212 — non-thread-safe setter from a background task auto-marshals
324+
// ════════════════════════════════════════════════════════════════════
325+
326+
/// <summary>
327+
/// Reproduces the Pomodoro / PeriodicTimer scenario from microsoft-ui-reactor#212:
328+
/// a default <c>UseState</c> + <c>UseReducer</c> hook (no <c>threadSafe: true</c>)
329+
/// whose setters are invoked from inside <c>Task.Run</c>. Before the fix this
330+
/// either silently failed (RELEASE) or threw an unobserved
331+
/// <see cref="InvalidOperationException"/> inside <c>_ = Task.Run(...)</c> (DEBUG).
332+
/// After the fix the setter auto-marshals onto the UI dispatcher and the
333+
/// rendered text reflects the writes.
334+
/// </summary>
335+
internal class NonThreadSafeAutoMarshal(Harness h) : SelfTestFixtureBase(h)
336+
{
337+
public override async Task RunAsync()
338+
{
339+
Action<int>? setValue = null;
340+
Action<Func<int, int>>? bumpValue = null;
341+
342+
var host = H.CreateHost();
343+
host.Mount(ctx =>
344+
{
345+
var (value, set) = ctx.UseState(0); // threadSafe: false (default)
346+
var (sum, bump) = ctx.UseReducer(0); // threadSafe: false (default)
347+
setValue = set;
348+
bumpValue = bump;
349+
return VStack(
350+
TextBlock($"Value: {value}"),
351+
TextBlock($"Sum: {sum}")
352+
);
353+
});
354+
355+
await Harness.Render();
356+
H.Check("AutoMarshal_Initial",
357+
H.FindText("Value: 0") is not null &&
358+
H.FindText("Sum: 0") is not null);
359+
360+
// Fire from background tasks — the failure mode the issue documents.
361+
// The discard `_ = Task.Run(...)` is intentional: it's the exact shape
362+
// that swallows the legacy AssertUIThread throw.
363+
const int iterations = 25;
364+
var done = new TaskCompletionSource();
365+
_ = Task.Run(async () =>
366+
{
367+
for (int i = 1; i <= iterations; i++)
368+
{
369+
setValue!(i);
370+
bumpValue!(prev => prev + 1);
371+
await Task.Delay(1);
372+
}
373+
done.SetResult();
374+
});
375+
376+
await done.Task;
377+
// Let the marshaled writes drain and render
378+
for (int i = 0; i < 4; i++) await Harness.Render();
379+
380+
H.Check("AutoMarshal_ValueFinal", H.FindText($"Value: {iterations}") is not null);
381+
H.Check("AutoMarshal_SumFinal", H.FindText($"Sum: {iterations}") is not null);
382+
}
383+
}
384+
322385
// ════════════════════════════════════════════════════════════════════
323386
// Strict render coalescing: setStates inside one dispatcher block
324387
// ════════════════════════════════════════════════════════════════════

tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,7 @@ internal static class SelfTestFixtureRegistry
274274
"ThreadSafe_HighFrequencyLargeTree",
275275
"ThreadSafe_RenderCoalescing",
276276
"ThreadSafe_RenderCoalescingDispatcherBatch",
277+
"ThreadSafe_NonThreadSafeAutoMarshal",
277278
// Animation system — Curve & Easing
278279
"Curve_RecordEquality",
279280
"Curve_PresetValues",
@@ -1055,6 +1056,7 @@ internal static class SelfTestFixtureRegistry
10551056
"ThreadSafe_HighFrequencyLargeTree" => new ThreadSafeHookFixtures.HighFrequencyLargeTree(harness),
10561057
"ThreadSafe_RenderCoalescing" => new ThreadSafeHookFixtures.RenderCoalescing(harness),
10571058
"ThreadSafe_RenderCoalescingDispatcherBatch" => new ThreadSafeHookFixtures.RenderCoalescingDispatcherBatch(harness),
1059+
"ThreadSafe_NonThreadSafeAutoMarshal" => new ThreadSafeHookFixtures.NonThreadSafeAutoMarshal(harness),
10581060
// Animation system — Curve & Easing
10591061
"Curve_RecordEquality" => new CurveTests.RecordEquality(harness),
10601062
"Curve_PresetValues" => new CurveTests.PresetValues(harness),

0 commit comments

Comments
 (0)