teksilo_core/event_source.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Backend event subscription infrastructure (architecture §9.4).
5//!
6//! Widgets subscribe to external event sources (database change notifiers,
7//! file watchers, message buses, network response channels) directly from
8//! their `build()` method via [`crate::BuildContext::subscribe_event`]. The
9//! framework bridges events from the publisher's thread to the UI thread via
10//! the application's event-loop proxy and routes them to the widget's
11//! UI-side callback, with automatic per-widget lifetime cleanup.
12//!
13//! This module defines the public [`EventSource`] trait, the opaque
14//! [`SubscriptionHandle`] returned by sources, and the internal
15//! [`TreeAppContext`] / [`EventSourceAdapter`] / [`AppEventPoster`] types that
16//! plug a registered source into the tree.
17
18use std::any::{Any, TypeId};
19use std::cell::{Cell, RefCell};
20use std::collections::HashMap;
21use std::rc::Rc;
22use std::sync::Arc;
23
24use crate::widget::EventContext;
25use crate::window::TeksiloWindowId;
26
27/// An external source of events that widgets can subscribe to.
28///
29/// Implementations include backend message buses, database change notifiers,
30/// file watchers, network response channels — any source that publishes
31/// events asynchronously and that widgets need to react to.
32pub trait EventSource: 'static {
33 /// The key by which subscribers identify which events they care about.
34 /// Typically an enum (e.g. a data layer's `Origin`) or a topic string.
35 type Origin: Clone + 'static;
36
37 /// The event payload delivered to subscriber callbacks. Must be `Send`
38 /// because events cross from the publisher's thread to the UI thread via
39 /// the framework's proxy bridge.
40 type Event: Send + 'static;
41
42 /// Subscribe a callback to events of a given origin. The callback is
43 /// invoked on whatever thread the source publishes from (typically a
44 /// background thread). The returned handle, when dropped, removes the
45 /// subscription from the source's internal registry.
46 fn subscribe(
47 &self,
48 origin: Self::Origin,
49 callback: Arc<dyn Fn(Self::Event) + Send + Sync + 'static>,
50 ) -> SubscriptionHandle;
51}
52
53/// An opaque handle returned by [`EventSource::subscribe`].
54///
55/// The source defines what the handle contains; the framework treats it as a
56/// token whose `Drop` impl performs the unsubscription. Returning an empty
57/// handle (via [`SubscriptionHandle::empty`]) is acceptable for sources that
58/// outlive the application or do not support removal.
59pub struct SubscriptionHandle {
60 _inner: Box<dyn Any>,
61}
62
63impl SubscriptionHandle {
64 /// Wrap an arbitrary value as a subscription handle. The value is dropped
65 /// when the handle is dropped — typically that drop performs removal from
66 /// the source's internal subscriber registry.
67 pub fn new<T: 'static>(token: T) -> Self {
68 Self {
69 _inner: Box::new(token),
70 }
71 }
72
73 /// A handle that performs no cleanup on drop. Use this for sources that
74 /// outlive the application or whose subscribers cannot be individually
75 /// removed.
76 pub fn empty() -> Self {
77 Self::new(())
78 }
79}
80
81/// A unique identifier for a subscription installed via
82/// [`crate::BuildContext::subscribe_event`].
83///
84/// Used internally to look up the UI-side callback when a posted event
85/// arrives back on the UI thread, and to key the per-widget cleanup scope.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
87pub struct SubscriptionId(pub(crate) u64);
88
89/// Posts events from background threads back to the UI thread.
90///
91/// teksilo-core cannot depend on winit or teksilo-app, so this trait acts as the
92/// boundary: teksilo-app provides an implementation that wraps the
93/// `EventLoopProxy<AppEvent>` and converts the calls into the
94/// matching `AppEvent::*` user-event variants.
95///
96/// Two posting paths share this trait:
97///
98/// - `post_subscription_event` — backend events for widgets that
99/// subscribed via [`BuildContext::subscribe_event`](crate::build_context::BuildContext).
100/// - `post_external` — arbitrary typed payloads delivered as
101/// [`AppEvent::External`](crate::app_event::AppEvent::External). Used
102/// by async OS-driven integrations (file dialogs, future background
103/// tasks) that resolve off the UI thread and need to deliver typed
104/// results back to the main loop.
105pub trait AppEventPoster: Send + Sync + 'static {
106 fn post_subscription_event(&self, sub_id: SubscriptionId, event: Box<dyn Any + Send>);
107
108 /// Post an arbitrary typed payload as `AppEvent::External(_)`.
109 /// Default body is a no-op so existing implementations stay
110 /// source-compatible; the real implementation in teksilo-app
111 /// forwards to `EventLoopProxy::send_event`.
112 fn post_external(&self, _payload: Box<dyn Any + Send>) {}
113}
114
115/// Type-erased wrapper around a registered [`EventSource`].
116///
117/// The generic source `S` is consumed when the adapter is constructed via
118/// [`EventSourceAdapter::new`]; only erased closures and `TypeId`s remain.
119/// This lets `WidgetTree` / `BuildContext` reach the source without becoming
120/// generic over `S`.
121pub struct EventSourceAdapter {
122 pub(crate) origin_type: TypeId,
123 pub(crate) origin_type_name: &'static str,
124 pub(crate) event_type: TypeId,
125 pub(crate) event_type_name: &'static str,
126 #[allow(clippy::type_complexity)]
127 pub(crate) subscribe_fn: Box<
128 dyn Fn(
129 Box<dyn Any>,
130 Arc<dyn Fn(Box<dyn Any + Send>) + Send + Sync + 'static>,
131 ) -> SubscriptionHandle,
132 >,
133}
134
135impl EventSourceAdapter {
136 /// Build an adapter from a concrete event source. Called by
137 /// `TeksiloAppBuilder::event_source`.
138 pub fn new<S: EventSource>(source: S) -> Self {
139 let source = Arc::new(source);
140 let origin_type = TypeId::of::<S::Origin>();
141 let origin_type_name = std::any::type_name::<S::Origin>();
142 let event_type = TypeId::of::<S::Event>();
143 let event_type_name = std::any::type_name::<S::Event>();
144
145 let subscribe_fn: Box<
146 dyn Fn(
147 Box<dyn Any>,
148 Arc<dyn Fn(Box<dyn Any + Send>) + Send + Sync + 'static>,
149 ) -> SubscriptionHandle,
150 > = Box::new(move |erased_origin, framework_wrapper| {
151 let origin: Box<S::Origin> = erased_origin
152 .downcast::<S::Origin>()
153 .expect("origin type mismatch — framework bug");
154
155 // Wrap the framework's `Fn(Box<dyn Any + Send>)` into the
156 // `Fn(S::Event)` shape the source expects. The wrapper boxes
157 // the typed event and forwards to the framework's poster.
158 let typed_callback: Arc<dyn Fn(S::Event) + Send + Sync + 'static> =
159 Arc::new(move |event: S::Event| {
160 let erased: Box<dyn Any + Send> = Box::new(event);
161 framework_wrapper(erased);
162 });
163
164 source.subscribe(*origin, typed_callback)
165 });
166
167 Self {
168 origin_type,
169 origin_type_name,
170 event_type,
171 event_type_name,
172 subscribe_fn,
173 }
174 }
175}
176
177/// UI-side callback for a *context-bearing* subscription
178/// ([`BuildContext::subscribe_event_with_ctx`](crate::build_context::BuildContext::subscribe_event_with_ctx)):
179/// it receives the downcast event **and** a fresh [`EventContext`], so it can
180/// imperatively drive toasts, modals, intents and navigation in reaction to a
181/// backend event — the things a plain, context-free `subscribe_event` callback
182/// cannot. teksilo-app invokes it from inside
183/// [`WidgetTree::run_with_event_context`](crate::WidgetTree::run_with_event_context),
184/// keyed by the originating window so the context binds to the right tree.
185///
186/// Stored behind `Rc` (not `Box`) so dispatch can **clone the handle and release
187/// the map borrow before invoking** — the callback runs arbitrary UI code (it
188/// may `open_window`, which synchronously builds a new tree whose widgets can
189/// register *their own* context-bearing subscriptions, i.e. re-enter this very
190/// map). Holding the borrow across the call would `BorrowMutError`-panic there.
191type CtxSubscriptionCallback = Rc<dyn Fn(&dyn Any, &mut EventContext)>;
192
193/// Per-tree app-level subscription state.
194///
195/// Held by `WidgetTree` as `Rc<TreeAppContext>` so that `BuildContext` can
196/// reach the event source adapter, allocate subscription ids, and post the
197/// UI-side callback into the lookup map. Fields are `RefCell`/`Cell`
198/// because the tree borrows itself mutably during `build()` and we need
199/// shared interior access.
200pub struct TreeAppContext {
201 pub(crate) poster: Option<Arc<dyn AppEventPoster>>,
202 pub(crate) event_source: Option<EventSourceAdapter>,
203 /// Plain (context-free) subscription callbacks and the window each was registered
204 /// from. Populated by
205 /// [`BuildContext::subscribe_event`](crate::build_context::BuildContext::subscribe_event).
206 ///
207 /// The window is recorded for the same reason `subscription_ctx_callbacks` below
208 /// records one: this map is shared by every window's tree (one `TreeAppContext`, one
209 /// `Rc` handed to each tree), while a closing window's tree is dropped wholesale with
210 /// no per-widget destroy pass. Without a window key the entries a closed window
211 /// installed could not be purged even in principle, and each would hold whatever its
212 /// closure captured for the rest of the process. See
213 /// [`purge_subscriptions_for_window`](Self::purge_subscriptions_for_window).
214 ///
215 /// `None` for a registration from a windowless tree (headless / tests). No window
216 /// purge ever touches those; only the per-widget teardown in `WidgetTree` does.
217 #[allow(clippy::type_complexity)]
218 pub(crate) subscription_callbacks:
219 RefCell<HashMap<SubscriptionId, (Option<TeksiloWindowId>, Rc<dyn Fn(&dyn Any)>)>>,
220 /// Context-bearing subscription callbacks + the window they target.
221 /// Populated by [`BuildContext::subscribe_event_with_ctx`](crate::build_context::BuildContext::subscribe_event_with_ctx);
222 /// dispatched by teksilo-app with a freshly-minted [`EventContext`]. A given
223 /// `SubscriptionId` lives in exactly one of the two callback maps.
224 ///
225 /// The window is `Option` because a subscription registered from a windowless
226 /// tree (headless / tests) has no tree to mint an `EventContext` from — the
227 /// app-side router then can't deliver it (real app widgets always have a
228 /// window). Direct [`dispatch_subscription_event_with_ctx`](Self::dispatch_subscription_event_with_ctx)
229 /// is window-agnostic, which is what unit tests drive.
230 #[allow(clippy::type_complexity)]
231 pub(crate) subscription_ctx_callbacks:
232 RefCell<HashMap<SubscriptionId, (Option<TeksiloWindowId>, CtxSubscriptionCallback)>>,
233 pub(crate) next_subscription_id: Cell<u64>,
234 /// Application-scoped values keyed by `TypeId`.
235 /// Populated at builder time, read-only after the tree starts running.
236 pub(crate) app_state: HashMap<TypeId, Box<dyn Any>>,
237}
238
239impl TreeAppContext {
240 /// Empty context — no event source, no proxy poster. Used by tests
241 /// and by `WidgetTree::new()`.
242 pub fn empty() -> Self {
243 Self {
244 poster: None,
245 event_source: None,
246 subscription_callbacks: RefCell::new(HashMap::new()),
247 subscription_ctx_callbacks: RefCell::new(HashMap::new()),
248 next_subscription_id: Cell::new(1),
249 app_state: HashMap::new(),
250 }
251 }
252
253 /// Build a context with both a registered event source and a proxy
254 /// poster. Called by `teksilo-app` when constructing a window for an
255 /// application that registered an event source on the builder.
256 pub fn with_source_and_poster(
257 event_source: EventSourceAdapter,
258 poster: Arc<dyn AppEventPoster>,
259 ) -> Self {
260 Self {
261 poster: Some(poster),
262 event_source: Some(event_source),
263 subscription_callbacks: RefCell::new(HashMap::new()),
264 subscription_ctx_callbacks: RefCell::new(HashMap::new()),
265 next_subscription_id: Cell::new(1),
266 app_state: HashMap::new(),
267 }
268 }
269
270 /// Install an app-state registry. Consumes `self`
271 /// and returns a new context with the registry attached; the builder
272 /// calls this after constructing the context and before wrapping it
273 /// in `Rc`.
274 pub fn with_app_state(mut self, registry: HashMap<TypeId, Box<dyn Any>>) -> Self {
275 self.app_state = registry;
276 self
277 }
278
279 /// Install an [`AppEventPoster`] so background work (file dialogs,
280 /// future async-result features) can post typed payloads back to
281 /// the UI loop via `AppEvent::External`. The builder calls this
282 /// unconditionally during `TeksiloAppBuilder::run` — the poster is
283 /// cheap (a thin wrapper around the event-loop proxy) and being
284 /// reachable means widgets do not have to depend on the event-source
285 /// feature for unrelated async-result delivery.
286 pub fn with_poster(mut self, poster: Arc<dyn AppEventPoster>) -> Self {
287 self.poster = Some(poster);
288 self
289 }
290
291 /// Borrow the registered [`AppEventPoster`] if one was installed.
292 /// Used by integrations that need to post typed payloads back to
293 /// the UI loop from an external thread (e.g. file-dialog backends).
294 pub fn poster(&self) -> Option<&Arc<dyn AppEventPoster>> {
295 self.poster.as_ref()
296 }
297
298 /// Look up an app-state value of type `T` previously registered via
299 /// `TeksiloAppBuilder::app_state`.
300 pub fn app_state<T: 'static>(&self) -> Option<&T> {
301 self.app_state
302 .get(&TypeId::of::<T>())
303 .and_then(|boxed| boxed.downcast_ref::<T>())
304 }
305
306 pub(crate) fn allocate_subscription_id(&self) -> SubscriptionId {
307 let id = self.next_subscription_id.get();
308 self.next_subscription_id.set(id + 1);
309 SubscriptionId(id)
310 }
311
312 /// Look up and invoke the UI-side callback for a posted subscription
313 /// event. Returns `true` if a callback was found and invoked.
314 /// The `Rc` handle is **cloned and the map borrow released before the call**, for
315 /// the same reason [`dispatch_subscription_event_with_ctx`](Self::dispatch_subscription_event_with_ctx)
316 /// does it: the callback may re-enter this map. It re-enters on two paths that both
317 /// exist today — a widget built from inside a handler registers its own
318 /// subscription (`borrow_mut` to insert), and a window closed from inside one is
319 /// purged by [`purge_subscriptions_for_window`](Self::purge_subscriptions_for_window)
320 /// (`borrow_mut` to retain). Holding the borrow across the call turns either into a
321 /// `BorrowMutError` panic; hence `Rc`, not `Box`.
322 pub fn dispatch_subscription_event(&self, sub_id: SubscriptionId, event: &dyn Any) -> bool {
323 let callback = self
324 .subscription_callbacks
325 .borrow()
326 .get(&sub_id)
327 .map(|(_window_id, callback)| Rc::clone(callback));
328 match callback {
329 Some(callback) => {
330 callback(event);
331 true
332 }
333 None => false,
334 }
335 }
336
337 /// The window a *context-bearing* subscription targets, if `sub_id` names one
338 /// **and** it was registered from a window (always true in a real app).
339 /// teksilo-app peeks this to know which window's tree to mint the
340 /// [`EventContext`] from before dispatching.
341 pub fn ctx_subscription_window(&self, sub_id: SubscriptionId) -> Option<TeksiloWindowId> {
342 self.subscription_ctx_callbacks
343 .borrow()
344 .get(&sub_id)
345 .and_then(|(window_id, _)| *window_id)
346 }
347
348 /// Whether `sub_id` names a context-bearing subscription, and the window
349 /// it was registered from: `None` when it names none, `Some(None)` for
350 /// one registered in a tree no window hosts (the application tree, in a
351 /// running app), `Some(Some(window))` otherwise. teksilo-app reads it to
352 /// pick the tree it mints the [`EventContext`] from.
353 pub fn ctx_subscription_target(
354 &self,
355 sub_id: SubscriptionId,
356 ) -> Option<Option<TeksiloWindowId>> {
357 self.subscription_ctx_callbacks
358 .borrow()
359 .get(&sub_id)
360 .map(|(window_id, _)| *window_id)
361 }
362
363 /// Invoke the *context-bearing* UI-side callback for a posted subscription
364 /// event, passing the freshly-minted [`EventContext`]. Returns `true` if a
365 /// callback was found and invoked. Called by teksilo-app from inside
366 /// [`WidgetTree::run_with_event_context`](crate::WidgetTree::run_with_event_context).
367 ///
368 /// The `Rc` handle is **cloned and the map borrow released before the call**,
369 /// so the callback may freely re-enter this map — e.g. `ctx.open_window(...)`
370 /// synchronously builds a new tree whose widgets register their own
371 /// context-bearing subscriptions. (Holding the borrow across the call would
372 /// panic there; hence `Rc`, not `Box`.)
373 pub fn dispatch_subscription_event_with_ctx(
374 &self,
375 sub_id: SubscriptionId,
376 event: &dyn Any,
377 ctx: &mut EventContext,
378 ) -> bool {
379 let callback = self
380 .subscription_ctx_callbacks
381 .borrow()
382 .get(&sub_id)
383 .map(|(_window_id, callback)| Rc::clone(callback));
384 match callback {
385 Some(callback) => {
386 callback(event, ctx);
387 true
388 }
389 None => false,
390 }
391 }
392
393 /// Number of context-bearing subscription callbacks currently installed.
394 /// Companion to [`subscription_count`](Self::subscription_count); used by
395 /// lifecycle tests to assert the ctx-map teardown ran.
396 pub fn ctx_subscription_count(&self) -> usize {
397 self.subscription_ctx_callbacks.borrow().len()
398 }
399
400 /// Drop every context-bearing subscription targeting `window_id`. Called by
401 /// teksilo-app when a window closes, so the shared, longer-lived
402 /// `TreeAppContext` map doesn't retain inert callbacks for a torn-down tree
403 /// (a window's tree is dropped without a per-widget `destroy_subtree` pass).
404 /// Mirrors [`AsyncCompletionHandle::purge_window`](crate::AsyncCompletionHandle::purge_window).
405 pub fn purge_ctx_subscriptions_for_window(&self, window_id: TeksiloWindowId) {
406 self.subscription_ctx_callbacks
407 .borrow_mut()
408 .retain(|_, (win, _)| *win != Some(window_id));
409 }
410
411 /// Drop every *plain* (context-free) subscription callback registered from
412 /// `window_id`. Called by teksilo-app when a window closes, beside
413 /// [`purge_ctx_subscriptions_for_window`](Self::purge_ctx_subscriptions_for_window).
414 ///
415 /// The two maps need two purges for the same reason they need two dispatch
416 /// functions: a given `SubscriptionId` lives in exactly one of them. Without this
417 /// one, every callback the closed window's widgets installed stays in the shared map
418 /// for the life of the process, holding strong references to whatever it captured
419 /// (view-models, document stores, context handles), because a closing window's tree
420 /// is dropped wholesale and nothing runs the per-widget removal in
421 /// `WidgetTree::destroy_subtree_inner`.
422 ///
423 /// A registration made from a windowless tree records `None` and is never purged
424 /// here; only the per-widget teardown reaches it, which an application drives
425 /// through [`BuildContext::destroy_subtree`](crate::BuildContext::destroy_subtree).
426 pub fn purge_subscriptions_for_window(&self, window_id: TeksiloWindowId) {
427 self.subscription_callbacks
428 .borrow_mut()
429 .retain(|_, (win, _)| *win != Some(window_id));
430 }
431
432 /// Number of UI-side subscription callbacks currently installed.
433 /// Used by lifecycle tests to assert cleanup ran correctly.
434 pub fn subscription_count(&self) -> usize {
435 self.subscription_callbacks.borrow().len()
436 }
437}
438
439#[cfg(test)]
440mod tests {
441 use super::*;
442 use crate::signal::Signal;
443 use crate::widget::{LayoutContext, Widget};
444 use crate::widget_id::WidgetId;
445 use crate::widget_tree::WidgetTree;
446 use std::sync::Mutex;
447 use teksilo_canvas::SizeProposal;
448
449 // --- Test event source ---
450
451 #[derive(Clone, PartialEq, Eq, Hash, Debug)]
452 enum TestOrigin {
453 Created,
454 Updated,
455 }
456
457 #[derive(Clone, Debug, PartialEq)]
458 struct TestEvent {
459 id: u64,
460 message: String,
461 }
462
463 /// A trivial in-process event source. Holds a list of (id, origin, callback)
464 /// entries; `publish` walks them and invokes matching callbacks
465 /// synchronously on the calling thread.
466 ///
467 /// ⚠ **Its handle really unsubscribes**, which is not decoration. A source that
468 /// returns [`SubscriptionHandle::empty`] keeps every subscriber it was ever given,
469 /// so one publish reaches the wrappers of *all* of a widget's past builds. That used
470 /// to be invisible, because each of those wrappers posted a by-then-dead id and the
471 /// dispatch dropped it; now that an id outlives a rebuild it would deliver the same
472 /// event once per past build. A mock that never removes anything would model a
473 /// source no real one resembles and would make this suite assert the wrong thing.
474 #[derive(Default)]
475 struct MockEventSource {
476 #[allow(clippy::type_complexity)]
477 subscribers: Arc<
478 Mutex<
479 Vec<(
480 u64,
481 TestOrigin,
482 Arc<dyn Fn(TestEvent) + Send + Sync + 'static>,
483 )>,
484 >,
485 >,
486 next_id: std::sync::atomic::AtomicU64,
487 }
488
489 /// Removes its entry from [`MockEventSource`] on drop, the way a real source's
490 /// token does.
491 struct MockToken {
492 #[allow(clippy::type_complexity)]
493 subscribers: Arc<
494 Mutex<
495 Vec<(
496 u64,
497 TestOrigin,
498 Arc<dyn Fn(TestEvent) + Send + Sync + 'static>,
499 )>,
500 >,
501 >,
502 id: u64,
503 }
504
505 impl Drop for MockToken {
506 fn drop(&mut self) {
507 if let Ok(mut subs) = self.subscribers.lock() {
508 subs.retain(|(id, _, _)| *id != self.id);
509 }
510 }
511 }
512
513 impl EventSource for MockEventSource {
514 type Origin = TestOrigin;
515 type Event = TestEvent;
516
517 fn subscribe(
518 &self,
519 origin: Self::Origin,
520 callback: Arc<dyn Fn(Self::Event) + Send + Sync + 'static>,
521 ) -> SubscriptionHandle {
522 let id = self
523 .next_id
524 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
525 self.subscribers
526 .lock()
527 .unwrap()
528 .push((id, origin, callback));
529 SubscriptionHandle::new(MockToken {
530 subscribers: self.subscribers.clone(),
531 id,
532 })
533 }
534 }
535
536 impl MockEventSource {
537 fn publish(&self, origin: TestOrigin, event: TestEvent) {
538 let subs = self.subscribers.lock().unwrap();
539 for (_id, sub_origin, cb) in subs.iter() {
540 if *sub_origin == origin {
541 cb(event.clone());
542 }
543 }
544 }
545
546 fn subscriber_count(&self) -> usize {
547 self.subscribers.lock().unwrap().len()
548 }
549 }
550
551 /// A poster that buffers posted events into a thread-safe queue. Tests
552 /// drain it after `publish` and dispatch them through the tree's
553 /// app_context, mirroring the real proxy → user_event flow.
554 #[derive(Default)]
555 struct TestPoster {
556 #[allow(clippy::type_complexity)]
557 queue: Mutex<Vec<(SubscriptionId, Box<dyn Any + Send>)>>,
558 }
559
560 impl AppEventPoster for TestPoster {
561 fn post_subscription_event(&self, sub_id: SubscriptionId, event: Box<dyn Any + Send>) {
562 self.queue.lock().unwrap().push((sub_id, event));
563 }
564 }
565
566 impl TestPoster {
567 fn drain(&self) -> Vec<(SubscriptionId, Box<dyn Any + Send>)> {
568 std::mem::take(&mut *self.queue.lock().unwrap())
569 }
570 }
571
572 // --- Test widget that subscribes in build() ---
573
574 #[derive(Debug)]
575 struct SubscribingWidget {
576 origin: TestOrigin,
577 last_message: Signal<String>,
578 }
579
580 impl Widget for SubscribingWidget {
581 fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
582 let last_message = self.last_message.clone();
583 ctx.subscribe_event(self.origin.clone(), move |event: &TestEvent| {
584 last_message.set(event.message.clone());
585 });
586 Vec::new()
587 }
588
589 fn layout_response(
590 &self,
591 proposal: SizeProposal,
592 _ctx: &LayoutContext,
593 ) -> crate::widget::LayoutResponse {
594 proposal.resolve(0.0, 0.0).into()
595 }
596 }
597
598 /// Subscribes via the *context-bearing* API in `build()` — headless, so the
599 /// registration records `None` for the window but still lands in the ctx map
600 /// and is torn down on destroy.
601 #[derive(Debug)]
602 struct CtxSubscribingWidget {
603 origin: TestOrigin,
604 last_message: Signal<String>,
605 }
606
607 impl Widget for CtxSubscribingWidget {
608 fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
609 let last_message = self.last_message.clone();
610 ctx.subscribe_event_with_ctx(
611 self.origin.clone(),
612 move |event: &TestEvent, _ctx: &mut crate::widget::EventContext| {
613 last_message.set(event.message.clone());
614 },
615 );
616 Vec::new()
617 }
618
619 fn layout_response(
620 &self,
621 proposal: SizeProposal,
622 _ctx: &LayoutContext,
623 ) -> crate::widget::LayoutResponse {
624 proposal.resolve(0.0, 0.0).into()
625 }
626 }
627
628 // --- Helpers ---
629
630 fn install_source(
631 tree: &mut WidgetTree,
632 source: MockEventSource,
633 ) -> (Arc<MockEventSource>, Arc<TestPoster>) {
634 let source = Arc::new(source);
635 // We need to share the source between the test and the adapter,
636 // so wrap a thin proxy that delegates to the Arc.
637 struct SharedSource {
638 inner: Arc<MockEventSource>,
639 }
640 impl EventSource for SharedSource {
641 type Origin = TestOrigin;
642 type Event = TestEvent;
643 fn subscribe(
644 &self,
645 origin: Self::Origin,
646 callback: Arc<dyn Fn(Self::Event) + Send + Sync + 'static>,
647 ) -> SubscriptionHandle {
648 self.inner.subscribe(origin, callback)
649 }
650 }
651
652 let adapter = EventSourceAdapter::new(SharedSource {
653 inner: source.clone(),
654 });
655 let poster: Arc<TestPoster> = Arc::new(TestPoster::default());
656 let poster_dyn: Arc<dyn AppEventPoster> = poster.clone();
657 let app_context =
658 std::rc::Rc::new(TreeAppContext::with_source_and_poster(adapter, poster_dyn));
659 tree.set_app_context(app_context);
660 (source, poster)
661 }
662
663 fn drain_and_dispatch(tree: &WidgetTree, poster: &TestPoster) {
664 let events = poster.drain();
665 for (sub_id, event) in events {
666 tree.app_context()
667 .dispatch_subscription_event(sub_id, &*event);
668 }
669 }
670
671 // --- Tests ---
672
673 #[test]
674 fn subscribe_event_delivers_to_widget_signal() {
675 let mut tree = WidgetTree::new();
676 let (source, poster) = install_source(&mut tree, MockEventSource::default());
677
678 let signal = Signal::new(String::new());
679 let _id = tree.add(SubscribingWidget {
680 origin: TestOrigin::Created,
681 last_message: signal.clone(),
682 });
683
684 assert_eq!(source.subscriber_count(), 1);
685 assert_eq!(tree.app_context().subscription_count(), 1);
686
687 source.publish(
688 TestOrigin::Created,
689 TestEvent {
690 id: 1,
691 message: "hello".to_string(),
692 },
693 );
694 drain_and_dispatch(&tree, &poster);
695
696 assert_eq!(signal.get(), "hello");
697 }
698
699 /// A context-bearing subscription registered in a tree no window hosts
700 /// is still one: the target says so, with no window, where the window
701 /// query cannot tell it from no subscription at all.
702 #[test]
703 fn a_windowless_ctx_subscription_is_told_apart_from_none() {
704 let tree = WidgetTree::new();
705 let app_ctx = tree.app_context().clone();
706 let sub_id = app_ctx.allocate_subscription_id();
707 let stored: std::rc::Rc<dyn Fn(&dyn Any, &mut crate::widget::EventContext)> =
708 std::rc::Rc::new(|_, _| {});
709 app_ctx
710 .subscription_ctx_callbacks
711 .borrow_mut()
712 .insert(sub_id, (None, stored));
713 assert_eq!(app_ctx.ctx_subscription_window(sub_id), None);
714 assert_eq!(app_ctx.ctx_subscription_target(sub_id), Some(None));
715 }
716
717 #[test]
718 fn subscribe_event_with_ctx_dispatches_inside_fresh_context() {
719 use crate::window::{NoopWindowOps, TeksiloWindowId};
720
721 let mut tree = WidgetTree::new();
722 // Register a context-bearing callback the way
723 // `BuildContext::subscribe_event_with_ctx` does — but directly, so the
724 // test needs no real window (that routing is covered end-to-end by the
725 // `toast_demo` example and the Skribisto importer).
726 let app_ctx = tree.app_context().clone();
727 let sub_id = app_ctx.allocate_subscription_id();
728 let win = TeksiloWindowId::new(1);
729 let seen = Signal::new(String::new());
730 let seen_cb = seen.clone();
731 let stored: std::rc::Rc<dyn Fn(&dyn Any, &mut crate::widget::EventContext)> =
732 std::rc::Rc::new(move |event_any, _ctx: &mut crate::widget::EventContext| {
733 let ev = event_any
734 .downcast_ref::<TestEvent>()
735 .expect("subscription event downcast failed");
736 seen_cb.set(ev.message.clone());
737 });
738 app_ctx
739 .subscription_ctx_callbacks
740 .borrow_mut()
741 .insert(sub_id, (Some(win), stored));
742
743 // The target window is peekable (teksilo-app reads it to pick the tree
744 // whose `EventContext` it mints).
745 assert_eq!(app_ctx.ctx_subscription_window(sub_id), Some(win));
746 assert_eq!(app_ctx.ctx_subscription_window(SubscriptionId(9999)), None);
747 assert_eq!(app_ctx.ctx_subscription_target(sub_id), Some(Some(win)));
748 assert_eq!(app_ctx.ctx_subscription_target(SubscriptionId(9999)), None);
749
750 // Dispatch inside a fresh `EventContext`, exactly like teksilo-app's
751 // `try_dispatch_subscription_with_ctx`.
752 let event = TestEvent {
753 id: 9,
754 message: "progress-42".to_string(),
755 };
756 let handled = std::cell::Cell::new(false);
757 tree.run_with_event_context(&mut NoopWindowOps, |ctx| {
758 handled.set(app_ctx.dispatch_subscription_event_with_ctx(sub_id, &event, ctx));
759 });
760 assert!(
761 handled.get(),
762 "context-bearing dispatch must find the callback"
763 );
764 assert_eq!(seen.get(), "progress-42");
765
766 // An unknown sub_id is not consumed (so the caller falls back to the
767 // plain, context-free path).
768 tree.run_with_event_context(&mut NoopWindowOps, |ctx| {
769 assert!(!app_ctx.dispatch_subscription_event_with_ctx(
770 SubscriptionId(9999),
771 &event,
772 ctx
773 ));
774 });
775 }
776
777 /// Regression for the re-entrancy panic: dispatch must clone the `Rc` and
778 /// **release the map borrow before invoking** the callback, so a callback
779 /// that re-enters the same map — as `ctx.open_window(...)` does via a nested
780 /// `build()` calling `subscribe_event_with_ctx` — does not `BorrowMutError`.
781 #[test]
782 fn ctx_dispatch_releases_borrow_before_invoking_callback() {
783 use crate::window::{NoopWindowOps, TeksiloWindowId};
784
785 let mut tree = WidgetTree::new();
786 let app_ctx = tree.app_context().clone();
787 let sub_id = app_ctx.allocate_subscription_id();
788
789 let reenter_ctx = app_ctx.clone();
790 let reentered = std::rc::Rc::new(std::cell::Cell::new(false));
791 let flag = reentered.clone();
792 let cb: std::rc::Rc<dyn Fn(&dyn Any, &mut crate::widget::EventContext)> =
793 std::rc::Rc::new(move |_ev, _ctx| {
794 // Simulate open_window → build() → subscribe_event_with_ctx: a
795 // fresh registration into the SAME map while this callback runs.
796 reenter_ctx.subscription_ctx_callbacks.borrow_mut().insert(
797 SubscriptionId(4242),
798 (
799 Some(TeksiloWindowId::new(2)),
800 std::rc::Rc::new(|_e: &dyn Any, _c: &mut crate::widget::EventContext| {}),
801 ),
802 );
803 flag.set(true);
804 });
805 app_ctx
806 .subscription_ctx_callbacks
807 .borrow_mut()
808 .insert(sub_id, (Some(TeksiloWindowId::new(1)), cb));
809
810 let event = TestEvent {
811 id: 1,
812 message: String::new(),
813 };
814 tree.run_with_event_context(&mut NoopWindowOps, |ctx| {
815 assert!(app_ctx.dispatch_subscription_event_with_ctx(sub_id, &event, ctx));
816 });
817
818 assert!(
819 reentered.get(),
820 "callback ran and its re-entrant map insert did not panic"
821 );
822 assert_eq!(
823 app_ctx.ctx_subscription_count(),
824 2,
825 "original + the re-entrant insert both present"
826 );
827 }
828
829 /// Exercises the real `BuildContext::subscribe_event_with_ctx` (headless →
830 /// window `None`) end-to-end: registration lands in the ctx map, and
831 /// destroying the widget tears it back down (covers the widget-destroy path's
832 /// removal from the ctx map).
833 #[test]
834 fn subscribe_event_with_ctx_registers_and_tears_down() {
835 let mut tree = WidgetTree::new();
836 let (_source, _poster) = install_source(&mut tree, MockEventSource::default());
837
838 let id = tree.add(CtxSubscribingWidget {
839 origin: TestOrigin::Created,
840 last_message: Signal::new(String::new()),
841 });
842 // The ctx path uses the OTHER map — the plain count stays 0.
843 assert_eq!(tree.app_context().ctx_subscription_count(), 1);
844 assert_eq!(tree.app_context().subscription_count(), 0);
845
846 tree.destroy_subtree(id);
847 assert_eq!(
848 tree.app_context().ctx_subscription_count(),
849 0,
850 "destroying the widget must remove its context-bearing subscription"
851 );
852 }
853
854 #[test]
855 fn unrelated_origin_does_not_fire_callback() {
856 let mut tree = WidgetTree::new();
857 let (source, poster) = install_source(&mut tree, MockEventSource::default());
858
859 let signal = Signal::new(String::new());
860 let _id = tree.add(SubscribingWidget {
861 origin: TestOrigin::Created,
862 last_message: signal.clone(),
863 });
864
865 source.publish(
866 TestOrigin::Updated,
867 TestEvent {
868 id: 1,
869 message: "ignored".to_string(),
870 },
871 );
872 drain_and_dispatch(&tree, &poster);
873
874 assert_eq!(signal.get(), "");
875 }
876
877 #[test]
878 fn destroying_widget_removes_ui_callback() {
879 let mut tree = WidgetTree::new();
880 let (_source, _poster) = install_source(&mut tree, MockEventSource::default());
881
882 let signal = Signal::new(String::new());
883 let id = tree.add(SubscribingWidget {
884 origin: TestOrigin::Created,
885 last_message: signal.clone(),
886 });
887
888 assert_eq!(tree.app_context().subscription_count(), 1);
889 tree.destroy_subtree(id);
890 assert_eq!(tree.app_context().subscription_count(), 0);
891 }
892
893 /// The window-closing twin of [`destroying_widget_removes_ui_callback`].
894 ///
895 /// Closing a window drops its whole tree at once: nothing calls `destroy_subtree`,
896 /// so the per-widget removal the test above pins never runs for a single one of the
897 /// widgets that window built. The callback map is shared by every window in the
898 /// process (one `TreeAppContext`, one `Rc` per tree), so without a window key those
899 /// closures stay live for the rest of the session holding everything they captured.
900 /// `purge_subscriptions_for_window` is what the app-side close path calls instead,
901 /// and this test is the only place that says so about the plain map.
902 ///
903 /// Three trees stand in for the three cases that must be told apart: the window
904 /// being closed, a window that stays open, and a windowless (headless) registration
905 /// that no window purge may ever touch.
906 #[test]
907 fn closing_a_window_removes_its_ui_callbacks() {
908 use crate::WindowStateInit;
909 use crate::window::{TeksiloWindowId, WindowPlacement, WindowState};
910
911 fn window_state(id: u64) -> WindowState {
912 WindowState::new(WindowStateInit {
913 id: TeksiloWindowId::new(id),
914 string_id: None,
915 placement: WindowPlacement::Floating,
916 title: String::new(),
917 size: (800, 600),
918 position: (0, 0),
919 focused: true,
920 resizable: true,
921 always_on_top: false,
922 })
923 }
924
925 // Window 1 installs the shared context; window 2 and the headless tree get a
926 // clone of the same `Rc`, exactly as `WindowManager` hands out its
927 // `app_context_template`.
928 let mut tree_one = WidgetTree::new();
929 tree_one.set_window_state(window_state(1));
930 let (source, poster) = install_source(&mut tree_one, MockEventSource::default());
931
932 let mut tree_two = WidgetTree::new();
933 tree_two.set_window_state(window_state(2));
934 tree_two.set_app_context(tree_one.app_context().clone());
935
936 let mut tree_headless = WidgetTree::new();
937 tree_headless.set_app_context(tree_one.app_context().clone());
938
939 let one = Signal::new(String::new());
940 let two = Signal::new(String::new());
941 let headless = Signal::new(String::new());
942 tree_one.add(SubscribingWidget {
943 origin: TestOrigin::Created,
944 last_message: one.clone(),
945 });
946 tree_two.add(SubscribingWidget {
947 origin: TestOrigin::Created,
948 last_message: two.clone(),
949 });
950 tree_headless.add(SubscribingWidget {
951 origin: TestOrigin::Created,
952 last_message: headless.clone(),
953 });
954
955 let app_ctx = tree_one.app_context().clone();
956 assert_eq!(app_ctx.subscription_count(), 3);
957 assert_eq!(source.subscriber_count(), 3);
958
959 // Close window 1 the way `WindowManager::close_window` does: purge first, then
960 // drop the tree (which drops the arena's subscription handles and so
961 // unregisters that window from the source).
962 app_ctx.purge_subscriptions_for_window(TeksiloWindowId::new(1));
963 drop(tree_one);
964
965 assert_eq!(
966 app_ctx.subscription_count(),
967 2,
968 "the closed window's callback must be gone, and neither the other window's \
969 nor the windowless one may go with it"
970 );
971 assert_eq!(
972 source.subscriber_count(),
973 2,
974 "dropping the tree unregisters the closed window from the source"
975 );
976
977 source.publish(
978 TestOrigin::Created,
979 TestEvent {
980 id: 1,
981 message: "after the close".to_string(),
982 },
983 );
984 drain_and_dispatch(&tree_two, &poster);
985
986 assert_eq!(one.get(), "", "a closed window's callback must not run");
987 assert_eq!(
988 two.get(),
989 "after the close",
990 "a window that stayed open keeps receiving"
991 );
992 assert_eq!(
993 headless.get(),
994 "after the close",
995 "a windowless registration is not purged by any window id"
996 );
997
998 // And the surviving window purges on its own close, leaving only the
999 // windowless entry, which nothing but a widget destroy can reach.
1000 app_ctx.purge_subscriptions_for_window(TeksiloWindowId::new(2));
1001 assert_eq!(app_ctx.subscription_count(), 1);
1002 }
1003
1004 #[test]
1005 fn in_flight_event_after_destroy_is_dropped_not_delivered() {
1006 // An event that was buffered in the proxy queue before the widget
1007 // was destroyed is silently dropped once cleanup completes. The
1008 // destroy path removes the UI-side callback synchronously, so by
1009 // the time the drain happens the callback lookup misses. This
1010 // preserves the invariant that a destroyed widget never sees
1011 // another event.
1012 let mut tree = WidgetTree::new();
1013 let (source, poster) = install_source(&mut tree, MockEventSource::default());
1014
1015 let signal = Signal::new(String::new());
1016 let id = tree.add(SubscribingWidget {
1017 origin: TestOrigin::Created,
1018 last_message: signal.clone(),
1019 });
1020
1021 // Publish — the wrapper fires and enqueues into the test poster.
1022 source.publish(
1023 TestOrigin::Created,
1024 TestEvent {
1025 id: 7,
1026 message: "buffered".to_string(),
1027 },
1028 );
1029
1030 tree.destroy_subtree(id);
1031 drain_and_dispatch(&tree, &poster);
1032
1033 assert_eq!(signal.get(), "");
1034 assert_eq!(tree.app_context().subscription_count(), 0);
1035 }
1036
1037 #[test]
1038 #[should_panic(expected = "no event source was registered")]
1039 fn subscribe_without_event_source_panics() {
1040 let mut tree = WidgetTree::new();
1041 let signal = Signal::new(String::new());
1042 // No install_source — tree has the empty default app context.
1043 tree.add(SubscribingWidget {
1044 origin: TestOrigin::Created,
1045 last_message: signal,
1046 });
1047 }
1048
1049 // --- app_state tests (architecture §9.5) ---
1050
1051 use std::rc::Rc;
1052
1053 struct TestGlobals {
1054 greeting: Signal<String>,
1055 }
1056
1057 /// Widget that reads `Rc<TestGlobals>` from app_state in `build()` and
1058 /// records what it observed into an out-of-band signal.
1059 #[derive(Debug)]
1060 struct AppStateReader {
1061 observed: Signal<String>,
1062 saw_none: Signal<bool>,
1063 }
1064
1065 impl Widget for AppStateReader {
1066 fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
1067 match ctx.app_state::<Rc<TestGlobals>>() {
1068 Some(globals) => self.observed.set(globals.greeting.get()),
1069 None => self.saw_none.set(true),
1070 }
1071 Vec::new()
1072 }
1073
1074 fn layout_response(
1075 &self,
1076 proposal: SizeProposal,
1077 _ctx: &LayoutContext,
1078 ) -> crate::widget::LayoutResponse {
1079 proposal.resolve(0.0, 0.0).into()
1080 }
1081 }
1082
1083 #[test]
1084 fn app_state_roundtrip_in_build_context() {
1085 let globals = Rc::new(TestGlobals {
1086 greeting: Signal::new("hello from registry".to_string()),
1087 });
1088
1089 let mut registry: HashMap<TypeId, Box<dyn Any>> = HashMap::new();
1090 registry.insert(TypeId::of::<Rc<TestGlobals>>(), Box::new(globals.clone()));
1091
1092 let mut tree = WidgetTree::new();
1093 tree.set_app_context(Rc::new(TreeAppContext::empty().with_app_state(registry)));
1094
1095 let observed = Signal::new(String::new());
1096 let saw_none = Signal::new(false);
1097 tree.add(AppStateReader {
1098 observed: observed.clone(),
1099 saw_none: saw_none.clone(),
1100 });
1101
1102 assert_eq!(observed.get(), "hello from registry");
1103 assert!(!saw_none.get());
1104 }
1105
1106 #[test]
1107 fn app_state_missing_returns_none() {
1108 let mut tree = WidgetTree::new();
1109 // No app_state installed — tree has the empty default app context.
1110
1111 let observed = Signal::new(String::new());
1112 let saw_none = Signal::new(false);
1113 tree.add(AppStateReader {
1114 observed: observed.clone(),
1115 saw_none: saw_none.clone(),
1116 });
1117
1118 assert_eq!(observed.get(), "");
1119 assert!(saw_none.get());
1120 }
1121
1122 #[test]
1123 fn app_state_distinct_types_coexist() {
1124 struct Alpha(u32);
1125 struct Beta(String);
1126
1127 let mut registry: HashMap<TypeId, Box<dyn Any>> = HashMap::new();
1128 registry.insert(TypeId::of::<Rc<Alpha>>(), Box::new(Rc::new(Alpha(42))));
1129 registry.insert(
1130 TypeId::of::<Rc<Beta>>(),
1131 Box::new(Rc::new(Beta("beta!".to_string()))),
1132 );
1133
1134 let ctx = TreeAppContext::empty().with_app_state(registry);
1135 assert_eq!(ctx.app_state::<Rc<Alpha>>().unwrap().0, 42);
1136 assert_eq!(ctx.app_state::<Rc<Beta>>().unwrap().0, "beta!");
1137 assert!(ctx.app_state::<Rc<u64>>().is_none());
1138 }
1139
1140 /// **An event posted before a rebuild must still reach the widget after it.**
1141 ///
1142 /// A backend event crosses two thread boundaries and a queue: the source publishes
1143 /// on its own thread, the wrapper posts an `AppEvent::SubscriptionEvent` carrying
1144 /// the `SubscriptionId` it captured at *publish* time, and the UI thread dispatches
1145 /// it some frames later. A rebuild in that gap used to be fatal — `build()` runs
1146 /// again, allocates fresh ids, and the teardown in `rebuild_single_widget` removes
1147 /// the previous build's callbacks, so the queued event named a dead id and
1148 /// `dispatch_subscription_event` dropped it on the floor and returned `false`.
1149 ///
1150 /// ⚠ The window is **not** the microsecond between dropping the source handle and
1151 /// removing the callback, which is what that function's `§9.4.5` comment reasons
1152 /// about. It is the whole span from publish to dispatch, and a widget opens it on
1153 /// itself simply by binding a signal at `BindingLevel::Rebuild` and then setting
1154 /// that signal — the ordinary documented pattern. Skribisto's Analysis pane starts
1155 /// a long operation in `build()` and sets its own state signal to `Running`, so
1156 /// whenever the operation finished inside that gap the completion was lost and the
1157 /// pane sat on "Reading the manuscript…" for the rest of the session.
1158 #[test]
1159 fn an_event_posted_before_a_rebuild_still_reaches_the_widget() {
1160 let mut tree = WidgetTree::new();
1161 let (source, poster) = install_source(&mut tree, MockEventSource::default());
1162
1163 let signal = Signal::new(String::new());
1164 let id = tree.add(SubscribingWidget {
1165 origin: TestOrigin::Created,
1166 last_message: signal.clone(),
1167 });
1168
1169 // Posted now: the queued event carries the id minted by the first build.
1170 source.publish(
1171 TestOrigin::Created,
1172 TestEvent {
1173 id: 1,
1174 message: "landed".to_string(),
1175 },
1176 );
1177
1178 // …and the widget rebuilds before the UI thread gets to it. This is exactly
1179 // what a `BindingLevel::Rebuild` binding does when its signal changes.
1180 tree.arena_mark_needs_rebuild_for_testing(id);
1181 tree.layout(SizeProposal::exact(100.0, 100.0));
1182
1183 drain_and_dispatch(&tree, &poster);
1184
1185 assert_eq!(
1186 signal.get(),
1187 "landed",
1188 "the rebuild must not swallow an event that was already in flight"
1189 );
1190 }
1191
1192 /// The same guarantee for the **context-bearing** API.
1193 ///
1194 /// `subscribe_event_with_ctx` keeps its callbacks in a second map and is dispatched
1195 /// by a different function, so it fails and has to be fixed separately from the
1196 /// plain path. It is also the API the framework documents as *the* bridge for
1197 /// long-operation progress, which is precisely the traffic this race eats.
1198 #[test]
1199 fn an_event_posted_before_a_rebuild_still_reaches_a_context_bearing_subscription() {
1200 use crate::window::NoopWindowOps;
1201
1202 let mut tree = WidgetTree::new();
1203 let (source, poster) = install_source(&mut tree, MockEventSource::default());
1204
1205 let signal = Signal::new(String::new());
1206 let id = tree.add(CtxSubscribingWidget {
1207 origin: TestOrigin::Created,
1208 last_message: signal.clone(),
1209 });
1210
1211 source.publish(
1212 TestOrigin::Created,
1213 TestEvent {
1214 id: 1,
1215 message: "landed".to_string(),
1216 },
1217 );
1218
1219 tree.arena_mark_needs_rebuild_for_testing(id);
1220 tree.layout(SizeProposal::exact(100.0, 100.0));
1221
1222 // Dispatched the way teksilo-app's `try_dispatch_subscription_with_ctx` does,
1223 // from the queue the wrapper actually posted into.
1224 let app_ctx = tree.app_context().clone();
1225 let events = poster.drain();
1226 assert!(!events.is_empty(), "the source must have posted something");
1227 for (sub_id, event) in events {
1228 tree.run_with_event_context(&mut NoopWindowOps, |ctx| {
1229 app_ctx.dispatch_subscription_event_with_ctx(sub_id, &*event, ctx);
1230 });
1231 }
1232
1233 assert_eq!(
1234 signal.get(),
1235 "landed",
1236 "the ctx-bearing path must survive a rebuild too"
1237 );
1238 }
1239
1240 /// A widget that is genuinely **destroyed** must not have its callback fired by a
1241 /// late event, and must not leave one behind. The fix above makes a subscription's
1242 /// identity outlive a rebuild; it must not make it outlive the widget.
1243 #[test]
1244 fn an_event_posted_before_a_destroy_fires_nothing_and_leaks_nothing() {
1245 let mut tree = WidgetTree::new();
1246 let (source, poster) = install_source(&mut tree, MockEventSource::default());
1247
1248 let signal = Signal::new(String::new());
1249 let id = tree.add(SubscribingWidget {
1250 origin: TestOrigin::Created,
1251 last_message: signal.clone(),
1252 });
1253
1254 source.publish(
1255 TestOrigin::Created,
1256 TestEvent {
1257 id: 1,
1258 message: "too late".to_string(),
1259 },
1260 );
1261 tree.destroy_subtree(id);
1262 drain_and_dispatch(&tree, &poster);
1263
1264 assert_eq!(
1265 signal.get(),
1266 "",
1267 "a destroyed widget's callback must not run"
1268 );
1269 assert_eq!(
1270 tree.app_context().subscription_count(),
1271 0,
1272 "and nothing may be left behind in the callback map"
1273 );
1274 }
1275
1276 /// The mirror of the case below: a rebuild that subscribes **more** times than the one
1277 /// before it re-uses what it can and allocates the rest.
1278 ///
1279 /// Worth its own test because the re-use is matched by position against a list that can
1280 /// simply run out. Reading one past its end has to mean "allocate", not panic and not
1281 /// silently re-use somebody else's id, and the extra subscription has to be a real live
1282 /// one rather than a slot that quietly went nowhere.
1283 #[test]
1284 fn a_rebuild_that_subscribes_more_reuses_what_it_can_and_allocates_the_rest() {
1285 /// Subscribes once on the first build and twice on every build after it.
1286 #[derive(Debug)]
1287 struct GrowingWidget {
1288 built: std::rc::Rc<std::cell::Cell<u32>>,
1289 first_message: Signal<String>,
1290 second_message: Signal<String>,
1291 }
1292
1293 impl Widget for GrowingWidget {
1294 fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
1295 let first = self.built.get() == 0;
1296 self.built.set(self.built.get() + 1);
1297 let one = self.first_message.clone();
1298 ctx.subscribe_event(TestOrigin::Created, move |event: &TestEvent| {
1299 one.set(event.message.clone());
1300 });
1301 if !first {
1302 let two = self.second_message.clone();
1303 ctx.subscribe_event(TestOrigin::Updated, move |event: &TestEvent| {
1304 two.set(event.message.clone());
1305 });
1306 }
1307 Vec::new()
1308 }
1309
1310 fn layout_response(
1311 &self,
1312 proposal: SizeProposal,
1313 _ctx: &LayoutContext,
1314 ) -> crate::widget::LayoutResponse {
1315 proposal.resolve(0.0, 0.0).into()
1316 }
1317 }
1318
1319 let mut tree = WidgetTree::new();
1320 let (source, poster) = install_source(&mut tree, MockEventSource::default());
1321
1322 let built = std::rc::Rc::new(std::cell::Cell::new(0));
1323 let one = Signal::new(String::new());
1324 let two = Signal::new(String::new());
1325 let id = tree.add(GrowingWidget {
1326 built: built.clone(),
1327 first_message: one.clone(),
1328 second_message: two.clone(),
1329 });
1330 assert_eq!(tree.app_context().subscription_count(), 1);
1331
1332 tree.arena_mark_needs_rebuild_for_testing(id);
1333 tree.layout(SizeProposal::exact(100.0, 100.0));
1334 assert_eq!(
1335 tree.app_context().subscription_count(),
1336 2,
1337 "the re-used slot plus a freshly allocated one"
1338 );
1339 assert_eq!(
1340 source.subscriber_count(),
1341 2,
1342 "and both are registered with the source, not just the re-used one"
1343 );
1344
1345 // Both deliver, and neither is delivering the other's traffic.
1346 source.publish(
1347 TestOrigin::Created,
1348 TestEvent {
1349 id: 1,
1350 message: "to the first".to_string(),
1351 },
1352 );
1353 source.publish(
1354 TestOrigin::Updated,
1355 TestEvent {
1356 id: 2,
1357 message: "to the second".to_string(),
1358 },
1359 );
1360 drain_and_dispatch(&tree, &poster);
1361
1362 assert_eq!(one.get(), "to the first");
1363 assert_eq!(
1364 two.get(),
1365 "to the second",
1366 "the newly allocated id must be live"
1367 );
1368 }
1369
1370 /// A rebuild that subscribes **fewer** times than the one before it must not leave
1371 /// the surplus subscription live. Reusing a slot across a rebuild is only safe if a
1372 /// slot the new build did not claim is dropped.
1373 #[test]
1374 fn a_rebuild_that_subscribes_less_drops_the_surplus_subscription() {
1375 /// Subscribes twice on the first build and once on every build after it.
1376 #[derive(Debug)]
1377 struct ShrinkingWidget {
1378 built: std::rc::Rc<std::cell::Cell<u32>>,
1379 }
1380
1381 impl Widget for ShrinkingWidget {
1382 fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
1383 let first = self.built.get() == 0;
1384 self.built.set(self.built.get() + 1);
1385 ctx.subscribe_event(TestOrigin::Created, |_event: &TestEvent| {});
1386 if first {
1387 ctx.subscribe_event(TestOrigin::Updated, |_event: &TestEvent| {});
1388 }
1389 Vec::new()
1390 }
1391
1392 fn layout_response(
1393 &self,
1394 proposal: SizeProposal,
1395 _ctx: &LayoutContext,
1396 ) -> crate::widget::LayoutResponse {
1397 proposal.resolve(0.0, 0.0).into()
1398 }
1399 }
1400
1401 let mut tree = WidgetTree::new();
1402 let (source, _poster) = install_source(&mut tree, MockEventSource::default());
1403
1404 let built = std::rc::Rc::new(std::cell::Cell::new(0));
1405 let id = tree.add(ShrinkingWidget {
1406 built: built.clone(),
1407 });
1408 assert_eq!(tree.app_context().subscription_count(), 2);
1409 assert_eq!(source.subscriber_count(), 2);
1410
1411 tree.arena_mark_needs_rebuild_for_testing(id);
1412 tree.layout(SizeProposal::exact(100.0, 100.0));
1413
1414 assert_eq!(
1415 tree.app_context().subscription_count(),
1416 1,
1417 "the second slot was not re-registered, so it must be gone"
1418 );
1419 assert_eq!(
1420 source.subscriber_count(),
1421 1,
1422 "and the source must not still be holding it"
1423 );
1424 }
1425}