Files
punktfunk/clients/windows/tests/reactor_semantics.rs
T
enricobuehlerandClaude Fable 5 2409ec9a4b docs(client/windows): the harness passes where the real backend fails, and the note says so
Case 3 of the characterization suite (a sync use_state write from a backend-fired handler
re-renders) is true of the ENGINE and was taken as license to de-hoist event-driven page
state — and the live app immediately disproved it: with settings componentized and its
scope local, a real ComboBox pick changed nothing on screen (UIA-verified, no repaint).
The real WinUI backend's handler wiring still never pumps the pass the harness pumps.

The de-hoist is reverted one commit back; this one makes the record honest. The module
discipline note now separates the three tiers of evidence — engine rules (harness-pinned),
real-backend behaviour (live-verified, hoisting stays), and the tween-keyed corollary —
and case 3's comment warns it must not be read as de-hoist permission without a live UIA
check. The upstream gap worth reporting grows to two: the AsyncSetState HostId registry
miss, and the backend event wiring that bypasses the render pump.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:31:49 +02:00

333 lines
12 KiB
Rust

#![cfg(windows)]
//! Characterization tests for the windows-reactor re-render semantics this client's
//! architecture depends on. These are not tests of the client — they encode the CONTRACT
//! the client's state placement assumes (see the re-render discipline note in `app/mod.rs`),
//! against the same pinned rev, using reactor's own headless harness (`RecordingBackend`).
//! Run them after every SHA bump: a red test here means the discipline note is stale again,
//! in one direction or the other.
//!
//! History that motivates each case:
//! * The June 2026 rev dropped a background `AsyncSetState` write on the floor unless the
//! owning component's props changed — that bug forced ALL thread-driven state into the
//! root component. Fixed July 2026 (`dirty: Arc<AtomicBool>` in the engine).
//! * The July 2026 pre-bump rev still pruned a dirty component sitting under an
//! element-equal non-component wrapper (`border`, `scroll_viewer`) — the reconciler's
//! dirty lookup was one level deep. Fixed by the 2026-07-29 pin (upstream regression
//! test: `nested_state_rerender.rs`).
//! * Event handlers wired in the WinUI backend (`add_Click` etc.) were believed to bypass
//! the render flush, so a sync `use_state` write from one never repainted. The harness
//! fires events through the same backend attach path; the third test pins the engine
//! side of that claim.
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use std::thread;
use test_reactor::{Op, RecordingBackend};
use windows_reactor::{
border, component, vstack, AsyncSetState, ChannelDispatcher, Component, ControlKind,
Dispatcher, DispatcherQueuePriority, Element, Event, Prop, PropValue, RenderCx, RenderHost,
SetState, TextBlock, ToggleSwitch,
};
type Job = Box<dyn FnOnce()>;
/// The upstream tests' single-threaded "UI thread": jobs queue until drained.
#[derive(Clone, Default)]
struct TestDispatcher {
queue: Rc<RefCell<Vec<Job>>>,
}
impl TestDispatcher {
fn drain(&self) {
loop {
let item = {
let mut q = self.queue.borrow_mut();
if q.is_empty() {
None
} else {
Some(q.remove(0))
}
};
match item {
Some(f) => f(),
None => break,
}
}
}
}
impl Dispatcher for TestDispatcher {
fn enqueue(&self, _p: DispatcherQueuePriority, f: Box<dyn FnOnce()>) -> bool {
self.queue.borrow_mut().push(f);
true
}
}
fn last_text(ops: &[Op]) -> Option<String> {
ops.iter().rev().find_map(|op| match op {
Op::SetProp {
prop: Prop::Text,
value: PropValue::Str(s),
..
} => Some(s.clone()),
_ => None,
})
}
// --- Case 1: sync `use_state` in a child under an element-equal `border` wrapper ---------
// The client wraps every screen in an animated `border` that compares equal once the
// entrance tween settles; `pair.rs` documents a live workaround for this exact topology.
struct SyncInner {
renders: Rc<Cell<u32>>,
setter_out: Rc<RefCell<Option<SetState<u64>>>>,
}
impl Component for SyncInner {
fn render(&self, _props: &(), cx: &mut RenderCx) -> Element {
let (n, set) = cx.use_state(0_u64);
*self.setter_out.borrow_mut() = Some(set);
self.renders.set(self.renders.get() + 1);
Element::TextBlock(TextBlock::new(format!("sync-{n}")))
}
}
struct SyncRoot {
renders: Rc<Cell<u32>>,
setter_out: Rc<RefCell<Option<SetState<u64>>>>,
}
impl Component for SyncRoot {
fn render(&self, _props: &(), _cx: &mut RenderCx) -> Element {
border(component(
SyncInner {
renders: Rc::clone(&self.renders),
setter_out: Rc::clone(&self.setter_out),
},
(),
))
.into()
}
}
#[test]
fn sync_state_child_under_element_equal_border_rerenders() {
let dispatcher = TestDispatcher::default();
let setter_out = Rc::new(RefCell::new(None));
let renders = Rc::new(Cell::new(0));
let host = RenderHost::new(
RecordingBackend::new(),
Box::new(SyncRoot {
renders: Rc::clone(&renders),
setter_out: Rc::clone(&setter_out),
}) as Box<dyn Component>,
dispatcher.clone(),
);
host.kick();
dispatcher.drain();
assert_eq!(renders.get(), 1, "inner mounts once");
setter_out.borrow().as_ref().unwrap().call(1);
dispatcher.drain();
assert_eq!(
renders.get(),
2,
"a dirty child under an element-equal border must re-render \
(the pre-2026-07-29 reconciler pruned at the wrapper)"
);
assert_eq!(
host.with_reconciler(|r| last_text(&r.backend.ops)),
Some("sync-1".to_string())
);
}
// --- Case 2: background `AsyncSetState` write into a child with unchanged props ----------
// THE case the root-hoisting discipline was built around. MEASURED BROKEN at the 2026-07-29
// pin, for a new reason: every component's `RenderCx` draws a fresh `HostId`
// (`RenderCx::new` → `HostId::next()`), but rerender callbacks are only registered for the
// ROOT's id (`RenderHost::set_marshaller` / the app bootstrap). A child-owned async write
// stores the value and sets the dirty flag, then calls
// `request_ui_rerender_on_ui_thread(child_host_id)` — a registry miss, silently dropped.
// The new value appears whenever anything ELSE triggers a pass. (Upstream's
// `async_setter_marks_owning_component_dirty_for_rerender` installs the guard for the very
// cx it tests, so it never sees this.) The test asserts the BROKEN behaviour on purpose:
// when a future bump fixes it upstream, this fails loudly and the root-hoisting rule for
// thread-driven state in `app/mod.rs` can finally be relaxed.
struct AsyncInner {
renders: Rc<Cell<u32>>,
setter_out: Rc<RefCell<Option<AsyncSetState<u64>>>>,
}
impl Component for AsyncInner {
fn render(&self, _props: &(), cx: &mut RenderCx) -> Element {
let (n, set) = cx.use_async_state(0_u64);
*self.setter_out.borrow_mut() = Some(set);
self.renders.set(self.renders.get() + 1);
Element::TextBlock(TextBlock::new(format!("async-{n}")))
}
}
struct AsyncRoot {
renders: Rc<Cell<u32>>,
root_renders: Rc<Cell<u32>>,
setter_out: Rc<RefCell<Option<AsyncSetState<u64>>>>,
}
impl Component for AsyncRoot {
fn render(&self, _props: &(), _cx: &mut RenderCx) -> Element {
self.root_renders.set(self.root_renders.get() + 1);
border(component(
AsyncInner {
renders: Rc::clone(&self.renders),
setter_out: Rc::clone(&self.setter_out),
},
(),
))
.into()
}
}
#[test]
fn async_state_child_under_element_equal_border_rerenders() {
let dispatcher = TestDispatcher::default();
let channel = ChannelDispatcher::new();
let setter_out: Rc<RefCell<Option<AsyncSetState<u64>>>> = Rc::new(RefCell::new(None));
let renders = Rc::new(Cell::new(0));
let root_renders = Rc::new(Cell::new(0));
let host = RenderHost::new(
RecordingBackend::new(),
Box::new(AsyncRoot {
renders: Rc::clone(&renders),
root_renders: Rc::clone(&root_renders),
setter_out: Rc::clone(&setter_out),
}) as Box<dyn Component>,
dispatcher.clone(),
);
host.set_marshaller(Some(channel.marshaller()));
host.kick();
dispatcher.drain();
assert_eq!(renders.get(), 1, "inner mounts once");
// The write happens OFF the UI thread, exactly like discovery/HUD/speed-test threads.
let set = setter_out.borrow().as_ref().unwrap().clone();
thread::spawn(move || set.call(1)).join().unwrap();
// Marshal back onto the "UI thread", then run the render it requested.
channel.drain();
dispatcher.drain();
assert_eq!(
root_renders.get(),
1,
"CONTRACT CHANGED: a child-owned async write now triggers a render pass — the \
host-id registry miss was fixed upstream. Thread-driven state may move out of \
root(); update the discipline note in app/mod.rs and this test"
);
assert_eq!(
renders.get(),
1,
"CONTRACT CHANGED: the child re-rendered from its own async write — see above"
);
// The write itself was NOT lost: it lands on the next pass someone else triggers.
// (This is exactly the 'appears after unrelated navigation' failure mode the client
// keeps paying for, and why thread-driven state lives in root().)
host.kick();
dispatcher.drain();
assert_eq!(renders.get(), 2);
assert_eq!(
host.with_reconciler(|r| last_text(&r.backend.ops)),
Some("async-1".to_string()),
"the buffered write must surface on the next externally-triggered pass"
);
}
// --- Case 3: sync `use_state` written from a backend-fired event handler -----------------
// The client hoists `forget`/`rename`/`hover` to root because WinUI-wired handlers
// (MenuFlyout `add_Click`, ComboBox selection, pointer enter/exit) mark dirty without ever
// pumping the reconciler. This test PASSES — the ENGINE honours a handler-driven sync
// write — but the claim it probes is about the REAL WinUI backend, and there it was
// re-confirmed live on 2026-07-29: a componentized settings page with local sync scope
// state did not repaint on a real ComboBox pick (see the discipline note in app/mod.rs;
// the de-hoist was reverted). Keep this green as the ENGINE contract; do NOT read it as
// permission to de-hoist event-driven state until a live UIA check passes too.
struct ToggleInner {
renders: Rc<Cell<u32>>,
}
impl Component for ToggleInner {
fn render(&self, _props: &(), cx: &mut RenderCx) -> Element {
let (n, set) = cx.use_state(0_u64);
self.renders.set(self.renders.get() + 1);
vstack((
TextBlock::new(format!("evt-{n}")),
ToggleSwitch::new(false).on_toggled(move |_| set.call(n + 1)),
))
.into()
}
}
struct ToggleRoot {
renders: Rc<Cell<u32>>,
}
impl Component for ToggleRoot {
fn render(&self, _props: &(), _cx: &mut RenderCx) -> Element {
border(component(
ToggleInner {
renders: Rc::clone(&self.renders),
},
(),
))
.into()
}
}
#[test]
fn sync_state_from_backend_fired_event_rerenders() {
let dispatcher = TestDispatcher::default();
let renders = Rc::new(Cell::new(0));
let host = RenderHost::new(
RecordingBackend::new(),
Box::new(ToggleRoot {
renders: Rc::clone(&renders),
}) as Box<dyn Component>,
dispatcher.clone(),
);
host.kick();
dispatcher.drain();
assert_eq!(renders.get(), 1);
let toggle_id = host.with_reconciler(|r| {
r.backend
.ops
.iter()
.find_map(|op| match op {
Op::Create {
id,
kind: ControlKind::ToggleSwitch,
} => Some(*id),
_ => None,
})
.expect("the toggle was created")
});
host.with_reconciler(|r| r.backend.fire_bool(toggle_id, Event::Toggled, true));
dispatcher.drain();
assert_eq!(
renders.get(),
2,
"a sync use_state write from a backend-fired handler must schedule and run a \
re-render — if this fails, the engine really does drop handler-driven dirt, and \
the root-hoisting of forget/rename/hover stays justified at the engine level"
);
assert_eq!(
host.with_reconciler(|r| last_text(&r.backend.ops)),
Some("evt-1".to_string())
);
}