Reactive internals
The arena the reactive graph lives in, how a subscription is recorded, what the flush does, and how cross-thread work requests a frame.
This page is the inside of zgui-reactive: where the graph is stored, what a write actually
touches, what the flush polls, and the edge that turns cross-thread work into a redraw request. It
assumes the guide and
the frame.
The facade and the engine
zgui does not implement a reactive engine. It pins one and wraps it.
| Layer | Crate | What it is |
|---|---|---|
| engine | reactive_graph =0.2.14, features ["effects"] | The graph, the arena, the ownership tree |
| engine | reactive_stores =0.4.3 | Per-field reactivity over a struct |
| facade | zgui-reactive | The whole reactive surface of the framework |
| view | zgui-view | Re-exports a subset through its prelude |
| umbrella | zgui | pub use zgui_reactive as reactive; |
zgui-reactive is the only crate in the workspace permitted to name reactive_graph,
reactive_stores, any_spawner or send_wrapper. The rule is written in
crates/zgui-reactive/Cargo.toml and the versions are pinned in the workspace manifest. One crate
names the engine, so the engine's version, its feature set and the handful of its APIs that are
wrong for a user interface are decided once.
Two engine features matter by their absence. nightly is off, so a signal is read as count.get()
and never as count(); turning it on deletes the conversions that let a component property accept
a closure, and crates/zgui-reactive/src/canary.rs has a test that fails first when that happens.
sandboxed-arenas is off, which is why the arena below is one map for the process rather than one
per thread.
What a signal handle is
A Copy slotmap key, plus a Location in debug builds.
One process-wide map resolves the NodeId.
- value: Arc<RwLock<T>> — the value
- inner: Arc<RwLock<SubscriberSet>> — who reads it
The arena, in full:
// reactive_graph/src/owner/arena.rs
new_key_type! { pub struct NodeId; }
pub type ArenaMap = SlotMap<NodeId, Box<dyn Any + Send + Sync>>;
static MAP: OnceLock<RwLock<ArenaMap>> = OnceLock::new();A slotmap key carries an index and a generation counter. A key whose slot was freed and then reused
does not resolve, so ArenaItem::is_disposed is exactly !arena.contains_key(self.node), and a
handle to a disposed value fails a lookup rather than reading somebody else's value. Every
arena-backed read takes a read lock on that one map; every creation and every disposal takes a
write lock.
That is what makes the handles Copy. RwSignal<T> is a key, not a pointer, so it moves into as
many closures as a view needs without a clone and without a lifetime.
| Handle | Storage | Freed when |
|---|---|---|
RwSignal, ReadSignal, WriteSignal, Memo, Signal, Trigger, StoredValue, Callback, Store, Field | arena | its owner is disposed of |
ArcRwSignal, ArcReadSignal, ArcWriteSignal, ArcMemo, ArcSignal, ArcTrigger, ArcField | reference counted | the last handle is dropped |
The Arc forms skip the arena entirely. That is not only a lifetime difference: it is up to four
fewer lock acquisitions per read, as the cost table at the end shows.
Node identity inside the graph
The graph itself does not use NodeId. It uses two type-erased handles:
// reactive_graph/src/graph/source.rs, subscriber.rs
pub struct AnySource(pub(crate) usize, pub(crate) Weak<dyn Source + Send + Sync>, /* Location */);
pub struct AnySubscriber(pub usize, pub Weak<dyn Subscriber + Send + Sync>);The usize is the address of the reference-counted inner state, and it is what Hash and Eq
use. Both directions are weak. Every graph operation is therefore an upgrade that may fail, and a
node that has gone away leaves dead edges rather than dangling ones — nothing has to walk the graph
to remove them.
Subscription
A tracking context is a run of some closure the reactive system performed itself: an effect body, a memo body, a reactive hole in a view. While that run is in progress, one thread-local names it.
// reactive_graph/src/graph/subscriber.rs
thread_local! {
static OBSERVER: RefCell<Option<ObserverState>> = const { RefCell::new(None) };
}
struct ObserverState { subscriber: AnySubscriber, untracked: bool }AnySubscriber::with_observer replaces that slot for the length of a call and restores it on drop,
including on unwind. Nesting is therefore free and correct.
A read records the dependency:
// reactive_graph/src/traits.rs
impl<T: Source + ToAnySource + DefinedAt> Track for T {
fn track(&self) {
if self.is_disposed() { return; }
if let Some(subscriber) = Observer::get() {
subscriber.add_source(self.to_any_source()); // forward edge
self.add_subscriber(subscriber); // back edge
} else {
// debug builds only: the "outside a reactive tracking context" diagnostic
}
}
}Two edges, one in each direction, and both are needed. The back edge is what a write walks to mark. The forward edge is what the flush walks to decide whether a marked observer really has to run, and what the next run walks to unsubscribe.
Read::try_read reduces to track() followed by try_read_untracked(), and Get, With and
Read all blanket down to it. So get() is one subscription and one read, and get_untracked()
is the read alone. There is no third path.
When no observer is current, debug builds print a diagnostic to standard error naming the read site
and the site the signal was defined at. It is an eprintln!, it is absent from release builds, and
it is easy to lose in other output — which is why the guide treats a read outside a tracking
context as a silent mistake. enter_non_reactive_zone suppresses that message and nothing else:
SpecialNonReactiveZone::is_inside is read only in the branch above where there is no observer, so
a read inside a zone with an observer current still subscribes. To not subscribe, use an
_untracked method.
The two sets
// reactive_graph/src/graph/sets.rs
type FxIndexSet<T> = IndexSet<T, BuildHasherDefault<FxHasher>>;
pub struct SourceSet(FxIndexSet<AnySource>); // what one subscriber reads
pub struct SubscriberSet(FxIndexSet<AnySubscriber>); // who reads one sourceBoth preserve insertion order. SubscriberSet::unsubscribe uses shift_remove rather than
swap_remove, and the engine says why: nested effects are created in order, and an inner effect may
assume the outer one has already run this pass. Reordering the set breaks that.
Replacing the dependency set
An effect's task body is the whole update protocol:
// reactive_graph/src/effect/render_effect.rs
while rx.next().await.is_some() {
if !owner.paused()
&& subscriber.with_observer(|| subscriber.update_if_necessary())
{
subscriber.clear_sources(&subscriber);
let old_value = mem::take(&mut *value.write().or_poisoned());
let new_value = owner.with_cleanup(|| {
subscriber.with_observer(|| fun(old_value))
});
*value.write().or_poisoned() = Some(new_value);
}
}clear_sources takes the whole SourceSet and calls remove_subscriber on every source in it.
The run that follows re-subscribes through track. The dependency set is rebuilt, not diffed:
the cost of one run is one removal per old dependency plus one insertion per new one. A closure that
read three signals on one run and one on the next ends holding exactly one.
owner.with_cleanup is cleanup() and then with(). The effect disposes of its own owner before
every re-run, which is why a cleanup registered inside an effect runs before that effect's next run,
and why a signal created inside an effect body is freed at the start of the next one.
Marking versus running
A write does not run anything. It moves flags, and it puts one bit into a notification.
// reactive_graph/src/graph/node.rs
pub enum ReactiveNodeState { Clean, Check, Dirty }| State | Meaning | Set by |
|---|---|---|
Clean | No source changed, or one did and this value did not | the node itself, after it recomputes |
Check | A source may have changed; this node does not know yet | mark_check from a source that was itself marked |
Dirty | A source definitely changed | mark_dirty from a source's own write |
The propagation has two speeds:
The write. set takes a write guard; dropping the guard calls Notify::notify, which is
mark_dirty. For a signal that is mark_subscribers_check: clone the SubscriberSet and call
mark_dirty on every direct subscriber. A signal holds no state of its own — it has no computed
value that could be stale.
One edge of Dirty. A memo marked dirty sets its own state to Dirty and then calls
mark_check on its subscribers.
The rest of the way as Check. mark_check sets a node to Check unless it is already
Dirty, and propagates mark_check onward. So Dirty travels exactly one edge and Check
travels the remainder of the observer sub-graph.
The task. At the end of every chain is an effect. EffectInner::mark_dirty sets dirty = true
and notifies its channel; mark_check only notifies. Notifying is where a write stops.
The ready set
There is no list of dirty nodes for the flush to scan. The ready set is the task pool's own queue, fed by one single-slot channel per effect:
// reactive_graph/src/channel.rs
struct Inner { waker: AtomicWaker, set: AtomicBool }
impl Sender {
pub fn notify(&mut self) { self.0.set.store(true, Relaxed); self.0.waker.wake(); }
}The slot is one bit. Receiver::poll_next swaps it back to false when the task runs. A signal
written a thousand times between two flushes therefore costs one re-run, not a thousand, and no
batching API is needed to get that.
Waking pushes the task into the thread's pool:
// futures::executor::LocalPool, held in crates/zgui-reactive/src/executor/pool.rs
pool: FuturesUnordered<LocalFutureObj<'static, ()>>,
incoming: Rc<RefCell<Vec<LocalFutureObj<'static, ()>>>>,FuturesUnordered keeps an intrusive queue of the tasks whose wakers have fired. That queue is the
ready set, and flush polls it and nothing else.
Deciding at the poll rather than at the write
The task's first act is update_if_necessary(). For an effect: if dirty, clear it and answer
true; otherwise walk the SourceSet and ask each source the same question. A memo answers by
recomputing when its own state is Dirty, or when one of its own sources answers true while it is
Check — and it returns whether the new value differs from the old one.
So a memo whose recomputed value compares equal answers false, and an effect that was only
Check never runs its closure. Memo::new requires T: PartialEq;
Memo::new_with_compare takes the comparison as a function pointer instead.
Marking is pushed eagerly and cheaply; the decision to run is pulled at the poll. That split is why a write inside a listener costs a few flag stores rather than a cascade of work.
The flush
// crates/zgui-reactive/src/executor/frame.rs
pub fn flush() -> FlushOutcome;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct FlushOutcome {
pub needs_another_frame: bool,
pub budget_exhausted: bool,
}In order:
assert_ui_thread("flush") — a debug-build panic, compiled away in release.
Claim the flush. FlushState.running is a thread-local flag. If it is already set, the call returns
FlushOutcome::default() and does nothing else. A flush from inside an effect is ignored, not
nested: the pool cannot be borrowed twice, and a nested executor is a panic one layer down.
Increment generation and clear both outcome flags. The generation is what the iteration budget is
charged against.
Drain the closures queued through Ui::post. The queue is removed before a closure runs. A closure
that posts another closure therefore schedules it for the next flush.
Enter the configured PollContext, then call pool::poll(), which is
LocalPool::run_until_stalled(). The Tokio integration uses this context to enter its runtime for
the complete poll. A Running drop guard clears the re-entrancy flag if a task panics.
Wake the tasks the budget set aside, and set needs_another_frame if there were any. Waking them
re-queues them in the pool without asking the platform for anything.
Return the outcome.
A task that panics propagates the panic to the caller of flush and leaves the executor usable: the
next flush polls what is left.
The iteration budget
Polling "until nothing is ready" does not terminate when two effects write each other's sources. Each write makes the other ready again, inside a single flush, with no frame presented and nothing logged.
// crates/zgui-reactive/src/executor/budget.rs
pub(crate) const BUDGET: u32 = if cfg!(debug_assertions) { 32 } else { 8 };The cap is per task, per flush. It is charged in WakeThrough::poll
(crates/zgui-reactive/src/executor/through.rs), which asks TaskBudget::admit(frame::generation())
before touching the inner future. A generation that does not match resets the count, so the budget
is fresh every flush.
| Admission | When | What happens |
|---|---|---|
Run | polls so far are within the cap | poll the inner future |
Defer | first poll over the cap this flush | report once at error level, hand the waker to frame::defer, return Pending |
AlreadyDeferred | later polls over the cap this flush | return Pending silently |
The report names the file and line the task was spawned at when the task went through this crate's
own spawn or spawn_local; effects spawned by the engine have no location and get the generic
message. Debug builds allow more polls than release builds on purpose: a chatty dependency chain
should be diagnosed rather than truncated while it is being investigated, and a shipped frame still
has to present.
An ordinary task is polled once or twice per flush, so correct code never approaches the cap.
What the outcome reports, and who reads it
| Field | True when | Read by |
|---|---|---|
needs_another_frame | a wake was raised while this flush was running, or the budget set a task aside | crates/zgui-runtime/src/window/frame.rs:285, combined with three other reasons into one redraw request; and crates/zgui-runtime/src/window/input.rs:70 |
budget_exhausted | at least one task exceeded the cap | nothing in the runtime; it is the programmatic form of the logged error |
An application calls neither install nor flush. App::into_handler installs
(crates/zgui-runtime/src/app.rs), and the flush runs from four places: once per frame in
window/frame.rs, between input events in window/input.rs, after an observation delivery in
window/observe.rs, and inside the scroll animation in window/scroll/mod.rs.
The wake edge
The task pool's own waker unparks the thread it lives on. The UI thread instead waits in the platform event loop. Without a second wake edge, a task that becomes ready on another thread does not cause a frame.
So every spawned task is polled through a composite waker:
// crates/zgui-reactive/src/executor/through.rs
impl ArcWake for Composite {
fn wake_by_ref(arc_self: &Arc<Self>) {
arc_self.pool.wake_by_ref();
if !frame::note_wake() {
arc_self.target.ping();
}
}
}frame::note_wake answers whether a flush is running. If one is, it records
needs_another_frame and the ping is suppressed: an effect writing a signal must not ask the
platform for a redraw from inside the frame that is already running, once per write.
The whole path:
Mark the task ready and wake it.
- pool.wake_by_ref() — queue the task on the UI thread's pool
- target.ping() — only when no flush is running
crates/zgui-runtime/src/wake.rs- If a frame is in flight, record one owed frame and stop
- Otherwise call platform.wake(ReactiveWork { surfaces })
Runs a frame; the frame calls flush(), which polls the task.
The contract
// crates/zgui-reactive/src/executor/wake.rs
pub trait FrameWaker: Send + Sync + 'static {
fn wake(&self);
}
pub fn set_frame_waker(waker: Arc<dyn FrameWaker>);| Guarantee | Why |
|---|---|
| Callable from any thread | The wake arrives from wherever the work finished |
| Must not block | It runs on that thread, inside whatever finished |
| Must not assume a current owner or the UI thread | Neither exists on a worker thread |
| Idempotent | A hundred wakes between two frames must cost one frame |
The target is a thread-local Arc<WakeTarget> created eagerly and shared by every composite waker
built on that thread, so installing a waker after effects already exist still reaches them.
RuntimeWaker is the implementation the framework installs, in crates/zgui-runtime/src/app.rs.
It holds the surfaces the wake concerns: an image decoding for one window is not a reason to redraw
another. Its idempotence comes from FrameGate, two atomic booleans shared with the frame loop. A
test asserts the shape directly: a thousand in-frame wakes produce zero platform requests and
exactly one owed frame (a_wake_inside_a_frame_is_owed_rather_than_sent,
crates/zgui-runtime/src/wake.rs).
Note the two suppressions on that path, and that they are at different layers. frame::note_wake
suppresses inside the reactive layer, for wakes raised while a flush is running. FrameGate
suppresses inside the runtime, for anything raised anywhere in a frame — a mutation closing its
batch, a timer's callback, an observation delivery. Each converts many requests into one.
Until set_frame_waker is called, a task woken from another thread is queued and nothing asks for
the frame that would poll it. A host that drives the pipeline itself, rather than through
zgui-runtime, must install one or it updates only when something else already caused a frame.
TestWaker is a counting implementation for tests and headless harnesses: count() reads it,
take() reads and resets.
Owners and arenas
An owner is the unit of "this went away".
// reactive_graph/src/owner.rs
pub(crate) struct OwnerInner {
pub parent: Option<Weak<RwLock<OwnerInner>>>,
nodes: Vec<NodeId>,
pub contexts: FxHashMap<TypeId, Box<dyn Any + Send + Sync>>,
pub cleanups: Vec<Box<dyn FnOnce() + Send + Sync>>,
pub children: Vec<Weak<RwLock<OwnerInner>>>,
paused: bool,
}Creating an arena-backed handle inserts into the arena and then pushes the NodeId onto the current
owner's nodes:
// reactive_graph/src/owner/arena_item.rs
let node = Arena::with_mut(|arena| arena.insert(Box::new(S::wrap(value)) as Box<dyn Any + Send + Sync>));
OWNER.with(|o| {
if let Some(owner) = o.borrow().as_ref().and_then(|o| o.upgrade()) {
owner.register(node);
}
});With no current owner the second half does nothing. The entry is in the arena, it works forever, and
it is never removed. No panic and no log. Four published APIs guard against that case with
assert_owner, and they are the complete list: provide_context, provide_local_context,
on_cleanup_local and Selector::is_selected. RwSignal::new is not one of them.
Disposal is one function and always the same order:
Take cleanups, nodes and children out of the owner under one write lock, so a cleanup that
touches the owner finds it readable rather than already borrowed.
Dispose of every child, recursively.
Run every cleanup, in registration order.
Remove every NodeId from the arena, under a single arena write lock for the whole batch.
Why disposal is synchronous
Mounted is the protocol: one owner per mounted node, and unmount disposes of everything before
it returns.
// crates/zgui-reactive/src/own/mounted.rs
#[must_use = "an unmounted scope frees nothing; store it and call `unmount` when the node goes away"]
pub struct Mounted { /* private */ }
impl Mounted {
#[track_caller] pub fn new() -> Self;
pub fn with<T>(&self, build: impl FnOnce() -> T) -> T;
pub fn owner(&self) -> &Owner;
pub fn unmount(mut self);
}Letting the effects behind a node's bindings drop their own scopes instead would defer every cleanup by one poll of the executor. That is one frame in which an unmounted node's cleanups have not run: a timer firing into a disposed scope, a row still holding its slot in a shared table, an observer reporting geometry for a node that is gone. Reading an arena-backed handle after its owner is disposed of panics, so the frame in between is not cosmetic.
Two further rules the type carries:
Dropdisposes as well, so a scope dropped during a panic or by a container that owns it still frees.unmountis the intended path;Dropis the safety net.- A cleanup runs with no scope current, deliberately. Cleanups also run when the last handle to
an owner is dropped, from anywhere, so a rule that held only on the
unmountpath would be a rule nothing could rely on. Everything a cleanup needs must be captured when it is registered.
Contexts live in the owner's own FxHashMap<TypeId, ...> and a lookup walks towards the root. The
key is the type and nothing else, which is why a newtype is the rule rather than a suggestion.
Two debug-build growth alarms report, at error level through tracing, without panicking:
MAX_OWNER_DEPTH = 4096 for the owner chain, and MAX_OWNER_CHILDREN = 1024 for one owner's child
count. The second exists because of the next section.
Scope and generation retirement
An owner keeps a weak reference to every child it has ever had, and dropping a child does not
remove it. children is a Vec that only grows. A long-lived parent with short-lived children —
a list that scrolls, a table that filters, a route that changes — accumulates one dead entry per
child ever created, and disposing of that parent eventually costs time proportional to everything it
has ever held.
Scope fixes that by handing members out of a generation, and retiring generations in place.
// crates/zgui-reactive/src/own/scope.rs
const RETIRE_AFTER: usize = 64;
impl Scope {
#[must_use] pub fn new() -> Self;
#[track_caller] pub fn mount(&self) -> Mounted;
#[must_use] pub fn live(&self) -> usize;
#[must_use] pub fn generation_children(&self) -> usize;
#[must_use] pub fn generations_created(&self) -> usize;
}On each release, the newest generation is replaced when it holds more dead members than the whole scope holds live ones, with a floor of 64:
if current.created.saturating_sub(current.live) > self.live.get().max(RETIRE_AFTER) {
generations.push(Generation { id, owner: self.parent.child(), created: 0, live: 0 });
}Then every generation that is neither the newest nor still occupied is dropped whole.
Three properties, each load-bearing:
| Property | Consequence if broken |
|---|---|
| Generations are siblings under the scope's own owner, never nested | Dropping a spent generation would dispose of the one that replaced it, and every live member in it |
| A live member never delays retirement and is never moved | The case that grows fastest — a virtualised list scrolling through a million rows — is also the case that always has rows on screen |
| A retired generation's owner is dropped only when its last member has gone | It then finds only dead references and cleans nothing |
What is left is one dead entry per generation rather than per member.
generation_children() is the number that would have grown without bound;
generations_created() is the residual that still grows, far more slowly. A test mounts and
unmounts 10 000 members and asserts generation_children() <= 65.
The For component does not use Scope. It implements the same policy directly over Owner, in
crates/zgui-view/src/flow/each/generation.rs, retiring when a generation's dead count exceeds the
list's live item count with no floor, and stating its own bound: dead references at most
2 × live + generations. Scope is the general form of the idea, published for code that keeps a
churning sibling set of its own.
What Selector does internally
The problem is one shared piece of state that every row of a list compares against: "which one is
selected?". Written the obvious way, every row reads the shared selected signal and compares it to
its own key. Every row is therefore a subscriber of that signal, so changing the selection in a
thousand-row list re-runs a thousand bindings to change two of them.
A memo per row is not enough. The memo does stop the row's closure from re-running when its own answer is unchanged, so only two closures run — but the shared signal still has a thousand subscribers. Every one of those memos is marked, every one of the thousand tasks behind them is polled, and every one recomputes its comparison. The work is O(rows) per selection change, and the saving is only the body of the binding.
The engine's Selector inverts the graph instead:
// reactive_graph/src/computed/selector.rs
pub struct Selector<T> where T: PartialEq + Eq + Clone + Hash + 'static {
subs: Arc<RwLock<FxHashMap<T, ArcRwSignal<bool>>>>, // one signal per watched key
v: Arc<RwLock<Option<T>>>, // the last value of the source
f: Arc<dyn Fn(&T, &T) -> bool + Send + Sync>,
effect: Arc<RenderEffect<T>>, // the one subscriber of the source
}The source has exactly one subscriber: the selector's own internal effect. A row calling
selected(&key) tracks the small ArcRwSignal<bool> for its own key and nothing else. When the
source changes, that effect walks the map once and notifies only the key that matches the new value
and the key that matched the previous one. Two rows re-run instead of a thousand.
The walk is proportional to the number of watched keys, and the engine's selector never removes an entry. Left alone, every key a long-running list ever displayed stays in that map, and the walk grows with the history rather than with what is on screen.
So zgui's Selector wraps it and adds eviction:
// crates/zgui-reactive/src/reexport/selector.rs
pub struct Selector<K> where K: Eq + Hash + Clone + Send + Sync + 'static {
inner: reactive_graph::computed::Selector<K>,
watchers: Rc<RefCell<Watchers<K>>>,
}
struct Watchers<K> {
counts: HashMap<K, usize>, // how many scopes watch each key
registered: HashSet<(usize, K)>, // which (scope, key) pairs are already registered
}is_selected(&key) calls watch(key) and then inner.selected(key). watch takes the current
owner's debug_id() as the scope's identity, inserts the (scope, key) pair, and returns early if
the pair was already there — so reading the same key twice in one row registers one cleanup, not
two. It then bumps the per-key count and registers an on_cleanup_local that removes the pair,
decrements the count, and calls inner.remove(&key) when the count reaches zero. A key two rows
watch survives the first row's unmount.
| Constructor or method | Signature |
|---|---|
Selector::new | pub fn new(source: impl Fn() -> K + Clone + Send + Sync + 'static) -> Self |
Selector::new_with_fn | takes matches: impl Fn(&K, &K) -> bool + Clone + Send + Sync + 'static, for keys not compared by equality — a path selected when it is a prefix of the current one, a range containing it |
Selector::is_selected | pub fn is_selected(&self, key: &K) -> bool |
Selector::watched_keys | pub fn watched_keys(&self) -> usize — the diagnostic a long-running list's tests should assert on |
Measured by the crate's own tests: eight rows, eight first runs, and after a selection change
exactly two more (only_the_two_affected_rows_re_run). Ten thousand mount-and-unmount cycles leave
watched_keys() == 0 (ten_thousand_rows_leave_nothing_behind).
With no current owner, is_selected calls assert_owner("Selector::is_selected"). That is a debug
panic. In release builds the assertion compiles away, nothing can evict the entry, and it is kept
for the life of the selector. Call it from the row's own scope.
Selector<K> holds Rc<RefCell<..>> internally, so it is not Send, even though K is required
to be — that bound comes from the engine.
Storage
Every slot in the arena is a Box<dyn Any + Send + Sync>, because the map is one map for the
process. Storage is how a value that is neither Send nor Sync gets in.
// reactive_graph/src/owner/storage.rs
pub trait Storage<T>: Send + Sync + 'static {
type Wrapped: StorageAccess<T> + Send + Sync + 'static;
fn wrap(value: T) -> Self::Wrapped;
fn try_with<U>(node: NodeId, fun: impl FnOnce(&T) -> U) -> Option<U>;
fn try_with_mut<U>(node: NodeId, fun: impl FnOnce(&mut T) -> U) -> Option<U>;
fn try_set(node: NodeId, value: T) -> Option<T>;
fn take(node: NodeId) -> Option<T>;
}SyncStorage | LocalStorage | |
|---|---|---|
Wrapped | T | SendWrapper<T> |
Bound on T | Send + Sync + 'static | 'static |
| Downcast target in the arena | T | SendWrapper<T> |
| Readable from | any thread | the creating thread only |
| Constructors | new, signal, derive | new_local, signal_local, derive_local |
Every handle type is generic over storage and defaults to SyncStorage. Trigger and ArcTrigger
are the exceptions: they carry no value, so they have nothing to store.
The run-time check is not in the handle and not in the arena. It is in
LocalStorage::try_with, which downcasts the slot to SendWrapper<T> and dereferences it.
SendWrapper compares the current thread against the one it was constructed on and panics if they
differ:
Dereferenced SendWrapper<T> variable from a thread different to the one it has been created with.So the promise is checked per read, and it fires on the first read from the wrong thread rather than
at creation. The same mechanism backs provide_local_context, which parks its value in a private
Local<T>(SendWrapper<T>) newtype so that it cannot collide with a provide_context of the same
type.
What is deliberately not published
Four engine APIs are unreachable from zgui-reactive's root, and the absence of each is asserted by
a compile-fail test in crates/zgui-reactive/tests/ui/.
| Not published | What it would have done | Use instead |
|---|---|---|
Effect::new_sync | Schedules an effect that can run off the UI thread, where it may not touch the document | RenderEffect |
Effect::new_isomorphic | Exists to run on a server as well as a client, which this framework has no notion of | RenderEffect |
Owner::on_cleanup | Requires impl FnOnce() + Send + Sync, so the first cleanup capturing anything from a view fails to compile | on_cleanup_local |
StoreFieldIterator (unkeyed store indexing) | Re-runs every later sibling's observers, and panics with index out of bounds when the collection shrinks under a live observer | #[store(key: K = ...)] and at_key |
Two more are never re-exported at all, and the reason is the same each time: the substitute is better.
untrack(...)— the_untrackedmethod family says the same thing at the read site, where the decision belongs.enter_non_reactive_zoneis not a substitute; it suppresses a diagnostic.batch(...)— unnecessary. A write never runs anything synchronously, and the one-bit notification slot coalesces every write between two flushes into at most one re-run per observer.
The engine crate itself is not re-exported from the root either, so neither is reachable through
zgui. The exception is zgui_reactive::store::reactive_stores, which is published at its module
path because #[derive(Store)] expands to reactive_stores:: paths in the deriving crate.
Costs
| Operation | What it touches | Proportional to |
|---|---|---|
get() on an arena signal, no observer | 2 arena read locks (disposal check, value clone), 1 value read lock, 1 clone of T | constant |
get() on an arena signal, tracked | 4 arena read locks (disposal check, build AnySource, add subscriber, value clone), 1 value read lock, 2 set insertions | constant |
get() on an ArcRwSignal | no arena at all: 1 value read lock, plus the same 2 set insertions when tracked | constant |
get_untracked() | 1 arena read lock, 1 value read lock | constant |
set() / update() | 2 arena read locks, 1 value write lock, then clone the subscriber set and mark each entry | number of direct subscribers |
| marking beyond the first edge | one mark_check per graph edge reached | size of the observer sub-graph |
flush() with nothing marked | one thread-local claim, one run_until_stalled over an empty queue | nothing |
flush() with work | one poll per ready task, one update_if_necessary walk per task | ready tasks and their dependency sets |
| one effect re-run | dispose its own owner, unsubscribe every old dependency, run, re-subscribe | old dependencies + new dependencies + what the owner held |
| disposing an owner | children, then cleanups, then one arena write lock for all its nodes | everything ever registered under it |
Three consequences worth stating outright:
- The
Archandle types are the fast read. Four arena lock acquisitions per tracked read is the price ofCopy. It is the right trade for a view, where handles are copied into closures constantly and read once per change; it is not the right trade for a hot inner loop. maybe_updateis the write that can decline to mark. Returningfalsecallsuntrackon the guard, so dropping it notifies nobody.- A memo is the only thing that stops propagation. Everything between a signal and an effect is marked; only a memo can answer "unchanged" and end the walk.
Measured on the framework's own workloads, all from docs/performance.md, which is generated by
cargo xtask perf:
| Measurement | Value | What it is |
|---|---|---|
kitchen.click | 11.34 µs | One class toggled on one element, whole frame, 1 851 boxes (band 15.12) |
kitchen.keystroke | 301.39 µs | One edit: one paragraph reshaped, one box repainted (band 407.40) |
idle.turn | 0.07 µs | One turn of the loop over a still document, which draws nothing |
idle.frames | 0.00 | A still document runs no frame at all, so it runs no flush |
The flush is not where a frame's time goes. The profile in docs/perf/pipeline.md put "the event
loop, dispatch, the reactive flush, the cascade, timers, animation, accessibility and
re-hit-testing together" at under 4 % of a click frame. Read that number as history: the click
frame it was measured against cost 38 ms, and three defects it names have since been fixed. What
survives is the shape — the reactive layer is a small, bounded phase of a frame, and there is no
scan of the graph anywhere in it.
Next
The style engine
What the elements an effect touched owe next: selector matching, the cascade, and the damage a changed declaration implies.
The frame
Where the flush sits in the loop, and the exact four things that wake it.
Invalidation
The obligations a document mutation raises, and how they propagate and retire.
Cost model
What each kind of change costs across the whole pipeline, measured.
Invalidation and damage
The fifteen obligation bits a node can owe, how a mark propagates and a walk retires it, and how damage rectangles are produced and merged.
The style engine
How zgui drives Stylo — the DOM traits, selector matching, the state word, the cascade, computed-style sharing, the restyle walk and damage translation.