Skip to main content

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}