Skip to main content

frust_native_widgets/
component.rs

1//! [`NativeComponent`] — the **public** trait a plugin author writes a native
2//! component against, from pure Rust, with no per-component Kotlin or Swift.
3//!
4//! **An app crate cannot implement this trait**: `create` constructs real native
5//! views, which means naming `jni`/`objc2-ui-kit` types in the implementing
6//! crate, and this plugin re-exports neither FFI crate — see
7//! `docs/NATIVE_WIDGETS_ARCHITECTURE.md` for that wall and the audience it
8//! leaves. The only implementing type here is the non-default `demo-components`
9//! composite (`crate::demo`).
10//!
11//! Where `crate::runtime`'s `NativeWidget` is the plugin's **internal** dispatch
12//! contract, this trait is the shape a *third party* implements:
13//!
14//! | | internal `NativeWidget` | public [`NativeComponent`] |
15//! |---|---|---|
16//! | receiver | associated functions | `&self` — the value the app constructs each rebuild |
17//! | props | decoded from `params_json` inside the impl | **already-typed Rust values** the app hands over, staged beside the wire (*Props travel beside the wire*, below) |
18//! | errors | every method returns `Result` | latched on the context ([`ComponentCtx::report_error`]); `create` may answer `None` |
19//! | events | decoded into the crate's typed `EventPayload` | the [`NativeEvent`] pair from a listener the component attached ([`ComponentCtx::attach_listener`]), answered with the event (if any) the app's `.on_event` hook receives |
20//! | context | the platform's own `NativeCtx` | the opaque [`ComponentCtx`] wrapper |
21//!
22//! The built-in controls are **not** ported onto it: they stay internal
23//! `NativeWidget` impls, and this module **bridges** to them through
24//! `Bridge<C>` (crate-private), one `NativeWidget` impl generic over every
25//! public component, so both kinds reach the same runtime, registry, props diff
26//! gate and disposal path. Porting them is not on the table — their whole wire
27//! is `params_json`, and a public trait implemented *by* an internal one would
28//! need a blanket impl that then blocks every third-party impl on coherence
29//! grounds. Every guarantee below is therefore the runtime's own.
30//!
31//! # The lifecycle contract (what the runtime guarantees)
32//!
33//! **Every method here runs on the platform main thread** — the host's
34//! post-frame command poll or a platform listener firing, never a frust rebuild
35//! and never off-thread (`crate::runtime`'s *main-thread confinement*).
36//!
37//! 1. **`create` arrives a frame or more after the widget mounts.** Mounting a
38//!    slot publishes a `Create` command; the host drains its backlog on the next
39//!    post-frame poll, and *that* is what calls [`NativeComponent::create`].
40//!    Anything the component retains lives in [`NativeComponent::State`], born
41//!    there — there is nothing native to hold before it.
42//! 2. **Props coalesce until then, and are always whole state, never a delta.**
43//!    Each rebuild replaces a slot's staged props outright, so a create landing
44//!    after three rebuilds sees only the newest, and replaying a backlog prefix
45//!    (a surface-recreate replay, a compaction) lands in the same place.
46//! 3. **`update` runs only when props actually differ.** The runtime compares
47//!    the typed props with `PartialEq` **before** any platform call, so an
48//!    unchanged rebuild costs zero FFI crossings. The one exception is the
49//!    bounded retry window a failed `update` opens (below): the next **two**
50//!    rebuilds each cost one dispatch for byte-identical props. Field-level
51//!    diffing inside a changed props value is the component's own job — only it
52//!    knows which setter is cheap and which forces a re-layout.
53//! 4. **`on_event` fires between frames, for the listeners the component
54//!    attached.** A component attaches the platform's one listener to any view
55//!    it built — root or child — with [`ComponentCtx::attach_listener`], and
56//!    from then on that view's clicks/toggles/value changes reach
57//!    [`NativeComponent::on_event`] through the same slot-id routing the
58//!    built-in controls use (*Listener attachment*, below). A slot that
59//!    attached nothing for an event's family never reaches the method at all.
60//!    A native interaction bypasses `RenderRoot::event` entirely: no
61//!    `EventCtx`, no capture/focus, none of `docs/CODE_STANDARDS.md`'s
62//!    Interaction Semantics. And a listener that fires while the runtime is
63//!    already borrowed — the classic case is a setter provoking its own
64//!    listener synchronously from inside `update` — is **dropped with a
65//!    warning**, not delivered re-entrantly.
66//! 5. **The staged-`&self` re-read is component-only, and its `dispose` half is
67//!    live today.** The `&self` carried into `on_event`/`dispose` is re-read
68//!    from the staging table on **every** dispatch, not only when props changed
69//!    (`BridgeState::refresh_component`): the diff gate skips `update` on an
70//!    equal-props rebuild, so anything less would run a stale rebuild's closures
71//!    ([`NativeComponent::dispose`]). The built-in controls decode `params_json`
72//!    and never read this table.
73//! 6. **`dispose` is best-effort-prompt, and may be late** — below.
74//!
75//! # Three design decisions this trait settles
76//!
77//! **1. The context a third-party impl receives** is [`ComponentCtx`], an
78//! **opaque wrapper** rather than the plugin's own per-platform `NativeCtx`, so
79//! the internal helper surface stays free to change without breaking a public
80//! impl; it also holds the error latch, which is what lets the trait's methods
81//! stay `Result`-free. Curated helpers can never cover "construct an arbitrary
82//! native view", so it carries each platform's own `#[cfg]`-gated escape hatch
83//! too (`ComponentCtx::env`, `ComponentCtx::mtm`) — already public types, so no
84//! new dependency.
85//!
86//! **2. A component reaches the dispatch table through**
87//! [`register_component`], explicitly, from app or plugin init:
88//! `inventory`-style link-time auto-registration stays **banned**, being exactly
89//! the mechanism that fails silently in a stripped, LTO'd device build.
90//! Registration is **first-wins** — a kind already registered, including any of
91//! the built-in controls (which the backend registers when the thread's
92//! runtime is first touched), is refused with a warning rather than replaced, so
93//! a third-party kind can never shadow a shipped one.
94//!
95//! **3. Disposal promptness** is **exactly the guarantee the built-in controls
96//! get, and no more**: the framework's `retire()` (driven from the mounting widget's
97//! teardown) is the prompt primary path, and the differ's missing-frame streak
98//! is the backstop. That streak only advances on gate-`Run` frames, so on an
99//! idle screen a `dispose` can arrive many frames late — or after a replacement
100//! `create` already re-used the slot id, in which case the stale command
101//! resolves against the *old* view by identity, finds nothing, and is dropped.
102//! So `dispose` may run long after the widget disappeared, and at process exit
103//! may not run at all: anything whose release cannot wait belongs in `State`,
104//! dropped immediately after [`NativeComponent::dispose`] returns, alongside
105//! the runtime's paired delete of the [`NativeRoot`].
106//!
107//! # Listener attachment
108//!
109//! There is still exactly **one listener class per platform** — Android's
110//! `dev.frust.nativewidgets.FrustNativeListener`, and each Apple arm's
111//! `FrustNativeControlTarget` — and a component reaches it the same way the
112//! built-in controls do, through one call: [`ComponentCtx::attach_listener`],
113//! given the view and the [`ListenerKinds`] to wire (click, toggled, value
114//! changed). The context constructs the listener bound to **this slot's own
115//! id**, which it knows privately and never hands to the component, so a
116//! component cannot route an event anywhere but home. It answers a
117//! [`ListenerHandle`] the component keeps in its [`NativeComponent::State`];
118//! dropping it with the state is the release **on every arm**, and
119//! [`ComponentCtx::detach_listener`] detaches explicitly (the same release,
120//! run early instead of at drop). A release only ever affects **the handle's
121//! own listener**: iOS removes its own target's action pairs, macOS clears the
122//! control's target/action only while they are still its own, and Android —
123//! whose `setOn*Listener` setters hold one listener each, replace it outright
124//! and expose no getter — **disarms** the handle's own `FrustNativeListener`
125//! (a `@Volatile` flag every callback checks) rather than nulling the view's
126//! interface, which may already hold a newer attach's listener. The interface
127//! keeps an inert object until a later attach replaces it or the view dies.
128//! So re-attaching the same view for the same kinds and letting the old
129//! handle drop is safe on every arm, and on Android a handle dropped off the
130//! main thread never touches a `View` at all.
131//!
132//! The dispatch then runs the path the built-in controls already use: the
133//! platform listener fires on the main thread, `crate::runtime`'s `on_event`
134//! routes it by slot id to this module's `Bridge`, which hands the
135//! [`NativeEvent`] (and the component's own state and last-applied props) to
136//! [`NativeComponent::on_event`]. Whatever event that answers rides the
137//! built-in controls' `EventPayload` callback table to the app's
138//! [`NativeComponentView::on_event`](crate::api::NativeComponentView::on_event)
139//! hook — the events-as-signals idiom, unchanged. The bridge remembers which
140//! [`ListenerKinds`] the slot attached and drops any other family before the
141//! component sees it, so a component that attached nothing still answers
142//! nothing.
143//!
144//! # A component owns its own native subtree
145//!
146//! One component may build a whole native view *hierarchy* — a parent with
147//! native children — and ship it as ONE slot, which is what stops a composite
148//! from leaking three slots to the consuming app. Five calls on
149//! [`ComponentCtx`], which documents each, are the entire surface: build a
150//! child (`ComponentCtx::new_view`, or an `objc2-ui-kit`/`objc2-app-kit`
151//! constructor off `ComponentCtx::mtm` — the one `#[cfg]`-gated pair), attach it
152//! ([`ComponentCtx::add_child`]), keep talking to it
153//! ([`ComponentCtx::retain_child`] → [`NativeChild`]), hear from it
154//! ([`ComponentCtx::attach_listener`] → [`ListenerHandle`]), and bound the JNI
155//! reference table ([`ComponentCtx::with_local_frame`], a no-op under ARC). The
156//! last four exist on every target, and the host arm's stand-ins let an
157//! ordinary `cargo test` assert a component's create/update/dispose plan.
158//!
159//! **The platform lays the subtree out, and frust deliberately does not know
160//! the children exist** — the wire carries per-slot geometry only (a `rect`, an
161//! optional `clip`, `shields`), so a component positions its own children the
162//! platform's way while frust keeps seeing one opaque slot with one rect
163//! ([`NativeComponent`]'s *No frust `View` children* has the model and why).
164//! **No wire change**: a subtree costs the differ exactly what a single leaf
165//! control costs it, and a11y comes out ahead — the platform owns the subtree,
166//! so it traverses it natively.
167//!
168//! **Teardown releases children with the parent**, so peak global refs return
169//! to zero over a dispose cycle by construction: a merely *attached* child
170//! needs no handle at all (Android's `ViewGroup` holds its own strong
171//! reference, UIKit and AppKit retain a subview) and dies with the parent,
172//! while a child you keep talking to lives in [`NativeComponent::State`] as a
173//! [`NativeChild`], whose `Drop` *is* the release (`DeleteGlobalRef` on
174//! Android, `Retained`'s own `Drop` on iOS and macOS). The leak bar is the built-in
175//! controls' own; `tests::a_component_builds_a_native_subtree_and_releases_every_child`
176//! counts refs rather than merely surviving the cycle.
177//!
178//! # Props travel beside the wire, not on it
179//!
180//! The platform-view wire carries one `params_json` string per slot, and that
181//! is what the differ diffs to decide whether to emit an `UpdateParams` at all.
182//! A public component's props are typed Rust values that never touch JSON, so
183//! they ride a **thread-local staging table** here (written by this module's
184//! crate-private `publish`/`forget` pair, whose one production caller is the
185//! generic mounting builder) while the slot's `params_json` carries only the
186//! runtime's two identity keys plus a props **generation** counter that
187//! `publish` bumps when — and only when — the published props actually changed.
188//! The wire therefore changes exactly when the props do, which is what makes
189//! the differ emit the `UpdateParams` the typed props ride along with. An app
190//! stages props by rebuilding
191//! [`native_component`](crate::api::native_component); none of this is public.
192//!
193//! Like every other slot-keyed table here, it is bounded by an explicit reaper
194//! (`forget`, from the mounting widget's teardown), never by disposal alone —
195//! the leak shape `crate::runtime`'s `forget_pending_callback` guards against
196//! applies verbatim: a culled slot's dispose resolves by view identity and
197//! never sees this table.
198//!
199//! # A failed `update` is retried, up to a cap
200//!
201//! The retry trigger is a **generation bump**, not `instance.props` differing,
202//! and that distinction is load-bearing. A failed `update` leaves `old` as the
203//! runtime's diff baseline, so the change *would* be re-applied by the next
204//! `UpdateParams` — but the differ only emits one when `params_json` changes,
205//! and `publish` moves the generation only when the app's props change, so an
206//! app republishing the same (already failed) props forever would emit none at
207//! all and the view would stay stale, silently, for the process lifetime. The
208//! staging table therefore carries the retry: a failed dispatch marks the slot
209//! (`request_update_retry`), and the **next `publish` for that slot bumps the
210//! generation even for identical props** — one wire change, one `UpdateParams`,
211//! one retry. Three consequences:
212//!
213//! - **A retry needs a rebuild.** Nothing here schedules one — the mark is
214//!   consumed by the next rebuild that publishes this slot, so on a screen that
215//!   never rebuilds again the change stays unapplied, exactly as any other
216//!   props change would.
217//! - **Three consecutive failed dispatches spend the budget and the slot goes
218//!   inert.** Failures one and two each re-mark the slot, so a transient
219//!   refusal gets two more attempts; failure three reports once at `warn` and
220//!   marks nothing further, so an unchanged rebuild is back to zero FFI
221//!   crossings and zero log lines. Re-marking forever would instead cost a
222//!   permanently failing slot one dispatch **plus** a `log::warn!` on *every*
223//!   rebuild — per-frame main-thread JNI traffic and log volume, with one more
224//!   warning per refused ctx call on top. Inert is the trade taken; retrying
225//!   cleverly (backoff, a schedule of its own) is not a goal.
226//! - **The cap counts *consecutive* failures and a success clears it**, so a
227//!   flaky platform never accumulates its way to inert. Only this synthetic
228//!   retry is capped: a genuine props change bumps the generation on its own
229//!   and is always dispatched, capped or not — the app's intent, not ours.
230//!
231//! # Kind and type must agree, and a mismatch fails closed four different ways
232//!
233//! `kind` is passed twice — once to [`register_component`], once to the
234//! mounting builder — and nothing mechanically ties the two, so each mismatch
235//! is named with its *actual* error (only the first is `UnknownControl`):
236//!
237//! | case | what surfaces | logged | slot |
238//! |---|---|---|---|
239//! | kind never registered | `NativeWidgetError::UnknownControl(kind)` from the runtime's own dispatch, before any decode | yes, by the platform export | dead |
240//! | registered to `C`, mounted with `C` | nothing — the ordinary path | — | live |
241//! | registered to `C`, mounted with `D` | `NativeWidgetError::Params`, from `Bridge::<C>::decode_props` failing to downcast the staged props to `C::Props` | yes, by the platform export | dead |
242//! | registered to `C`, mounted with `D` where `D::Props == C::Props` | `NativeWidgetError::Params`, one step later — the props downcast *succeeds* and `Bridge::<C>::create` fails to downcast the staged component to `C` | yes, by the platform export | dead |
243//!
244//! Registering two components under one kind reduces to the third row, since
245//! first-wins refuses the second. Every case **fails closed** — no half-created
246//! slot, no instance retained, no silent no-op — and both mismatch rows name the
247//! wiring bug in their message rather than reading like an ordinary "nothing
248//! staged here any more".
249//!
250//! # Dispatch-boundary exception guard (Android)
251//!
252//! `ComponentCtx::env` (Android-only) hands a component the live `jni::Env`,
253//! and a component is free to leave a Java exception pending on it — undefined
254//! behaviour for the *next* JNI call, not for the one that threw. So **all
255//! four** dispatches through this module — `create`, `update`, `dispose` and
256//! `on_event` — check and clear one on the way out through the crate's own
257//! `run_jni` helper, whose report names the throwable's class and message. On
258//! the three carrying a context it folds into the same first-wins error channel
259//! as a latched error; `on_event` has none, so it logs at `warn`.
260//!
261//! **`on_event` is guarded through the VM, not through a context**, because it
262//! is handed neither: `NativeWidget::on_event` takes only `(state, event)`. Its
263//! `Env` comes from `JavaVM::with_top_local_frame` on the process VM
264//! (`frust_plugin::android::vm`), which borrows the *existing* top JNI frame
265//! rather than pushing one, so the clean path is a `GetEnv` plus an
266//! `ExceptionCheck` and a report's locals die with `nativeOnEvent`'s own frame.
267//! It is deliberately **not** `frust_plugin::android::with_jni_env`: jni 0.22's
268//! scoped attach defaults to `AttachmentExceptionPolicy::PreReThrowPostCatch`,
269//! which stashes an already-pending exception before running the closure and
270//! re-throws it after, so a check inside would read *clean* every time and clear
271//! nothing. (That policy is also why a component reaching JNI through
272//! `with_jni_env` is caught on its own way out; this guard covers the paths that
273//! do not — a raw `jni-sys` call, an attach configured with `Ignore`, a
274//! hand-recovered `Env`.)
275//!
276//! `env` itself stays a **safe** fn: making it `unsafe` would tax the one
277//! audience that can use this trait at all, for a hazard this guard contains.
278
279// The publication half of this module (`publish`/`forget`/`component_params`)
280// has its production caller in `crate::api::mount`'s generic builder, which
281// runs exactly the sequence the host tests below drive (`publish` → mount →
282// `create` → `update` → `forget`). `staged_count` stays test-only, and keeps
283// its own `allow` rather than a module-level one, so anything else falling
284// dead here still warns.
285
286use std::any::Any;
287use std::cell::RefCell;
288use std::collections::HashMap;
289use std::marker::PhantomData;
290use std::rc::Rc;
291
292use crate::NativeWidgetError;
293use crate::controls::DARK;
294use crate::events::{
295    EVENT_KIND_CLICK, EVENT_KIND_DRAG_END, EVENT_KIND_DRAG_START, EVENT_KIND_TOGGLED,
296    EVENT_KIND_VALUE_CHANGED, EventPayload, pack_bool, pack_value_changed, unpack_bool,
297    unpack_value_changed,
298};
299use crate::registry::SlotId;
300use crate::runtime::{
301    NativeCtx as PlatformCtx, NativeEvent as WireEvent, NativeView, NativeWidget, Params,
302    with_runtime,
303};
304
305/// The reserved `params_json` key carrying a component slot's props
306/// **generation** — the wire-visible value that changes when (and only when)
307/// [`publish`] is handed props that differ from the ones already staged.
308///
309/// Deliberately a sibling of `crate::runtime`'s two identity keys rather than
310/// a re-use of either: the runtime's `__frustControl`/`__frustSlot` say *which*
311/// component a slot is, this says *which version of its props* the slot last
312/// published. A component's real props never appear on the wire at all.
313const PROPS_GENERATION_KEY: &str = "__frustProps";
314
315// --- the public trait --------------------------------------------------------
316
317/// One retained native view — or native view *hierarchy* — created, updated
318/// and torn down entirely from Rust.
319///
320/// Implement this for a plain marker/config type, register it once under a
321/// kind string ([`register_component`]), and the same runtime that serves this
322/// crate's built-in controls will serve yours: one generic platform
323/// factory, one generic listener, **no per-component Kotlin or Swift, ever**.
324///
325/// Read the module doc first — it is the lifecycle contract (when each method
326/// runs, what the runtime guarantees about ordering, and what it does *not*
327/// guarantee about disposal promptness). The short version:
328///
329/// - every method runs on the **platform main thread**;
330/// - `create` runs at the host's post-frame poll, a frame or more after the
331///   mounting widget appeared;
332/// - `update` runs only when [`Props`](Self::Props) compare unequal (plus the
333///   bounded retry window a failed one opens);
334/// - `on_event` runs from a platform listener between frames, for every view
335///   the component attached one to with [`ComponentCtx::attach_listener`]
336///   ([`on_event`](Self::on_event));
337/// - `dispose` is prompt on teardown but may be late, and at process exit may
338///   not run at all.
339///
340/// # No frust `View` children
341///
342/// A component's native hierarchy is **its own**: frust sees one opaque slot
343/// with one rect, and the platform (a `LinearLayout`, a `UIStackView`,
344/// explicit frames) lays the subtree out. This is the SwiftUI
345/// `UIViewRepresentable` / Compose `AndroidView` model, chosen deliberately
346/// over React Native's — where the framework's own layout engine walks into
347/// native containers — because frust's wire carries no hierarchical child
348/// geometry and buying one would mean re-acquiring, per child, the
349/// frame-pairing, shield collection, culling and accessibility bridging it
350/// gets per slot today.
351pub trait NativeComponent: 'static {
352    /// The Rust-side-diffed create/update payload: everything the app tells
353    /// this component, as one value it constructs directly.
354    ///
355    /// `PartialEq` is load-bearing, not a formality — it is the gate that
356    /// keeps an unchanged rebuild from crossing the FFI boundary at all (a
357    /// comparison costs nanoseconds; a redundant JNI setter costs ~0.14–29 µs
358    /// depending on whether it re-layouts). `Send` is inherited from the
359    /// runtime's internal contract; props never actually leave the main
360    /// thread.
361    type Props: Clone + PartialEq + Send + 'static;
362
363    /// Per-instance retained state: native handles, listeners, buffers —
364    /// whatever [`create`](Self::create) needs to keep to drive the view
365    /// later. Created at attach time and held by the runtime's slot registry,
366    /// dropped immediately after [`dispose`](Self::dispose) returns.
367    type State: 'static;
368
369    /// Build this component's native view (hierarchy) for a freshly attached
370    /// slot, returning its root and the state that drives it.
371    ///
372    /// Return `None` to fail the slot — the runtime then reports it dead to
373    /// the host rather than leaving a half-created control mounted. That is
374    /// the *only* failure channel `create` has, and it exists because there is
375    /// no meaningful `State` to hand back when the native side did not come
376    /// up; use [`ComponentCtx::report_error`] (or let a ctx helper latch its
377    /// own failure) first, so the log names what went wrong.
378    ///
379    /// Returning `Some` after an error was latched is legal and means *"I
380    /// recovered"*: the slot lives and the latched error is logged as a
381    /// warning. The impl always has the final say.
382    fn create(
383        &self,
384        ctx: &mut ComponentCtx<'_, '_, '_>,
385        props: &Self::Props,
386    ) -> Option<(NativeRoot, Self::State)>;
387
388    /// Apply a props change to the live view with direct setters on the
389    /// handles [`State`](Self::State) retained.
390    ///
391    /// Called only when `old != new` (the module doc's props diff gate), on
392    /// the main thread, ordered with this slot's create/dispose.
393    ///
394    /// A failure latched on `ctx` leaves the runtime's diff baseline at `old`
395    /// **and marks the slot for retry**, so the next rebuild that publishes
396    /// this slot re-applies the change — even if the app's props never differ
397    /// again. The trigger is a props-generation bump, not the app's props
398    /// moving; the module doc's *A failed `update` is retried, up to a cap* has
399    /// the mechanism and its limits (a retry needs a rebuild; three consecutive
400    /// failures spend the budget and the slot then stops asking, so a broken
401    /// component degrades to inert rather than to per-frame FFI traffic).
402    fn update(
403        &self,
404        ctx: &mut ComponentCtx<'_, '_, '_>,
405        state: &mut Self::State,
406        old: &Self::Props,
407        new: &Self::Props,
408    );
409
410    /// A platform listener the component attached fired for this slot: act on
411    /// it, and answer the event (if any) the app's
412    /// [`NativeComponentView::on_event`](crate::api::NativeComponentView::on_event)
413    /// hook should receive.
414    ///
415    /// # Which events arrive here
416    ///
417    /// Exactly the ones a listener this component attached reports: a view it
418    /// built (its root as much as a child) wired with
419    /// [`ComponentCtx::attach_listener`], for the [`ListenerKinds`] it asked
420    /// for. The listener is the platform's one shared class, bound to this
421    /// slot's own id by the context — the component never sees the id, so it
422    /// cannot route anything but home — and its events reach this method
423    /// through the runtime's slot-id routing, the path the built-in
424    /// controls' events take. An event whose family this slot never attached
425    /// (a stray, or a hand-built Android listener carrying this slot's number)
426    /// is dropped at the bridge and never reaches this method.
427    ///
428    /// # The pair, the state and the props
429    ///
430    /// `event` is the listener's raw wire ([`NativeEvent::kind`]/
431    /// [`NativeEvent::detail`], with [`NativeEvent::checked`] and
432    /// [`NativeEvent::value`] decoding the two payload-carrying kinds).
433    /// `state` is the component's own, and `props` the last props a `create`
434    /// or successful `update` applied — the typed baseline the runtime diffs
435    /// against. The `&self` a dispatch runs against is the value the app
436    /// published this rebuild — re-read from the staging table on every
437    /// dispatch (the module doc's point 5) — so it can carry the closures such
438    /// an event should reach.
439    ///
440    /// # The answer
441    ///
442    /// `Some(event)` forwards that event to the app's hook (the builders'
443    /// events-as-signals idiom: the hook typically writes a signal, which wakes
444    /// exactly one frust frame); `None` swallows it. The answer need not be
445    /// the event that arrived — a component may translate one kind into
446    /// another — but only the kinds [`NativeEvent`] names are forwarded; any
447    /// other kind is dropped (logged at `debug`). The default forwards every
448    /// event unchanged, which is right for a component whose listeners exist
449    /// to report straight to the app.
450    ///
451    /// It runs on the main thread inside the runtime's borrow, like every
452    /// listener dispatch here: a setter provoking its own listener
453    /// synchronously from inside this method is dropped, not re-entered.
454    fn on_event(
455        &self,
456        state: &mut Self::State,
457        props: &Self::Props,
458        event: NativeEvent,
459    ) -> Option<NativeEvent> {
460        let _ = (state, props);
461        Some(event)
462    }
463
464    /// The slot is going away: detach listeners and release anything `state`
465    /// owns beyond the [`NativeRoot`], which the runtime releases immediately
466    /// afterwards — the paired delete, in that order.
467    ///
468    /// Defaults to doing nothing, which is correct whenever dropping `State`
469    /// already releases everything (the iOS arm's `Retained` fields, an
470    /// Android state whose only refs are its own `Global`s).
471    ///
472    /// # Which `&self` this runs against
473    ///
474    /// The most recently published one — re-read from the staging table on
475    /// dispatch, not the value whose `create` produced `state`. Those differ
476    /// whenever a rebuild republished an equal `Props` with a different
477    /// component value (new closures, a different `Rc`): the props diff gate
478    /// skips `update` entirely on an equal-props rebuild, so without the
479    /// re-read this would run a stale rebuild's closures.
480    ///
481    /// One exception, and it is the *common* teardown path: when the mounting
482    /// widget's `on_cleanup` has already reaped the staging entry, the
483    /// retained value is kept instead. That is correct — a torn-down widget
484    /// published nothing newer. The re-read matters for a `dispose` that
485    /// reaches a **still-mounted** slot: the differ's missing-frame-streak
486    /// culling backstop, and `suspend_all` on surface teardown.
487    ///
488    /// A [`ListenerHandle`] kept in `state` needs no call here: dropping it
489    /// with the state is its release ([`ListenerHandle`]'s own doc).
490    fn dispose(&self, ctx: &mut ComponentCtx<'_, '_, '_>, state: Self::State) {
491        let _ = (ctx, state);
492    }
493}
494
495// --- the public context ------------------------------------------------------
496
497/// The scoped, opaque call context every [`NativeComponent`] method builds
498/// through — see the module doc's decision **1**.
499///
500/// Two things it is not: it is not the plugin's own per-platform `NativeCtx`
501/// (which stays internal, so its helper surface can keep changing), and it is
502/// not a handle you may store — it borrows the live platform context of the
503/// one create/update/dispose call on the stack and cannot outlive it.
504///
505/// # The error latch
506///
507/// [`NativeComponent`]'s methods return no `Result`. Instead every fallible
508/// helper here latches its failure on the context and answers `None`, and
509/// [`report_error`](Self::report_error) lets a component latch one of its own.
510/// The runtime reads the latch when the method returns: on `create` a latched
511/// error is fatal only if the component also answered `None`; on `update` it
512/// keeps the diff baseline unchanged and marks the slot for retry (the module
513/// doc's *A failed `update` is retried on the next rebuild*); on `dispose` it
514/// is logged.
515///
516/// **The first error wins, and every later one is logged rather than
517/// dropped.** Reporting keeps the failure that *started* the cascade, not its
518/// last symptom — but a symptom is still evidence, so a latch that refuses a
519/// later error says so in the log (`log::warn!`), naming both. That holds
520/// across [`with_local_frame`](Self::with_local_frame) too, which is the one
521/// place a second context exists to lose an error in: the latch travels into
522/// the frame and back out, so `failed()` answers the same inside it as
523/// outside, on every platform arm.
524///
525/// # The slot it speaks for
526///
527/// A context also knows **which slot** the call is for — privately. That is
528/// what [`attach_listener`](Self::attach_listener) binds the platform's one
529/// listener to, and it is never handed to the component: a component can
530/// only ever route an event back to its own slot (the module doc's *Listener
531/// attachment*).
532pub struct ComponentCtx<'ctx, 'local, 'env> {
533    inner: &'ctx mut PlatformCtx<'local, 'env>,
534    error: Option<NativeWidgetError>,
535    /// The slot this call is for — the id every listener this context attaches
536    /// reports under. Private on purpose (the type doc's *The slot it speaks
537    /// for*).
538    slot: SlotId,
539    /// Every [`ListenerKinds`] family an [`attach_listener`](Self::attach_listener)
540    /// made through this context succeeded for — read back by `Bridge` so the
541    /// slot's event gate knows what it may deliver.
542    attached: ListenerKinds,
543}
544
545impl<'ctx, 'local, 'env> ComponentCtx<'ctx, 'local, 'env> {
546    /// Wrap the platform context of one runtime call for `slot`.
547    fn new(inner: &'ctx mut PlatformCtx<'local, 'env>, slot: SlotId) -> Self {
548        Self {
549            inner,
550            error: None,
551            slot,
552            attached: ListenerKinds::NONE,
553        }
554    }
555
556    /// Consume the wrapper, reporting whatever was latched and every listener
557    /// family attached through it.
558    fn into_parts(self) -> (Option<NativeWidgetError>, ListenerKinds) {
559        (self.error, self.attached)
560    }
561
562    /// Record a successful attach: the families join what `Bridge` will let
563    /// through for this slot, and the attach is logged at `debug` — the one
564    /// line a device or desktop run can grep to see a component's listener
565    /// wiring happen (the slot id appears in the log, never in the component).
566    fn note_attached(&mut self, kinds: ListenerKinds) {
567        self.attached |= kinds;
568        log::debug!(
569            "frust-native-widgets: component slot {} attached a {kinds} listener",
570            self.slot
571        );
572    }
573
574    /// Refuse an [`attach_listener`](Self::attach_listener) asking for no
575    /// family at all — shared by every arm, so an empty request latches the
576    /// same error everywhere rather than attaching a listener that could never
577    /// report anything.
578    fn refuse_empty(&mut self, kinds: ListenerKinds) -> bool {
579        if kinds.is_empty() {
580            self.latch(NativeWidgetError::Params(
581                "attach_listener was asked for no ListenerKinds — nothing to attach".into(),
582            ));
583            return true;
584        }
585        false
586    }
587
588    /// Record the first failure and keep it (see this type's *error latch*).
589    ///
590    /// A later error cannot replace it — that is the whole point of the latch
591    /// — but it is **logged rather than swallowed**: on a device the cascade's
592    /// symptoms are what let a reader judge the root failure's blast radius,
593    /// and a second platform failure that vanished without trace is exactly
594    /// the defect this guards against.
595    fn latch(&mut self, error: NativeWidgetError) {
596        match &self.error {
597            Some(first) => log::warn!(
598                "frust-native-widgets: component context already failed ({first}) — keeping that \
599                 error and reporting this later one here only: {error}"
600            ),
601            None => self.error = Some(error),
602        }
603    }
604}
605
606impl ComponentCtx<'_, '_, '_> {
607    /// Whether a platform call made through this context has already failed —
608    /// the early-out a component checks before continuing to build against a
609    /// handle that may not exist.
610    pub fn failed(&self) -> bool {
611        self.error.is_some()
612    }
613
614    /// Latch a failure of the component's own (a platform call it made through
615    /// the escape hatch, a precondition it found broken). Only the first
616    /// latched error is kept.
617    pub fn report_error(&mut self, message: impl Into<String>) {
618        self.latch(NativeWidgetError::Platform(message.into()));
619    }
620}
621
622#[cfg(target_os = "android")]
623impl ComponentCtx<'_, '_, '_> {
624    /// `parent.addView(child)` — attach one native child, the subtree call
625    /// (module doc's *A component owns its own native subtree*).
626    ///
627    /// The parent owns the child from here: a `ViewGroup` holds its own
628    /// strong reference, so a child you never touch again needs no handle of
629    /// yours at all. Answers `Option` so it chains with `?` beside every
630    /// other fallible helper here; latches and answers `None` when the call
631    /// throws (the usual cause being a child that already has a parent).
632    pub fn add_child(
633        &mut self,
634        parent: &jni::objects::JObject<'_>,
635        child: &jni::objects::JObject<'_>,
636    ) -> Option<()> {
637        match self.inner.add_child(parent, child) {
638            Ok(()) => Some(()),
639            Err(error) => {
640                self.latch(error);
641                None
642            }
643        }
644    }
645
646    /// Promote a child's local reference into a [`NativeChild`] — one global
647    /// reference the component keeps in its [`NativeComponent::State`] to
648    /// drive that child later, released when `State` drops (module doc's
649    /// *Teardown*).
650    ///
651    /// Only a child you keep talking to needs this. Latches and answers
652    /// `None` when the JVM cannot allocate the reference — which, ART
653    /// aborting the process at 51,200 live global refs, is itself a leak
654    /// signal rather than an ordinary failure.
655    pub fn retain_child(&mut self, view: &jni::objects::JObject<'_>) -> Option<NativeChild> {
656        match self.inner.retain(view) {
657            Ok(global) => Some(NativeChild(global)),
658            Err(error) => {
659                self.latch(error);
660                None
661            }
662        }
663    }
664
665    /// Attach this crate's one listener class, `FrustNativeListener`, to
666    /// `view` for `kinds` — the module doc's *Listener attachment*. `view` may
667    /// be the component's root or any child it built.
668    ///
669    /// [`ListenerKinds::CLICK`] sets it as the view's `View.OnClickListener`
670    /// (any view); [`ListenerKinds::TOGGLED`] as a `CompoundButton`'s
671    /// `OnCheckedChangeListener`; [`ListenerKinds::VALUE_CHANGED`] as a
672    /// `SeekBar`'s `OnSeekBarChangeListener`, which also reports the drag
673    /// edges ([`NativeEvent::KIND_DRAG_START`]/[`NativeEvent::KIND_DRAG_END`]).
674    /// The listener is constructed bound to this slot's id — which this
675    /// context never hands you — so its events come home to
676    /// [`NativeComponent::on_event`] and nowhere else.
677    ///
678    /// Keep the returned [`ListenerHandle`] in your
679    /// [`NativeComponent::State`]: its `Drop` **disarms** the listener it
680    /// created — that one object, never the view — and releases the global
681    /// references it holds to both (this arm's `Drop`, [`ListenerHandle`]'s
682    /// own doc). Attaching `view` again for the same `kinds` replaces the
683    /// view's listener outright, so `state.handle = ctx.attach_listener(..)`
684    /// is safe: the old handle's drop silences only the old listener, which
685    /// the view no longer holds.
686    ///
687    /// Latches and answers `None` when `kinds` is empty, the listener class
688    /// cannot be loaded, or a setter throws — typically
689    /// [`ListenerKinds::TOGGLED`]/[`ListenerKinds::VALUE_CHANGED`] asked of a
690    /// view that is no `CompoundButton`/`SeekBar` (`NoSuchMethodError`). A
691    /// failure part-way is unwound before this answers `None`: whatever
692    /// setter this same call already succeeded with is nulled again first
693    /// (`NativeCtx`'s own `attach_listener` — the one place nulling is
694    /// identity-safe, since nothing can have replaced those interfaces in
695    /// between), so no stray interface survives it.
696    pub fn attach_listener(
697        &mut self,
698        view: &jni::objects::JObject<'_>,
699        kinds: ListenerKinds,
700    ) -> Option<ListenerHandle> {
701        if self.refuse_empty(kinds) {
702            return None;
703        }
704        match self.inner.attach_listener(
705            view,
706            self.slot,
707            kinds.contains(ListenerKinds::CLICK),
708            kinds.contains(ListenerKinds::TOGGLED),
709            kinds.contains(ListenerKinds::VALUE_CHANGED),
710        ) {
711            Ok((view, listener)) => {
712                self.note_attached(kinds);
713                Some(ListenerHandle {
714                    kinds,
715                    inner: ListenerInner {
716                        _view: view,
717                        listener,
718                        _not_send: PhantomData,
719                    },
720                })
721            }
722            Err(error) => {
723                self.latch(error);
724                None
725            }
726        }
727    }
728
729    /// Detach what `handle` attached and release it — the explicit spelling
730    /// of dropping the handle, whose `Drop` already disarms the listener it
731    /// created and releases both global references (this arm's `Drop`; iOS
732    /// and macOS below release their own target the same way). For a
733    /// component that stops listening while its view lives on, or that
734    /// detaches in `dispose`, as the built-in controls do. The view's
735    /// interface keeps the disarmed, inert listener until a later attach
736    /// replaces it or the view dies — the view itself is never mutated here.
737    ///
738    /// Consuming `handle` here is what makes a double detach impossible: a
739    /// `ListenerHandle`'s fields cannot be moved out of it individually once
740    /// it carries a `Drop` impl, so this — like the two Apple arms — can only
741    /// ever run the release once, through `Drop` itself, never twice against
742    /// the same global reference. Never latches: a detach failure logs from
743    /// inside the `Drop` instead, which has no context to latch onto.
744    pub fn detach_listener(&mut self, handle: ListenerHandle) -> Option<()> {
745        drop(handle);
746        Some(())
747    }
748
749    /// Run `f` inside a pushed JNI local frame, so every local reference it
750    /// creates is released the moment it returns — **mandatory** around a
751    /// loop building more than a handful of children (module doc's subtree
752    /// table; `capacity` is the JVM's pre-allocation hint, not a cap).
753    ///
754    /// Fifty children built without one would pin fifty-plus local references
755    /// for the whole `create` call. A value the closure returns must not *be* a
756    /// local reference — that is what [`Self::retain_child`] is for, and its
757    /// [`NativeChild`] outlives the frame.
758    ///
759    /// **The latch travels with you.** The closure runs against a context that
760    /// already carries whatever this one latched — so `failed()` answers the
761    /// same inside the frame as outside it, exactly as on the iOS and host
762    /// arms, which hand the closure this very context — and whatever survives
763    /// comes back out. A frame that cannot be pushed at all latches too, and
764    /// leaves an already-latched error untouched; either way the answer is
765    /// `None`. Per the latch's first-wins rule, an error raised inside the
766    /// frame behind an already-latched one is logged rather than reported.
767    pub fn with_local_frame<T>(
768        &mut self,
769        capacity: usize,
770        f: impl FnOnce(&mut ComponentCtx<'_, '_, '_>) -> Option<T>,
771    ) -> Option<T> {
772        // A *fresh* inner context is structurally forced here — the pushed
773        // frame's references carry different lifetimes than this context's —
774        // but a fresh *latch* must not be: that would make `failed()` read
775        // `false` inside a frame where the other two arms read `true`, and
776        // re-latching the inner error on return would silently drop it whenever
777        // this context already held one.
778        //
779        // The slot and the attached-listener record travel the same way: a
780        // listener attached inside the frame is this slot's like any other.
781        let mut latched = self.error.take();
782        let slot = self.slot;
783        let mut attached = self.attached;
784        let outcome = self.inner.with_frame(capacity, |inner| {
785            let mut cx = ComponentCtx {
786                inner,
787                error: latched.take(),
788                slot,
789                attached,
790            };
791            let value = f(&mut cx);
792            (latched, attached) = cx.into_parts();
793            Ok::<Option<T>, NativeWidgetError>(value)
794        });
795        // A frame that could not be pushed never ran the closure, so this puts
796        // the carried-in error back rather than erasing it.
797        self.error = latched;
798        self.attached = attached;
799        match outcome {
800            Ok(value) => value,
801            Err(error) => {
802                self.latch(error);
803                None
804            }
805        }
806    }
807}
808
809#[cfg(target_os = "android")]
810impl<'local, 'env> ComponentCtx<'_, 'local, 'env> {
811    /// The live JNI `Env` — the escape hatch for everything the helpers here
812    /// do not cover (a component's own cached `JMethodID`s and
813    /// `call_method_unchecked` hot path, any class this crate never names).
814    ///
815    /// A pending Java exception is undefined behaviour for the next JNI call,
816    /// so a component using this directly should check and clear its own —
817    /// [`Self::new_view`] and [`Self::root`] do that for the calls they make.
818    /// **The runtime does not take that on trust:** every dispatch through this
819    /// module — the three that carry this context (`create`, `update`,
820    /// `dispose`) and `on_event`, which reaches the VM instead — checks and
821    /// clears a leftover exception on the way out and reports it (the module
822    /// doc's *Dispatch-boundary exception guard*). Clearing your own is still
823    /// the right discipline — it keeps the *rest of your own call* on defined
824    /// ground, which the boundary guard cannot do for you — but forgetting it
825    /// cannot poison the next unrelated JNI call.
826    ///
827    /// This stays a **safe** fn on purpose: the hazard is bounded by the guard
828    /// above, and an `unsafe` escape hatch would tax the small audience that
829    /// can implement this trait at all for no further protection.
830    pub fn env(&mut self) -> &mut jni::Env<'local> {
831        self.inner.env()
832    }
833
834    /// The hosting `Context` (the platform-view factory passes the `Activity`
835    /// as one), or `None` on a call path that carries none — only `create`
836    /// does. A component needing a `Context` later must retain what it needs
837    /// in its own [`NativeComponent::State`]; asking here off the create path
838    /// is not an error and latches nothing.
839    pub fn context(&self) -> Option<&'env jni::objects::JObject<'local>> {
840        self.inner.context().ok()
841    }
842
843    /// `new <binary_name>(context)` — the one-argument `Context` constructor
844    /// every `android.widget` view has, with the class resolved through the
845    /// **application** classloader (`FindClass` cannot see app classes at all)
846    /// and cached process-wide.
847    ///
848    /// Latches and answers `None` when the class cannot be loaded, the call
849    /// path carries no `Context`, or the constructor throws.
850    pub fn new_view(&mut self, binary_name: &'static str) -> Option<jni::objects::JObject<'local>> {
851        match self.inner.new_view(binary_name) {
852            Ok(view) => Some(view),
853            Err(error) => {
854                self.latch(error);
855                None
856            }
857        }
858    }
859
860    /// Promote a local reference into the slot's [`NativeRoot`]: one global
861    /// reference the runtime owns and pair-deletes on dispose.
862    ///
863    /// Latches and answers `None` when the JVM cannot allocate the reference
864    /// — which, ART aborting the process at 51,200 live global refs, is itself
865    /// a leak signal rather than an ordinary failure.
866    pub fn root(&mut self, view: &jni::objects::JObject<'_>) -> Option<NativeRoot> {
867        match self.inner.retain(view) {
868            Ok(global) => Some(NativeRoot(NativeView::new(global))),
869            Err(error) => {
870                self.latch(error);
871                None
872            }
873        }
874    }
875}
876
877#[cfg(target_os = "ios")]
878impl ComponentCtx<'_, '_, '_> {
879    /// The main-thread proof this call carries — what every `objc2-ui-kit`
880    /// constructor demands, and the Apple arm's whole escape hatch (the
881    /// Objective-C runtime is globally reachable; there is no `Env` to thread).
882    pub fn mtm(&self) -> objc2::MainThreadMarker {
883        self.inner.mtm()
884    }
885
886    /// Take this slot's root view. ARC owns it from here — `Retained`'s own
887    /// `Drop` is the release, so there is no paired-delete discipline on this
888    /// arm.
889    ///
890    /// A typed view converts with objc2's own upcast, e.g.
891    /// `Retained::clone(&button).into_super().into_super()` for a `UIButton`.
892    /// Answers `Option` only so a component reads the same on both platforms
893    /// (`let root = ctx.root(view)?;`); this arm never latches here.
894    pub fn root(&mut self, view: objc2::rc::Retained<objc2_ui_kit::UIView>) -> Option<NativeRoot> {
895        let mtm = self.inner.mtm();
896        Some(NativeRoot(NativeView::new(view, mtm)))
897    }
898
899    /// `parent.addSubview(child)` — attach one native child, the subtree call
900    /// (module doc's *A component owns its own native subtree*).
901    ///
902    /// UIKit retains a subview, so a child you never touch again needs no
903    /// handle of yours at all — it is released when the parent is. Answers
904    /// `Option` only so a component reads the same on both platforms; this
905    /// arm never latches here.
906    ///
907    /// A typed view passes with objc2's own upcast, e.g. `&label` for a
908    /// `Retained<UILabel>` derefs through `UIView`'s superclass chain.
909    pub fn add_child(
910        &mut self,
911        parent: &objc2_ui_kit::UIView,
912        child: &objc2_ui_kit::UIView,
913    ) -> Option<()> {
914        self.inner.add_child(parent, child);
915        Some(())
916    }
917
918    /// Retain a child view as a [`NativeChild`] the component keeps in its
919    /// [`NativeComponent::State`], released when `State` drops (module doc's
920    /// *Teardown*).
921    ///
922    /// ARC already does this for any `Retained<T>` a component keeps itself,
923    /// with the child's own concrete type preserved — reach for that first.
924    /// This exists so a component that wants one `State` shape on both arms
925    /// can name [`NativeChild`] on both. Never latches.
926    pub fn retain_child(&mut self, view: &objc2_ui_kit::UIView) -> Option<NativeChild> {
927        use objc2::Message as _;
928        Some(NativeChild(view.retain()))
929    }
930
931    /// Attach this crate's one target-action class, `FrustNativeControlTarget`,
932    /// to `view` for `kinds` — the module doc's *Listener attachment*. `view`
933    /// may be the component's root or any child it built.
934    ///
935    /// [`ListenerKinds::CLICK`] wires `TouchUpInside` on any `UIControl`;
936    /// [`ListenerKinds::TOGGLED`] a `UISwitch`'s `ValueChanged`;
937    /// [`ListenerKinds::VALUE_CHANGED`] a `UISlider`'s `ValueChanged` plus its
938    /// `TouchDown`/`TouchUpInside|TouchUpOutside` drag edges — exactly the
939    /// wiring the built-in controls use. The target is bound to this slot's id,
940    /// which this context never hands you.
941    ///
942    /// UIKit holds a control's targets **weakly**, so the returned
943    /// [`ListenerHandle`] is the target's only strong reference: keep it in
944    /// your [`NativeComponent::State`]. Dropping it removes the target-action
945    /// pairs it added, then releases the target.
946    ///
947    /// Latches and answers `None` when `kinds` is empty or `view` is not the
948    /// class a requested kind reads its payload from (no `UIControl` at all,
949    /// or `TOGGLED`/`VALUE_CHANGED` on a view that is no
950    /// `UISwitch`/`UISlider`) — checked before anything is attached, so a
951    /// refusal leaves nothing half-wired.
952    pub fn attach_listener(
953        &mut self,
954        view: &objc2_ui_kit::UIView,
955        kinds: ListenerKinds,
956    ) -> Option<ListenerHandle> {
957        if self.refuse_empty(kinds) {
958            return None;
959        }
960        let mtm = self.inner.mtm();
961        match crate::apple::FrustNativeControlTarget::attach_component(
962            mtm,
963            self.slot,
964            view,
965            kinds.contains(ListenerKinds::CLICK),
966            kinds.contains(ListenerKinds::TOGGLED),
967            kinds.contains(ListenerKinds::VALUE_CHANGED),
968        ) {
969            Ok((target, control)) => {
970                self.note_attached(kinds);
971                Some(ListenerHandle {
972                    kinds,
973                    inner: ListenerInner { target, control },
974                })
975            }
976            Err(message) => {
977                self.latch(NativeWidgetError::Platform(message));
978                None
979            }
980        }
981    }
982
983    /// Detach what `handle` attached and release it — the explicit spelling
984    /// of dropping the handle, whose `Drop` already removes its target-action
985    /// pairs on this arm. Never latches.
986    pub fn detach_listener(&mut self, handle: ListenerHandle) -> Option<()> {
987        drop(handle);
988        Some(())
989    }
990
991    /// The Apple counterpart of Android's local-frame wrapper: it runs `f`
992    /// and nothing else.
993    ///
994    /// There is no reference table to bound here — a `Retained`'s own `Drop`
995    /// is the release (`crate::apple::ctx`'s module doc) — so `capacity` is
996    /// accepted and ignored. The method exists on this arm so a component's
997    /// `create` is written once and compiles on both.
998    ///
999    /// Handing the closure this very context is also what makes the error
1000    /// latch shared, which the Android arm now matches deliberately rather
1001    /// than by accident: `failed()` reads the same inside the frame as outside
1002    /// it on every arm.
1003    pub fn with_local_frame<T>(
1004        &mut self,
1005        _capacity: usize,
1006        f: impl FnOnce(&mut ComponentCtx<'_, '_, '_>) -> Option<T>,
1007    ) -> Option<T> {
1008        f(self)
1009    }
1010}
1011
1012/// The macOS arm: the `NSView` twin of the iOS block above, method for method
1013/// — the main-thread proof (AppKit's whole escape hatch, exactly as on iOS),
1014/// `root`/`add_child`/`retain_child` over `Retained<NSView>`, and the same
1015/// run-`f`-and-nothing-else local frame.
1016///
1017/// `add_child` goes through `crate::appkit::NativeCtx::add_child`
1018/// (`addSubview:`), the seam `crate::appkit::ctx`'s *Hierarchy* section keeps
1019/// for exactly this caller. There is no host-style `record` here: this arm
1020/// builds real views, and the `demo-components` `DemoCard` has its own AppKit
1021/// `mod platform` (`crate::demo`).
1022///
1023/// [`attach_listener`](Self::attach_listener) wires `crate::appkit::events`'
1024/// one target class onto an `NSControl` the component built, the AppKit
1025/// counterpart of the other two arms' listener attach.
1026#[cfg(target_os = "macos")]
1027impl ComponentCtx<'_, '_, '_> {
1028    /// The main-thread proof this call carries — what every `objc2-app-kit`
1029    /// constructor demands, and the macOS arm's whole escape hatch (the
1030    /// Objective-C runtime is globally reachable; there is no `Env` to thread).
1031    pub fn mtm(&self) -> objc2::MainThreadMarker {
1032        self.inner.mtm()
1033    }
1034
1035    /// Take this slot's root view. ARC owns it from here — `Retained`'s own
1036    /// `Drop` is the release, so there is no paired-delete discipline on this
1037    /// arm.
1038    ///
1039    /// A typed view converts with objc2's own upcast, e.g.
1040    /// `Retained::clone(&button).into_super().into_super()` for an `NSButton`
1041    /// (`NSButton` → `NSControl` → `NSView`). Answers `Option` only so a
1042    /// component reads the same on every platform (`let root =
1043    /// ctx.root(view)?;`); this arm never latches here.
1044    pub fn root(&mut self, view: objc2::rc::Retained<objc2_app_kit::NSView>) -> Option<NativeRoot> {
1045        let mtm = self.inner.mtm();
1046        Some(NativeRoot(NativeView::new(view, mtm)))
1047    }
1048
1049    /// `parent.addSubview(child)` — attach one native child, the subtree call
1050    /// (module doc's *A component owns its own native subtree*).
1051    ///
1052    /// AppKit retains a subview, so a child you never touch again needs no
1053    /// handle of yours at all — it is released when the parent is. Answers
1054    /// `Option` only so a component reads the same on every platform; this
1055    /// arm never latches here.
1056    ///
1057    /// A typed view passes with objc2's own upcast, e.g. `&label` for a
1058    /// `Retained<NSTextField>` derefs through `NSView`'s superclass chain.
1059    pub fn add_child(
1060        &mut self,
1061        parent: &objc2_app_kit::NSView,
1062        child: &objc2_app_kit::NSView,
1063    ) -> Option<()> {
1064        self.inner.add_child(parent, child);
1065        Some(())
1066    }
1067
1068    /// Retain a child view as a [`NativeChild`] the component keeps in its
1069    /// [`NativeComponent::State`], released when `State` drops (module doc's
1070    /// *Teardown*).
1071    ///
1072    /// ARC already does this for any `Retained<T>` a component keeps itself,
1073    /// with the child's own concrete type preserved — reach for that first.
1074    /// This exists so a component that wants one `State` shape on every arm
1075    /// can name [`NativeChild`] on this one too. Never latches.
1076    pub fn retain_child(&mut self, view: &objc2_app_kit::NSView) -> Option<NativeChild> {
1077        use objc2::Message as _;
1078        Some(NativeChild(view.retain()))
1079    }
1080
1081    /// Attach this crate's one target-action class, `FrustNativeControlTarget`,
1082    /// to `view` for `kinds` — the module doc's *Listener attachment*. `view`
1083    /// may be the component's root or any child it built, and must be an
1084    /// `NSControl`.
1085    ///
1086    /// **Exactly one family per control on this arm**: an `NSControl` carries
1087    /// a single `target`/`action` pair, sent at its one "value committed"
1088    /// moment, so [`ListenerKinds::CLICK`] (any control — a button click),
1089    /// [`ListenerKinds::TOGGLED`] (an `NSSwitch`, whose `state` is the payload)
1090    /// and [`ListenerKinds::VALUE_CHANGED`] (any control's `doubleValue` — set
1091    /// an `NSSlider` `continuous` to hear every drag step) are alternatives
1092    /// here, not a mask. AppKit reports no drag edges (`crate::appkit::events`'
1093    /// module doc). The target is bound to this slot's id, which this context
1094    /// never hands you.
1095    ///
1096    /// `NSControl.target` is weak, so the returned [`ListenerHandle`] is the
1097    /// target's only strong reference: keep it in your
1098    /// [`NativeComponent::State`]. Dropping it clears the control's
1099    /// target/action (if they are still this handle's), then releases the
1100    /// target.
1101    ///
1102    /// Latches and answers `None` when `kinds` is empty or names more than one
1103    /// family, `view` is no `NSControl`, or `TOGGLED` is asked of a control
1104    /// that is no `NSSwitch` — checked before anything is attached.
1105    pub fn attach_listener(
1106        &mut self,
1107        view: &objc2_app_kit::NSView,
1108        kinds: ListenerKinds,
1109    ) -> Option<ListenerHandle> {
1110        if self.refuse_empty(kinds) {
1111            return None;
1112        }
1113        let Some(kind) = kinds.single_event_kind() else {
1114            self.latch(NativeWidgetError::Params(format!(
1115                "macOS attach_listener: an NSControl carries one target/action pair, so attach \
1116                 exactly one ListenerKinds family per control (asked for {kinds})"
1117            )));
1118            return None;
1119        };
1120        let mtm = self.inner.mtm();
1121        match crate::appkit::FrustNativeControlTarget::attach_view(mtm, view, self.slot, kind) {
1122            Ok((target, control)) => {
1123                self.note_attached(kinds);
1124                Some(ListenerHandle {
1125                    kinds,
1126                    inner: ListenerInner { target, control },
1127                })
1128            }
1129            Err(message) => {
1130                self.latch(NativeWidgetError::Platform(message));
1131                None
1132            }
1133        }
1134    }
1135
1136    /// Detach what `handle` attached and release it — the explicit spelling
1137    /// of dropping the handle, whose `Drop` already clears the control's
1138    /// target/action on this arm. Never latches.
1139    pub fn detach_listener(&mut self, handle: ListenerHandle) -> Option<()> {
1140        drop(handle);
1141        Some(())
1142    }
1143
1144    /// The Apple counterpart of Android's local-frame wrapper: it runs `f`
1145    /// and nothing else.
1146    ///
1147    /// There is no reference table to bound here — a `Retained`'s own `Drop`
1148    /// is the release (`crate::appkit::ctx`'s module doc) — so `capacity` is
1149    /// accepted and ignored. Handing the closure this very context is what
1150    /// makes the error latch shared, exactly as on the iOS arm: `failed()`
1151    /// reads the same inside the frame as outside it on every arm.
1152    pub fn with_local_frame<T>(
1153        &mut self,
1154        _capacity: usize,
1155        f: impl FnOnce(&mut ComponentCtx<'_, '_, '_>) -> Option<T>,
1156    ) -> Option<T> {
1157        f(self)
1158    }
1159}
1160
1161#[cfg(not(any(target_os = "android", target_os = "ios", target_os = "macos")))]
1162impl ComponentCtx<'_, '_, '_> {
1163    /// Record one would-be platform call — the host stand-in's whole surface,
1164    /// so a component's create/update/dispose can be asserted by an ordinary
1165    /// `cargo test` on a machine with no JNI and no Objective-C runtime at
1166    /// all. Compiled only on a host with no platform arm; there is no native
1167    /// view to build there.
1168    pub fn record(&mut self, call: impl Into<String>) {
1169        self.inner.record(call);
1170    }
1171
1172    /// Take a stand-in root view with the given identity — the host mirror of
1173    /// the three platform arms' `root`, where `identity` plays the role
1174    /// `Env::is_same_object` plays on Android (what a dispose resolves
1175    /// against).
1176    pub fn root(&mut self, identity: u64) -> Option<NativeRoot> {
1177        Some(NativeRoot(NativeView { identity }))
1178    }
1179
1180    /// Record one would-be `addView`/`addSubview` — the host mirror of the
1181    /// three platform arms' `add_child`, so a subtree's *plan* is assertable
1182    /// (which child went under which parent, in what order) on a machine with
1183    /// no view hierarchy at all.
1184    pub fn add_child(&mut self, parent: u64, child: u64) -> Option<()> {
1185        self.inner.record(format!("addChild {parent} <- {child}"));
1186        Some(())
1187    }
1188
1189    /// Take a stand-in retained child handle — the host mirror of Android's
1190    /// global reference and the Apple arms' `Retained`.
1191    ///
1192    /// Live handles are counted process-thread-wide (this module's
1193    /// crate-private `live_child_count`), so a host test asserts the paired
1194    /// release the way ART's global-ref count does on device (module doc's
1195    /// *Teardown*) rather than merely asserting that nothing panicked.
1196    pub fn retain_child(&mut self, identity: u64) -> Option<NativeChild> {
1197        self.inner.record(format!("retainChild {identity}"));
1198        Some(NativeChild::new(identity))
1199    }
1200
1201    /// Record a would-be listener attach on the stand-in view `view` — the
1202    /// host mirror of the three platform arms' `attach_listener`, so a
1203    /// component's listener wiring is part of its assertable plan.
1204    ///
1205    /// Like the real arms it binds the stand-in to this context's own slot
1206    /// (logged, never recorded in the plan, never handed out), and it latches
1207    /// and answers `None`
1208    /// for an empty `kinds`. Live stand-in handles are counted
1209    /// (`live_listener_count`) the way [`Self::retain_child`]'s are.
1210    pub fn attach_listener(&mut self, view: u64, kinds: ListenerKinds) -> Option<ListenerHandle> {
1211        if self.refuse_empty(kinds) {
1212            return None;
1213        }
1214        self.inner.record(format!("attachListener {view} {kinds}"));
1215        self.note_attached(kinds);
1216        Some(ListenerHandle {
1217            kinds,
1218            inner: HostListener::new(view),
1219        })
1220    }
1221
1222    /// Record a would-be detach of what `handle` attached, then release it —
1223    /// the host mirror of the three platform arms' `detach_listener`.
1224    pub fn detach_listener(&mut self, handle: ListenerHandle) -> Option<()> {
1225        self.inner.record(format!(
1226            "detachListener {} {}",
1227            handle.inner.identity, handle.kinds
1228        ));
1229        Some(())
1230    }
1231
1232    /// Record a would-be `PushLocalFrame`/`PopLocalFrame` pair around `f` —
1233    /// the host mirror of Android's real local frame, so a test can assert a
1234    /// subtree build actually ran inside one.
1235    ///
1236    /// Like the iOS arm, this hands the closure the caller's own context, so
1237    /// the error latch is shared: `failed()` reads the same inside the frame
1238    /// as outside it, and a second error raised inside it is logged by the
1239    /// latch rather than dropped. Android reproduces both properties over a
1240    /// context it is forced to build fresh.
1241    pub fn with_local_frame<T>(
1242        &mut self,
1243        capacity: usize,
1244        f: impl FnOnce(&mut ComponentCtx<'_, '_, '_>) -> Option<T>,
1245    ) -> Option<T> {
1246        self.inner.record(format!("pushLocalFrame {capacity}"));
1247        let value = f(self);
1248        self.inner.record("popLocalFrame");
1249        value
1250    }
1251}
1252
1253/// The root native view a [`NativeComponent::create`] hands back — an opaque
1254/// handle the runtime retains for the slot and releases on dispose (Android:
1255/// the paired global-ref delete; iOS and macOS: ARC).
1256///
1257/// Built only through [`ComponentCtx::root`], so the platform handle type
1258/// itself never has to appear in a component's signature; a component that
1259/// wants to keep talking to its own view retains a second, typed reference in
1260/// its [`NativeComponent::State`], exactly as the built-in controls do.
1261pub struct NativeRoot(NativeView);
1262
1263impl NativeRoot {
1264    /// Hand the platform handle to the runtime.
1265    fn into_inner(self) -> NativeView {
1266        self.0
1267    }
1268}
1269
1270/// One retained **child** of a component's native subtree — the handle a
1271/// component keeps in its [`NativeComponent::State`] when it wants to drive
1272/// that child later (module doc's *A component owns its own native subtree*).
1273///
1274/// Built only through [`ComponentCtx::retain_child`]. **Dropping it is the
1275/// release** — `DeleteGlobalRef` on Android, `Retained`'s own `Drop` on iOS and
1276/// macOS —
1277/// and `State` is dropped immediately after [`NativeComponent::dispose`]
1278/// returns, so a child is released with its parent by construction rather
1279/// than by remembering to.
1280///
1281/// A child you never talk to again needs none of this: the platform parent
1282/// already owns it.
1283pub struct NativeChild(ChildHandle);
1284
1285/// [`NativeChild`]'s per-platform payload: a global reference on Android, an
1286/// ARC retain on iOS (`UIView`) and macOS (`NSView`), a counted stand-in on a
1287/// host with no platform arm (Linux/Windows/web).
1288#[cfg(target_os = "android")]
1289type ChildHandle = jni::refs::Global<jni::objects::JObject<'static>>;
1290#[cfg(target_os = "ios")]
1291type ChildHandle = objc2::rc::Retained<objc2_ui_kit::UIView>;
1292#[cfg(target_os = "macos")]
1293type ChildHandle = objc2::rc::Retained<objc2_app_kit::NSView>;
1294#[cfg(not(any(target_os = "android", target_os = "ios", target_os = "macos")))]
1295type ChildHandle = HostChild;
1296
1297#[cfg(target_os = "android")]
1298impl NativeChild {
1299    /// The retained child, for the setters a component's `update` calls on it
1300    /// (through [`ComponentCtx::env`], or a cached `JMethodID` of its own).
1301    pub fn as_object(&self) -> &jni::objects::JObject<'static> {
1302        &self.0
1303    }
1304}
1305
1306#[cfg(target_os = "ios")]
1307impl NativeChild {
1308    /// The retained child, for the `objc2-ui-kit` setters a component's
1309    /// `update` calls on it. Takes the same main-thread proof
1310    /// [`AppleHandle::view`](crate::registry::apple::AppleHandle::view) does —
1311    /// a typestate guard, not a runtime cost.
1312    pub fn view(&self, _mtm: objc2::MainThreadMarker) -> &objc2_ui_kit::UIView {
1313        &self.0
1314    }
1315}
1316
1317#[cfg(target_os = "macos")]
1318impl NativeChild {
1319    /// The retained child, for the `objc2-app-kit` setters a component's
1320    /// `update` calls on it. Takes the same main-thread proof
1321    /// [`AppKitHandle::view`](crate::registry::appkit::AppKitHandle::view)
1322    /// does — a typestate guard, not a runtime cost.
1323    pub fn view(&self, _mtm: objc2::MainThreadMarker) -> &objc2_app_kit::NSView {
1324        &self.0
1325    }
1326}
1327
1328#[cfg(not(any(target_os = "android", target_os = "ios", target_os = "macos")))]
1329impl NativeChild {
1330    /// A counted stand-in handle with the given identity.
1331    fn new(identity: u64) -> Self {
1332        LIVE_CHILDREN.with(|live| live.set(live.get() + 1));
1333        Self(HostChild { identity })
1334    }
1335
1336    /// This child's stand-in identity — what a test asserts against.
1337    pub fn identity(&self) -> u64 {
1338        self.0.identity
1339    }
1340}
1341
1342/// The host arm's stand-in child handle: it decrements [`LIVE_CHILDREN`] when
1343/// dropped, exactly as Android's `Global` issues its `DeleteGlobalRef` — the
1344/// leak bar `crate::registry`'s own `CountingHandle` established, one table
1345/// over.
1346#[cfg(not(any(target_os = "android", target_os = "ios", target_os = "macos")))]
1347pub(crate) struct HostChild {
1348    identity: u64,
1349}
1350
1351#[cfg(not(any(target_os = "android", target_os = "ios", target_os = "macos")))]
1352impl Drop for HostChild {
1353    fn drop(&mut self) {
1354        LIVE_CHILDREN.with(|live| live.set(live.get().saturating_sub(1)));
1355    }
1356}
1357
1358#[cfg(not(any(target_os = "android", target_os = "ios", target_os = "macos")))]
1359thread_local! {
1360    /// How many host stand-in child handles are alive on this thread — the
1361    /// mirror of ART's live-global-ref count ("52 at peak → 0 after the
1362    /// dispose cycle" on device). Thread-local for the same reason
1363    /// [`STAGED`] is, which also keeps each `cargo test` thread's count its
1364    /// own.
1365    static LIVE_CHILDREN: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1366}
1367
1368/// How many retained subtree children are currently alive — the host arm's
1369/// leak bar, which every create/dispose cycle must return to `0`.
1370#[cfg(not(any(target_os = "android", target_os = "ios", target_os = "macos")))]
1371#[allow(dead_code)] // the leak bar's only caller is this module's own tests
1372pub(crate) fn live_child_count() -> usize {
1373    LIVE_CHILDREN.with(|live| live.get())
1374}
1375
1376// --- listener attachment -----------------------------------------------------
1377
1378/// Which of the platform listener's event families
1379/// [`ComponentCtx::attach_listener`] wires onto a view — the module doc's
1380/// *Listener attachment*. Combine with `|`.
1381///
1382/// | family | Android (`FrustNativeListener` as…) | iOS (`FrustNativeControlTarget` on…) | macOS (`FrustNativeControlTarget` on…) | [`NativeEvent`] kinds it delivers |
1383/// |---|---|---|---|---|
1384/// | [`CLICK`](Self::CLICK) | `View.OnClickListener`, any view | `TouchUpInside`, any `UIControl` | the action, any `NSControl` | [`NativeEvent::KIND_CLICK`] |
1385/// | [`TOGGLED`](Self::TOGGLED) | `OnCheckedChangeListener`, a `CompoundButton` | `ValueChanged`, a `UISwitch` | the action, an `NSSwitch` | [`NativeEvent::KIND_TOGGLED`] |
1386/// | [`VALUE_CHANGED`](Self::VALUE_CHANGED) | `OnSeekBarChangeListener`, a `SeekBar` | `ValueChanged` + drag edges, a `UISlider` | the action, any `NSControl` (`doubleValue`) | [`NativeEvent::KIND_VALUE_CHANGED`], plus the drag edges on the two mobile arms |
1387///
1388/// macOS takes exactly one family per control (one target/action pair per
1389/// `NSControl` — [`ComponentCtx::attach_listener`]'s macOS doc). Later
1390/// families (a selection, a date) land beside these three and route through
1391/// the same table.
1392#[derive(Clone, Copy, Default, PartialEq, Eq, Hash)]
1393pub struct ListenerKinds(u8);
1394
1395impl ListenerKinds {
1396    /// No family at all — what [`ComponentCtx::attach_listener`] refuses.
1397    pub const NONE: Self = Self(0);
1398    /// A click / tap.
1399    pub const CLICK: Self = Self(1);
1400    /// A two-state control flipped.
1401    pub const TOGGLED: Self = Self(1 << 1);
1402    /// A ranged control moved (and, on Android and iOS, its drag began/ended).
1403    pub const VALUE_CHANGED: Self = Self(1 << 2);
1404
1405    /// Whether every family in `other` is in `self` (so `contains(NONE)` is
1406    /// always `true`).
1407    pub const fn contains(self, other: Self) -> bool {
1408        self.0 & other.0 == other.0
1409    }
1410
1411    /// Whether no family is set.
1412    pub const fn is_empty(self) -> bool {
1413        self.0 == 0
1414    }
1415
1416    /// Every family in either.
1417    pub const fn union(self, other: Self) -> Self {
1418        Self(self.0 | other.0)
1419    }
1420
1421    /// The single `crate::events` kind code a one-family mask means on the
1422    /// macOS arm (one target/action pair per `NSControl`), or `None` for an
1423    /// empty or multi-family mask.
1424    #[cfg(target_os = "macos")]
1425    fn single_event_kind(self) -> Option<i32> {
1426        match self {
1427            Self::CLICK => Some(EVENT_KIND_CLICK),
1428            Self::TOGGLED => Some(EVENT_KIND_TOGGLED),
1429            Self::VALUE_CHANGED => Some(EVENT_KIND_VALUE_CHANGED),
1430            _ => None,
1431        }
1432    }
1433}
1434
1435impl std::ops::BitOr for ListenerKinds {
1436    type Output = Self;
1437
1438    fn bitor(self, other: Self) -> Self {
1439        self.union(other)
1440    }
1441}
1442
1443impl std::ops::BitOrAssign for ListenerKinds {
1444    fn bitor_assign(&mut self, other: Self) {
1445        *self = self.union(other);
1446    }
1447}
1448
1449/// `click|toggled|value_changed`, or `none` — the spelling log lines and the
1450/// host arm's recorded plan use.
1451impl std::fmt::Display for ListenerKinds {
1452    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1453        if self.is_empty() {
1454            return f.write_str("none");
1455        }
1456        let names = [
1457            (Self::CLICK, "click"),
1458            (Self::TOGGLED, "toggled"),
1459            (Self::VALUE_CHANGED, "value_changed"),
1460        ];
1461        let mut first = true;
1462        for (family, name) in names {
1463            if self.contains(family) {
1464                if !first {
1465                    f.write_str("|")?;
1466                }
1467                f.write_str(name)?;
1468                first = false;
1469            }
1470        }
1471        Ok(())
1472    }
1473}
1474
1475impl std::fmt::Debug for ListenerKinds {
1476    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1477        write!(f, "ListenerKinds({self})")
1478    }
1479}
1480
1481/// One platform listener a component attached through
1482/// [`ComponentCtx::attach_listener`] — keep it in your
1483/// [`NativeComponent::State`].
1484///
1485/// **Dropping it is the release, on every arm**, and `State` is dropped
1486/// immediately after [`NativeComponent::dispose`] returns, so a listener is
1487/// released with the view it listens to by construction. A release only ever
1488/// affects **this handle's own listener**, never one a later attach set:
1489///
1490/// - **Android** disarms the `FrustNativeListener` this handle created — a
1491///   `@Volatile` flag every callback checks before reporting — through a JNI
1492///   env obtained from the process VM (a `Drop` carries no context of its
1493///   own; see the impl below), then deletes its global references to the
1494///   listener and the view. **The view is never mutated**: its
1495///   `setOn*Listener` slots hold one listener each, replace it outright and
1496///   expose no getter, so nulling one could wipe a newer listener. The slot
1497///   keeps the disarmed, inert object until a later attach replaces it or the
1498///   view dies.
1499/// - **iOS** removes the target-action pairs this handle's own target added,
1500///   and **macOS** clears the control's target/action only while they are
1501///   still this handle's target — UIKit and AppKit hold the target weakly, so
1502///   this handle is its only strong reference — then the target is released.
1503///
1504/// So `state.handle = ctx.attach_listener(&view, kinds)` on a view that
1505/// already carries a handle for the same kinds is safe on every arm: the
1506/// assignment drops the old handle, which silences only the old listener.
1507/// [`ComponentCtx::detach_listener`] is the explicit form on every arm:
1508/// dropping the handle early rather than waiting for `State` to go.
1509///
1510/// The handle is `!Send`/`!Sync` on every platform arm (Android as well as
1511/// iOS/macOS), so it stays on the main thread the runtime creates and drops
1512/// component `State` on. Even so, Android's release is thread-safe by
1513/// construction — it flips a volatile flag on its own listener and touches no
1514/// `View` — so no drop path can corrupt a view from the wrong thread.
1515#[must_use = "a dropped ListenerHandle releases its listener at once — keep it in State"]
1516pub struct ListenerHandle {
1517    kinds: ListenerKinds,
1518    inner: ListenerInner,
1519}
1520
1521impl ListenerHandle {
1522    /// The families this handle's listener was attached for.
1523    pub fn kinds(&self) -> ListenerKinds {
1524        self.kinds
1525    }
1526}
1527
1528/// [`ListenerHandle`]'s per-platform payload.
1529#[cfg(target_os = "android")]
1530struct ListenerInner {
1531    /// A global reference to the view the listener was set on. Never mutated
1532    /// by the release — held so the view outlives every handle that names it,
1533    /// exactly as the Apple arms retain their control.
1534    _view: jni::refs::Global<jni::objects::JObject<'static>>,
1535    /// A global reference to the `FrustNativeListener` this handle created —
1536    /// the one object the handle's `Drop` disarms.
1537    listener: jni::refs::Global<jni::objects::JObject<'static>>,
1538    /// Makes [`ListenerHandle`] `!Send`/`!Sync` on Android, matching the
1539    /// Apple arms (whose `Retained` fields already are): `State` lives and
1540    /// dies on the platform main thread.
1541    _not_send: PhantomData<*const ()>,
1542}
1543
1544/// [`ListenerHandle`]'s per-platform payload.
1545#[cfg(target_os = "ios")]
1546struct ListenerInner {
1547    /// The target, whose only strong reference this is.
1548    target: objc2::rc::Retained<crate::apple::FrustNativeControlTarget>,
1549    /// The control it is attached to — kept so the handle's `Drop` can remove
1550    /// exactly the target-action pairs it added.
1551    control: objc2::rc::Retained<objc2_ui_kit::UIControl>,
1552}
1553
1554/// [`ListenerHandle`]'s per-platform payload.
1555#[cfg(target_os = "macos")]
1556struct ListenerInner {
1557    /// The target, whose only strong reference this is.
1558    target: objc2::rc::Retained<crate::appkit::FrustNativeControlTarget>,
1559    /// The control it is attached to — kept so the handle's `Drop` can clear
1560    /// the control's target/action while they are still this target's.
1561    control: objc2::rc::Retained<objc2_app_kit::NSControl>,
1562}
1563
1564/// [`ListenerHandle`]'s per-platform payload: a counted stand-in on a host
1565/// with no platform arm.
1566#[cfg(not(any(target_os = "android", target_os = "ios", target_os = "macos")))]
1567type ListenerInner = HostListener;
1568
1569/// Android's counterpart to the two Apple arms below: a `Drop` carries no
1570/// `Env` of its own (unlike every other call in this module, which runs
1571/// inside a JNI export's own frame), so this obtains one from the process VM
1572/// the same way the context-free dispatch-boundary guard does
1573/// (`frust_plugin::android::vm` +
1574/// [`with_top_local_frame`](jni::JavaVM::with_top_local_frame), module doc's
1575/// *Dispatch-boundary exception guard*), then **disarms this handle's own
1576/// listener** — `FrustNativeListener.disarm()` through
1577/// `NativeCtx::disarm_listener` (`crate::android::ctx`) — and lets both
1578/// `Global`s go. It never calls a `setOn*Listener` setter: the view may
1579/// already hold a newer attach's listener in the same slot (the defect a
1580/// nulling release had — [`ListenerHandle`]'s own doc).
1581///
1582/// The sequence, in order:
1583///
1584/// 1. Obtain the VM. None — no platform handles installed yet — logs at
1585///    `warn` and skips to step 4.
1586/// 2. Inside `with_top_local_frame`, **check for a pending Java exception
1587///    first**. Making a JNI call with one pending is undefined behaviour, and
1588///    a `Drop` may run mid-unwind of a JNI export that has not cleared its
1589///    own; so when one is pending the disarm is skipped and logged at `warn`
1590///    (naming the kinds), and the exception is left for its owner to handle.
1591/// 3. Otherwise call `disarm()`. A failure (the method missing, a throw — the
1592///    exception is cleared by `run_jni`) is logged at `warn`, never
1593///    propagated and never a panic: a `Drop` has no `Result` to return it
1594///    through.
1595/// 4. Both `Global`s drop, issuing `DeleteGlobalRef` — which is on JNI's
1596///    short list of calls that are safe with an exception pending.
1597///
1598/// A listener left armed by step 1 or 2's early out can still fire; the
1599/// bridge's per-slot family gate refuses its event once the slot that
1600/// attached it is gone, so the cost is a wasted crossing, not a misroute.
1601#[cfg(target_os = "android")]
1602impl Drop for ListenerHandle {
1603    fn drop(&mut self) {
1604        let kinds = self.kinds;
1605        let vm = match frust_plugin::android::vm() {
1606            Ok(vm) => vm,
1607            Err(error) => {
1608                log::warn!(
1609                    "frust-native-widgets: ListenerHandle drop ({kinds}) could not reach a JNI \
1610                     env ({error}) — the listener stays armed; releasing only the global \
1611                     references"
1612                );
1613                return;
1614            }
1615        };
1616        let listener = &self.inner.listener;
1617        let outcome = vm.with_top_local_frame(|env| {
1618            if env.exception_check() {
1619                return Ok::<Option<Result<(), NativeWidgetError>>, jni::errors::Error>(None);
1620            }
1621            let mut ctx = PlatformCtx::detached(env);
1622            Ok(Some(ctx.disarm_listener(listener)))
1623        });
1624        match outcome {
1625            Ok(Some(Ok(()))) => {}
1626            Ok(None) => log::warn!(
1627                "frust-native-widgets: ListenerHandle drop ({kinds}) found a Java exception \
1628                 pending — skipped disarming the listener (no JNI call is safe with one pending); \
1629                 releasing only the global references"
1630            ),
1631            Ok(Some(Err(error))) => log::warn!(
1632                "frust-native-widgets: ListenerHandle drop ({kinds}) failed disarming its \
1633                 listener: {error}"
1634            ),
1635            Err(error) => log::warn!(
1636                "frust-native-widgets: ListenerHandle drop ({kinds}) could not reach a JNI env: \
1637                 {error}"
1638            ),
1639        }
1640    }
1641}
1642
1643/// The two Apple arms detach on drop too: the platform holds the target
1644/// weakly, so releasing it while still attached would leave a control whose
1645/// action has nowhere to go. Runs on the main thread by construction — a
1646/// handle lives in a component's `State`, which the runtime drops there.
1647#[cfg(target_os = "ios")]
1648impl Drop for ListenerHandle {
1649    fn drop(&mut self) {
1650        self.inner.target.detach_component(
1651            &self.inner.control,
1652            self.kinds.contains(ListenerKinds::CLICK),
1653            self.kinds.contains(ListenerKinds::TOGGLED),
1654            self.kinds.contains(ListenerKinds::VALUE_CHANGED),
1655        );
1656    }
1657}
1658
1659/// See the iOS arm's `Drop`.
1660#[cfg(target_os = "macos")]
1661impl Drop for ListenerHandle {
1662    fn drop(&mut self) {
1663        self.inner.target.detach_if_current(&self.inner.control);
1664    }
1665}
1666
1667/// The host arm's stand-in listener: it decrements [`LIVE_LISTENERS`] when
1668/// dropped, the leak bar [`HostChild`] keeps for retained children.
1669#[cfg(not(any(target_os = "android", target_os = "ios", target_os = "macos")))]
1670pub(crate) struct HostListener {
1671    identity: u64,
1672}
1673
1674#[cfg(not(any(target_os = "android", target_os = "ios", target_os = "macos")))]
1675impl HostListener {
1676    /// A counted stand-in attached to the view with the given identity.
1677    fn new(identity: u64) -> Self {
1678        LIVE_LISTENERS.with(|live| live.set(live.get() + 1));
1679        Self { identity }
1680    }
1681}
1682
1683#[cfg(not(any(target_os = "android", target_os = "ios", target_os = "macos")))]
1684impl Drop for HostListener {
1685    fn drop(&mut self) {
1686        LIVE_LISTENERS.with(|live| live.set(live.get().saturating_sub(1)));
1687    }
1688}
1689
1690#[cfg(not(any(target_os = "android", target_os = "ios", target_os = "macos")))]
1691thread_local! {
1692    /// How many host stand-in listener handles are alive on this thread — the
1693    /// [`LIVE_CHILDREN`] leak bar's twin for [`ListenerHandle`].
1694    static LIVE_LISTENERS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1695}
1696
1697/// How many attached listener handles are currently alive — every
1698/// create/dispose cycle must return it to `0`.
1699#[cfg(not(any(target_os = "android", target_os = "ios", target_os = "macos")))]
1700#[allow(dead_code)] // the leak bar's only callers are this crate's own tests
1701pub(crate) fn live_listener_count() -> usize {
1702    LIVE_LISTENERS.with(|live| live.get())
1703}
1704
1705/// A platform listener firing for one component slot, as the generic listener
1706/// glue delivers it: a primitive `(kind, detail)` pair, never an allocation on
1707/// the hot path.
1708///
1709/// **This bypasses `RenderRoot::event` entirely** (the crate doc): it is a
1710/// platform interaction surfacing as a callback on the main thread, not a
1711/// frust pointer event — there is no `EventCtx`, no capture, no focus, and no
1712/// fire-on-up-inside semantics unless the platform control itself has them.
1713#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1714pub struct NativeEvent {
1715    kind: i32,
1716    detail: i64,
1717}
1718
1719impl NativeEvent {
1720    /// A click / tap ([`ListenerKinds::CLICK`]); `detail` unused (`0`).
1721    pub const KIND_CLICK: i32 = EVENT_KIND_CLICK;
1722    /// A two-state control flipped ([`ListenerKinds::TOGGLED`]); the new
1723    /// state is [`Self::checked`].
1724    pub const KIND_TOGGLED: i32 = EVENT_KIND_TOGGLED;
1725    /// A ranged control moved ([`ListenerKinds::VALUE_CHANGED`]); the new
1726    /// position is [`Self::value`].
1727    pub const KIND_VALUE_CHANGED: i32 = EVENT_KIND_VALUE_CHANGED;
1728    /// A ranged control's drag began (Android and iOS only); `detail` unused.
1729    pub const KIND_DRAG_START: i32 = EVENT_KIND_DRAG_START;
1730    /// A ranged control's drag ended (Android and iOS only); `detail` unused.
1731    pub const KIND_DRAG_END: i32 = EVENT_KIND_DRAG_END;
1732
1733    /// Which listener fired — one of the `KIND_*` codes above, which are
1734    /// `crate::events`' wire codes, shared verbatim with Android's listener.
1735    pub fn kind(self) -> i32 {
1736        self.kind
1737    }
1738
1739    /// The listener's primitive payload; `0` for a kind that carries none.
1740    pub fn detail(self) -> i64 {
1741        self.detail
1742    }
1743
1744    /// Whether this is a [`Self::KIND_CLICK`].
1745    pub fn is_click(self) -> bool {
1746        self.kind == EVENT_KIND_CLICK
1747    }
1748
1749    /// The reported checked state of a [`Self::KIND_TOGGLED`]; `None` for
1750    /// any other kind.
1751    pub fn checked(self) -> Option<bool> {
1752        (self.kind == EVENT_KIND_TOGGLED).then(|| unpack_bool(self.detail))
1753    }
1754
1755    /// The reported position of a [`Self::KIND_VALUE_CHANGED`], in the
1756    /// **platform's own** space (a `SeekBar`'s zero-based progress, a
1757    /// `UISlider`'s/`NSSlider`'s value rounded to the nearest integer) —
1758    /// mapping it into an app range is the component's job, since only it
1759    /// configured the control's range. `None` for any other kind.
1760    pub fn value(self) -> Option<i32> {
1761        (self.kind == EVENT_KIND_VALUE_CHANGED).then(|| unpack_value_changed(self.detail).0)
1762    }
1763
1764    /// The [`ListenerKinds`] family whose attach delivers this kind — what the
1765    /// bridge checks a slot attached before delivering (the module doc's
1766    /// *Listener attachment*); [`ListenerKinds::NONE`] for a kind no family
1767    /// delivers.
1768    pub fn family(self) -> ListenerKinds {
1769        match self.kind {
1770            EVENT_KIND_CLICK => ListenerKinds::CLICK,
1771            EVENT_KIND_TOGGLED => ListenerKinds::TOGGLED,
1772            EVENT_KIND_VALUE_CHANGED | EVENT_KIND_DRAG_START | EVENT_KIND_DRAG_END => {
1773                ListenerKinds::VALUE_CHANGED
1774            }
1775            _ => ListenerKinds::NONE,
1776        }
1777    }
1778
1779    /// Adapt the runtime's internal wire event.
1780    fn from_wire(event: WireEvent) -> Self {
1781        Self {
1782            kind: event.kind,
1783            detail: event.detail,
1784        }
1785    }
1786
1787    /// This event in the built-in controls' typed [`EventPayload`] vocabulary —
1788    /// the table a component's answer rides to the app's hook
1789    /// (`crate::api::mount`). `None` for a kind that vocabulary has no word
1790    /// for, which the bridge then drops.
1791    ///
1792    /// Exactly inverted by [`Self::from_payload`] for every kind it maps: the
1793    /// same `crate::events` codec packs and unpacks both directions.
1794    pub(crate) fn into_payload(self) -> Option<EventPayload> {
1795        match self.kind {
1796            EVENT_KIND_CLICK => Some(EventPayload::Click),
1797            EVENT_KIND_TOGGLED => Some(EventPayload::Toggled(unpack_bool(self.detail))),
1798            EVENT_KIND_VALUE_CHANGED => {
1799                let (value, from_user) = unpack_value_changed(self.detail);
1800                Some(EventPayload::ValueChanged { value, from_user })
1801            }
1802            EVENT_KIND_DRAG_START => Some(EventPayload::DragStart),
1803            EVENT_KIND_DRAG_END => Some(EventPayload::DragEnd),
1804            _ => None,
1805        }
1806    }
1807
1808    /// [`Self::into_payload`]'s inverse — how `crate::api::mount` hands the
1809    /// app's `.on_event` hook the public pair back.
1810    pub(crate) fn from_payload(payload: EventPayload) -> Self {
1811        let (kind, detail) = match payload {
1812            EventPayload::Click => (EVENT_KIND_CLICK, 0),
1813            EventPayload::Toggled(checked) => (EVENT_KIND_TOGGLED, pack_bool(checked)),
1814            EventPayload::ValueChanged { value, from_user } => (
1815                EVENT_KIND_VALUE_CHANGED,
1816                pack_value_changed(value, from_user),
1817            ),
1818            EventPayload::DragStart => (EVENT_KIND_DRAG_START, 0),
1819            EventPayload::DragEnd => (EVENT_KIND_DRAG_END, 0),
1820            EventPayload::Selected(index) => (
1821                crate::events::EVENT_KIND_SELECTION,
1822                crate::events::pack_index(index as isize),
1823            ),
1824            EventPayload::Date(date) => (
1825                crate::events::EVENT_KIND_DATE,
1826                crate::events::pack_date(date),
1827            ),
1828            EventPayload::Reselected(index) => (
1829                crate::events::EVENT_KIND_RESELECTED,
1830                crate::events::pack_index(index as isize),
1831            ),
1832        };
1833        Self { kind, detail }
1834    }
1835}
1836
1837// --- registration ------------------------------------------------------------
1838
1839/// Register `C` under `kind`, so a slot whose params name that kind is served
1840/// by this component — the module doc's decision **2**.
1841///
1842/// Call it once, from app or plugin init, **on the platform main thread**.
1843/// Returns whether the registration was accepted: **first-wins**, so a `kind`
1844/// already taken — including the built-in control kinds this build's
1845/// backend registers itself — is refused with a warning rather than replaced.
1846///
1847/// # A wrong-thread call is refused, not silently accepted
1848///
1849/// The runtime is a `thread_local!`, and only the platform main thread's copy
1850/// is ever dispatched through: a registration made anywhere else lands in a
1851/// runtime nothing will ever consult, and the symptom arrives much later, as a
1852/// component that simply never appears. So this asks the platform first —
1853/// `Looper.myLooper() == Looper.getMainLooper()` on Android (through the
1854/// plugin substrate's scoped attach), `MainThreadMarker` on iOS — and a
1855/// definitive *no* is refused outright: nothing is registered, `false` comes
1856/// back, and the reason is logged at error level.
1857///
1858/// A platform that cannot answer proceeds as before. On Android that means
1859/// before the shell installs the plugin handles (`NotInitialized`) or if the
1860/// probe itself fails; on a desktop/CI host there is no platform main thread
1861/// to be wrong about at all, and nothing dispatches there in any case. Guessing
1862/// "wrong thread" from an unknown answer would turn a legitimate registration
1863/// into a silent no-show, which is the exact failure this is meant to prevent.
1864///
1865/// Registration is explicit on purpose: `inventory`-style link-time discovery
1866/// is banned in this crate, because a stripped, LTO'd device build is exactly
1867/// where it fails silently.
1868pub fn register_component<C: NativeComponent>(kind: &'static str) -> bool {
1869    if on_platform_main_thread() == Some(false) {
1870        log::error!(
1871            "frust-native-widgets: register_component('{kind}') was called off the platform main \
1872             thread — the runtime is thread-local, so this registration would land in a runtime \
1873             nothing ever dispatches through and the component would never appear. Refused; \
1874             register from app or plugin init on the main thread."
1875        );
1876        return false;
1877    }
1878    // Also the moment the iOS factory class must exist by, if an app registers
1879    // long before it mounts anything: `crate::runtime`'s own encode path forces
1880    // this too, and it is idempotent (a `Once`), so paying it here as well only
1881    // moves the cost earlier.
1882    crate::runtime::ensure_platform_factory();
1883    with_runtime(|runtime| runtime.register_if_free::<Bridge<C>>(kind)).unwrap_or(false)
1884}
1885
1886/// Whether this is the platform main thread — the one thread whose
1887/// thread-local runtime the host actually dispatches through.
1888///
1889/// `None` is *unknown*, and every unknown answer means "proceed" at the one
1890/// call site ([`register_component`]): refusing a legitimate main-thread
1891/// registration would be far worse than missing a wrong-thread one.
1892///
1893/// `Looper.myLooper()` answers null on a thread with no looper and
1894/// `getMainLooper()` never does, so the pair is never both-null — the trap
1895/// `IsSameObject` sets for two nulls, which `frust-camera`'s own
1896/// `ensure_off_ui_thread` names in the same words. The scoped attach is
1897/// `frust_plugin`'s (`docs/CODE_STANDARDS.md`'s never-`attach_permanently`
1898/// rule), and any JNI failure — including the pre-init `NotInitialized` — maps
1899/// to *unknown* rather than to a refusal.
1900#[cfg(target_os = "android")]
1901fn on_platform_main_thread() -> Option<bool> {
1902    use jni::{Env, jni_sig, jni_str};
1903
1904    fn on_main_looper(env: &mut Env<'_>) -> Result<bool, jni::errors::Error> {
1905        let looper = env.find_class(jni_str!("android/os/Looper"))?;
1906        let mine = env
1907            .call_static_method(
1908                &looper,
1909                jni_str!("myLooper"),
1910                jni_sig!("()Landroid/os/Looper;"),
1911                &[],
1912            )?
1913            .l()?;
1914        let main = env
1915            .call_static_method(
1916                &looper,
1917                jni_str!("getMainLooper"),
1918                jni_sig!("()Landroid/os/Looper;"),
1919                &[],
1920            )?
1921            .l()?;
1922        env.is_same_object(&mine, &main)
1923    }
1924
1925    frust_plugin::android::with_jni_env(|env, _context| {
1926        let probe = on_main_looper(env);
1927        // Never leave a pending exception behind for the next JNI call, even
1928        // on a path whose whole answer is "I don't know".
1929        if env.exception_check() {
1930            env.exception_clear();
1931            return None;
1932        }
1933        probe.ok()
1934    })
1935    .ok()
1936    .flatten()
1937}
1938
1939/// See the Android arm: `MainThreadMarker::new()` is `NSThread.isMainThread`,
1940/// so both Apple arms (UIKit and AppKit — the desktop host dispatches on
1941/// winit's event-loop thread, which is the process main thread) always have a
1942/// definitive answer.
1943#[cfg(any(target_os = "ios", target_os = "macos"))]
1944fn on_platform_main_thread() -> Option<bool> {
1945    Some(objc2::MainThreadMarker::new().is_some())
1946}
1947
1948/// See the Android arm: a host with no platform arm (Linux/Windows/web) has no
1949/// platform main thread to be wrong about — and no platform dispatch either —
1950/// so the answer is always *unknown*.
1951#[cfg(not(any(target_os = "android", target_os = "ios", target_os = "macos")))]
1952fn on_platform_main_thread() -> Option<bool> {
1953    None
1954}
1955
1956// --- the typed props channel -------------------------------------------------
1957
1958/// How many **consecutive** failed `update` dispatches a slot may cost before
1959/// the runtime stops asking for another one (module doc's *A failed `update` is
1960/// retried, up to a cap*).
1961///
1962/// Three, and the number is a judgement rather than a measurement — nothing in
1963/// this repo has ever measured a native-widgets retry on device, and the
1964/// Android arm is compile-gated only. The reasoning it encodes: one attempt is
1965/// the app's own change and buys nothing extra; a second covers the transient
1966/// refusal this mechanism exists for (a setter that threw because a sibling
1967/// view had not been laid out yet, a resource that arrived one frame late); a
1968/// third is the cheap benefit of the doubt. Beyond that the evidence says the
1969/// component is broken, not unlucky, and every further attempt is a dispatch
1970/// and a `log::warn!` per rebuild on the platform main thread — which on a
1971/// screen that rebuilds every frame is per-frame JNI traffic and per-frame log
1972/// volume, and would falsify the crate's headline zero-crossing invariant for
1973/// the process lifetime. Small enough to bound the damage, large enough that a
1974/// component has to fail three times in a row to be given up on.
1975const RETRY_UPDATE_BUDGET: u32 = 3;
1976
1977/// One slot's staged component value and props, as [`publish`] left them.
1978struct Staged {
1979    /// `Rc<C>` for the registered `C`, erased — cloned into the instance at
1980    /// create/update time.
1981    component: Rc<dyn Any>,
1982    /// `C::Props`, erased.
1983    props: Box<dyn Any>,
1984    /// Bumped by [`publish`] only when the incoming props differ from these —
1985    /// the wire-visible change signal (module doc's *Props travel beside the
1986    /// wire*) — or when [`Self::retry_update`] asks for one.
1987    generation: u64,
1988    /// Set by [`request_update_retry`] when a dispatch for this slot failed,
1989    /// and consumed by the next [`publish`], which then bumps the generation
1990    /// even for identical props.
1991    ///
1992    /// This is the whole retry mechanism (module doc's *A failed `update` is
1993    /// retried, up to a cap*): the runtime keeps its diff baseline on a
1994    /// failure, but only a wire change makes the differ hand it a second
1995    /// chance, and only this makes the wire change when the app's props do not.
1996    retry_update: bool,
1997    /// How many `update` dispatches for this slot have failed in a row, reset
1998    /// by [`clear_update_retry_budget`] on the first one that succeeds.
1999    ///
2000    /// The bound on the field above: once it reaches [`RETRY_UPDATE_BUDGET`],
2001    /// [`request_update_retry`] reports once and stops re-marking, so an
2002    /// unchanged rebuild costs nothing again. It survives a [`publish`]
2003    /// deliberately — the whole point is to count across the rebuilds that
2004    /// carry the retries — and dies with the entry when the mounting widget's
2005    /// [`forget`] reaper runs, which is the same slot-lifetime bound
2006    /// everything else in this table lives under.
2007    consecutive_update_failures: u32,
2008}
2009
2010thread_local! {
2011    /// Per-slot staged props, main-thread-confined exactly like the runtime
2012    /// itself. A `thread_local!` rather than a `Mutex` global for the same
2013    /// reason (`crate::runtime`'s *main-thread confinement*): a component value
2014    /// may hold `!Send` platform handles, which a global would have to forbid.
2015    static STAGED: RefCell<HashMap<SlotId, Staged>> = RefCell::new(HashMap::new());
2016}
2017
2018/// Run `f` against the calling thread's staging table, reporting `None` when
2019/// it is already borrowed on this thread — the same re-entrancy tolerance
2020/// `crate::runtime::with_runtime` has, and for the same reason: dropping a
2021/// pathological re-entrant call (a `Props: Clone` impl that itself publishes)
2022/// beats panicking anywhere near an FFI boundary.
2023fn with_staged<T>(f: impl FnOnce(&mut HashMap<SlotId, Staged>) -> T) -> Option<T> {
2024    STAGED
2025        .try_with(|cell| match cell.try_borrow_mut() {
2026            Ok(mut staged) => Some(f(&mut staged)),
2027            Err(_) => {
2028                log::warn!("frust-native-widgets: re-entrant component props publish ignored");
2029                None
2030            }
2031        })
2032        .unwrap_or_default()
2033}
2034
2035/// Stage this rebuild's component value and props for `slot`, returning the
2036/// props **generation** the slot's `params_json` must carry
2037/// ([`component_params`]).
2038///
2039/// The generation changes if and only if `props` differ from what is already
2040/// staged — or a failed dispatch marked the slot for retry
2041/// ([`request_update_retry`]) — which is what makes the platform-view differ
2042/// emit an `UpdateParams` exactly when a component's props actually changed, or
2043/// when a change it already reported failed to apply, and nothing at all
2044/// otherwise.
2045///
2046/// Publishing is idempotent and replaces outright: props are whole state, never
2047/// a delta (module doc's lifecycle contract, point 2).
2048///
2049/// The component arrives as an `Rc` the mounting builder already holds
2050/// (`crate::api::mount`), so a rebuild that changes nothing costs one
2051/// refcount bump rather than a deep clone of the app's component value — and
2052/// the public trait needs no `Clone` bound as a result.
2053pub(crate) fn publish<C: NativeComponent>(slot: SlotId, component: Rc<C>, props: C::Props) -> u64 {
2054    with_staged(|staged| {
2055        let (generation, failures) = match staged.get(&slot) {
2056            // A slot whose staged props are a *different* component's (a kind
2057            // swap on a re-used slot id) counts as changed, not as equal.
2058            Some(previous) => {
2059                let unchanged = previous
2060                    .props
2061                    .downcast_ref::<C::Props>()
2062                    .is_some_and(|staged| *staged == props);
2063                // The retry mark forces the bump an unchanged republish would
2064                // not otherwise make — and is consumed by making it, so one
2065                // failure buys exactly one retry.
2066                let generation = if unchanged && !previous.retry_update {
2067                    previous.generation
2068                } else {
2069                    previous.generation.wrapping_add(1)
2070                };
2071                // Carried across the republish on purpose: the budget counts
2072                // failures across the very rebuilds that carry the retries, so
2073                // resetting it here would restore the unbounded loop exactly.
2074                (generation, previous.consecutive_update_failures)
2075            }
2076            None => (0, 0),
2077        };
2078        staged.insert(
2079            slot,
2080            Staged {
2081                component,
2082                props: Box::new(props),
2083                generation,
2084                retry_update: false,
2085                consecutive_update_failures: failures,
2086            },
2087        );
2088        generation
2089    })
2090    .unwrap_or(0)
2091}
2092
2093/// Mark `slot` so the next [`publish`] bumps its props generation whatever the
2094/// app publishes — the retry half of the module doc's *A failed `update` is
2095/// retried, up to a cap* — **unless this slot has spent its budget.**
2096///
2097/// Called from [`Bridge`]'s dispatch when a component reports a failure, which
2098/// leaves the runtime's diff baseline behind the app's intent; without the
2099/// forced bump, an app that republishes the same props forever would emit no
2100/// further `UpdateParams` and the runtime would never get to try again.
2101///
2102/// The bound is [`RETRY_UPDATE_BUDGET`] **consecutive** failures. Reaching it
2103/// reports once at `warn` and leaves the mark unset, so a permanently failing
2104/// slot settles into inert rather than costing a dispatch and a log line on
2105/// every rebuild for the process lifetime; later calls for the same slot are
2106/// silent, since the one report is the point and repeating it would be the very
2107/// log volume the cap exists to stop. A success in between clears the count
2108/// ([`clear_update_retry_budget`]), so the cap never accumulates across
2109/// unrelated flakes.
2110///
2111/// A slot with nothing staged (its reaper already ran, or it was never a
2112/// component slot) is a silent no-op: there is no rebuild left to retry from.
2113fn request_update_retry(slot: SlotId) {
2114    with_staged(|staged| {
2115        let Some(entry) = staged.get_mut(&slot) else {
2116            return;
2117        };
2118        if entry.consecutive_update_failures >= RETRY_UPDATE_BUDGET {
2119            // Already given up on and already reported: stay inert and quiet.
2120            return;
2121        }
2122        entry.consecutive_update_failures += 1;
2123        if entry.consecutive_update_failures >= RETRY_UPDATE_BUDGET {
2124            log::warn!(
2125                "frust-native-widgets: native component slot {slot} failed \
2126                 {RETRY_UPDATE_BUDGET} consecutive updates — no further retries will be \
2127                 scheduled for it. Its native view keeps whatever state the last successful \
2128                 update left; a later props change is still dispatched (only the runtime's own \
2129                 retry is capped), and one that succeeds restores the full budget."
2130            );
2131            return;
2132        }
2133        entry.retry_update = true;
2134    });
2135}
2136
2137/// Clear `slot`'s consecutive-failure count — called from [`Bridge::update`]
2138/// the moment a dispatch succeeds, which is what makes [`RETRY_UPDATE_BUDGET`]
2139/// a bound on a *run* of failures rather than on a slot's lifetime total.
2140///
2141/// A slot already at zero (the overwhelmingly common case) is left untouched,
2142/// so the ordinary success path costs one hash lookup and no write.
2143fn clear_update_retry_budget(slot: SlotId) {
2144    with_staged(|staged| {
2145        if let Some(entry) = staged.get_mut(&slot)
2146            && entry.consecutive_update_failures != 0
2147        {
2148            entry.consecutive_update_failures = 0;
2149        }
2150    });
2151}
2152
2153/// Drop `slot`'s staged component and props — the teardown reaper, registered
2154/// from the mounting widget's `on_cleanup` exactly like
2155/// `crate::runtime`'s `forget_pending_callback`.
2156///
2157/// **Disposal alone does not bound this table.** A culled slot's dispose
2158/// resolves by native-view identity and never names a slot id, so a slot
2159/// disposed while off-screen and then re-published by a still-mounted widget
2160/// would strand its entry for the process lifetime without this. Idempotent: a
2161/// slot with nothing staged is a silent no-op.
2162pub(crate) fn forget(slot: SlotId) {
2163    with_staged(|staged| staged.remove(&slot));
2164}
2165
2166/// The `params_json` a component slot's `platform_view` carries: the
2167/// runtime's two identity keys, the props generation [`publish`] returned,
2168/// and the app's active brightness (theme ladder L1's [`DARK`] wire bit) —
2169/// never a component's real props.
2170///
2171/// `dark` is the caller's to resolve (`crate::api::mount`'s `build_with_mode`
2172/// reads it off the same `use_context::<Theme>()` the builders'
2173/// `ambient_theme_tokens` already does) — this module has no reactive
2174/// context of its own. Carrying the bit directly on the wire, rather than
2175/// folding it into a component's typed `Props` (which this crate
2176/// deliberately never touches — `crate::api::mount`'s *What a component's
2177/// builder does NOT carry*), means a brightness-only flip still changes
2178/// `params_json` byte-for-byte, bumping `PlatformViewFrame::params_generation`
2179/// and reaching `crate::appkit::theme`'s/`crate::apple::theme`'s shared
2180/// `brightness_is_dark`, which both read this exact key straight off a
2181/// slot's raw wire — unconditionally, for every registered kind, before any
2182/// per-kind decode — the same mechanism the built-in controls' own
2183/// `params_for` already rides.
2184pub(crate) fn component_params(kind: &str, slot: SlotId, generation: u64, dark: bool) -> String {
2185    crate::runtime::with_identity(
2186        kind,
2187        slot,
2188        &format!("\"{PROPS_GENERATION_KEY}\":{generation},\"{DARK}\":{dark}"),
2189    )
2190}
2191
2192/// How many slots currently have staged props — the staging table's own leak
2193/// bar, which every mount/unmount cycle must return to `0`.
2194#[allow(dead_code)] // the staging table's leak bar: tests only, by design
2195pub(crate) fn staged_count() -> usize {
2196    with_staged(|staged| staged.len()).unwrap_or(0)
2197}
2198
2199/// Why a staged lookup missed — two answers a caller must never conflate
2200/// (module doc's *Kind and type must agree*).
2201#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2202enum StagedMiss {
2203    /// Nothing at all is staged for the slot: the ordinary shape of a replay
2204    /// or a dispatch arriving after the mounting widget's reaper ran.
2205    Unstaged,
2206    /// Something *is* staged, but it belongs to a different component than the
2207    /// one this kind is registered to — a kind/type mismatch, and a wiring bug
2208    /// rather than the lifecycle race the "nothing staged" wording would
2209    /// suggest.
2210    OtherComponent,
2211}
2212
2213impl StagedMiss {
2214    /// The error this miss surfaces as, naming `what` was looked for (`props`
2215    /// or `component`) and, where the caller knows it, the `kind` the slot was
2216    /// mounted under.
2217    fn describe(self, slot: SlotId, what: &str, kind: Option<&str>) -> NativeWidgetError {
2218        match self {
2219            Self::Unstaged => NativeWidgetError::Params(format!(
2220                "native component slot {slot} has no published {what}"
2221            )),
2222            Self::OtherComponent => {
2223                let named = kind
2224                    .map(|kind| format!(" (kind '{kind}')"))
2225                    .unwrap_or_default();
2226                NativeWidgetError::Params(format!(
2227                    "native component slot {slot}{named} staged a different component's {what} — \
2228                     the kind a slot is mounted under must be the one that component was \
2229                     registered under"
2230                ))
2231            }
2232        }
2233    }
2234}
2235
2236/// `slot`'s staged props, cloned for the runtime's own baseline copy.
2237///
2238/// A staging table already borrowed on this thread (the re-entrant publish
2239/// `with_staged` warns about) reads as [`StagedMiss::Unstaged`] — there is
2240/// nothing this can answer with, and the borrow itself is logged where it is
2241/// detected.
2242fn staged_props<C: NativeComponent>(slot: SlotId) -> Result<C::Props, StagedMiss> {
2243    with_staged(|staged| match staged.get(&slot) {
2244        None => Err(StagedMiss::Unstaged),
2245        Some(entry) => entry
2246            .props
2247            .downcast_ref::<C::Props>()
2248            .cloned()
2249            .ok_or(StagedMiss::OtherComponent),
2250    })
2251    .unwrap_or(Err(StagedMiss::Unstaged))
2252}
2253
2254/// `slot`'s staged component value; see [`staged_props`] for the miss rules.
2255fn staged_component<C: NativeComponent>(slot: SlotId) -> Result<Rc<C>, StagedMiss> {
2256    with_staged(|staged| match staged.get(&slot) {
2257        None => Err(StagedMiss::Unstaged),
2258        Some(entry) => Rc::clone(&entry.component)
2259            .downcast::<C>()
2260            .map_err(|_| StagedMiss::OtherComponent),
2261    })
2262    .unwrap_or(Err(StagedMiss::Unstaged))
2263}
2264
2265// --- the bridge to the internal runtime --------------------------------------
2266
2267/// One internal `NativeWidget` impl standing in for **every** public
2268/// [`NativeComponent`] — the module doc's bridge.
2269///
2270/// Never instantiated: like the built-in controls' own marker types it exists only
2271/// to name a vtable ([`register_component`] registers `Bridge<C>`, and the
2272/// runtime's dispatch table holds the monomorphised shims).
2273pub(crate) struct Bridge<C>(PhantomData<fn() -> C>);
2274
2275/// [`Bridge`]'s props: the component's own, plus the slot id the runtime's
2276/// identity keys carried — which is how `create`/`update` find their way back
2277/// to the staging table.
2278pub(crate) struct BridgeProps<C: NativeComponent> {
2279    slot: SlotId,
2280    props: C::Props,
2281}
2282
2283impl<C: NativeComponent> Clone for BridgeProps<C> {
2284    fn clone(&self) -> Self {
2285        Self {
2286            slot: self.slot,
2287            props: self.props.clone(),
2288        }
2289    }
2290}
2291
2292impl<C: NativeComponent> PartialEq for BridgeProps<C> {
2293    /// The props diff gate itself: the slot id is part of the comparison for
2294    /// completeness, but it is the component's own `PartialEq` that decides
2295    /// whether anything crosses the FFI boundary.
2296    fn eq(&self, other: &Self) -> bool {
2297        self.slot == other.slot && self.props == other.props
2298    }
2299}
2300
2301/// [`Bridge`]'s state: the newest component value this slot has resolved,
2302/// beside the component's own state and the slot id both are keyed by.
2303///
2304/// The value is **retained as a fallback, not as the source of truth**: every
2305/// dispatch that carries a `&self` into the public trait re-reads the staging
2306/// table first ([`Self::refresh_component`]), because the app constructs its
2307/// component value fresh on every rebuild and only the staging table sees all
2308/// of them. The retained copy answers the calls that arrive with no staged
2309/// entry left to read — a dispose landing after the mounting widget's
2310/// `forget` reaper already ran (`crate::api::mount`'s `on_cleanup`), which on
2311/// the primary teardown path is the *usual* order, since `retire`'s `Dispose`
2312/// is drained a frame or more later.
2313///
2314/// It also carries the two things an event dispatch needs that
2315/// `NativeWidget::on_event`'s `(state, event)` signature does not: the props
2316/// last applied (the typed baseline [`NativeComponent::on_event`] is handed)
2317/// and the [`ListenerKinds`] this slot's component attached — the event gate
2318/// (module doc's *Listener attachment*).
2319pub(crate) struct BridgeState<C: NativeComponent> {
2320    /// The slot whose staged entry [`Self::refresh_component`] re-reads. Slot
2321    /// ids are handed out by a process-wide monotonic counter
2322    /// (`crate::api::builders`' `next_local_slot`) and never recycled, so this
2323    /// can only ever name this slot's own staged entry.
2324    slot: SlotId,
2325    component: Rc<C>,
2326    state: C::State,
2327    /// The props the last `create` or successful `update` applied — the same
2328    /// value the runtime keeps as its diff baseline, so a failed `update`
2329    /// leaves this at `old` exactly as it leaves the runtime's.
2330    props: C::Props,
2331    /// Every family an `attach_listener` succeeded for on this slot, across
2332    /// `create` and every `update` — the union, never narrowed, since a
2333    /// listener detached on the platform side simply stops firing.
2334    listening: ListenerKinds,
2335}
2336
2337impl<C: NativeComponent> BridgeState<C> {
2338    /// Re-read this slot's staged component value, so the call about to run
2339    /// sees the value the app published on its **most recent rebuild** — the
2340    /// guarantee [`NativeComponent::on_event`] states.
2341    ///
2342    /// This cannot be left to [`Bridge::update`] alone: the runtime's props
2343    /// diff gate (`crate::runtime`'s `update_params`) returns `Unchanged`
2344    /// before touching the vtable's `update` at all, so a rebuild that
2345    /// republishes equal props with a functionally different component value —
2346    /// new closures capturing a loop index, a different `Rc` — would otherwise
2347    /// leave the retained value stale and run the *old* closures on the next
2348    /// event or dispose.
2349    ///
2350    /// A slot with nothing staged (its `forget` reaper already ran) keeps the
2351    /// retained value: there is no newer value to be had, and nothing here
2352    /// resurrects a reaped entry or panics on its absence.
2353    fn refresh_component(&mut self) {
2354        if let Ok(component) = staged_component::<C>(self.slot) {
2355            self.component = component;
2356        }
2357    }
2358}
2359
2360/// Check for — and clear — a Java exception a component left pending at a
2361/// dispatch boundary (module doc's *Dispatch-boundary exception guard*).
2362///
2363/// `ComponentCtx::env` is a safe fn handing a third party the live `Env`, and
2364/// "clear your own pending exception" is prose, not a type: a component that
2365/// forgets leaves the *next* JNI call — anyone's — on undefined ground. So the
2366/// runtime checks after every dispatch that carried a context. The crate's own
2367/// `run_jni` helper is the check: it extracts the throwable's class and message
2368/// before clearing, so the report names what was thrown. Nothing is called
2369/// inside it — whatever it finds was raised before we got here.
2370#[cfg(target_os = "android")]
2371fn guard_pending_exception(ctx: &mut PlatformCtx<'_, '_>, op: &str) -> Option<NativeWidgetError> {
2372    ctx.run_jni(op, |_env| Ok::<(), jni::errors::Error>(()))
2373        .err()
2374}
2375
2376/// The same guard, for the one dispatch that is handed **no context** —
2377/// [`NativeWidget::on_event`] takes `(state, event)` and nothing else.
2378///
2379/// The `Env` comes from the process VM instead: `with_top_local_frame` borrows
2380/// the JNI stack frame the `nativeOnEvent` export is already running in rather
2381/// than pushing a new one, so the clean path is a `GetEnv` plus an
2382/// `ExceptionCheck`, and the handful of local references a *report* costs are
2383/// released when that export returns.
2384///
2385/// **Not `frust_plugin::android::with_jni_env`, deliberately.** That helper
2386/// attaches with jni 0.22's default `PreReThrowPostCatch` policy, which stashes
2387/// an already-pending exception before running the closure and re-throws it
2388/// after: a check inside it would read clean every time and clear nothing. (The
2389/// same policy also means a component that reaches JNI *through* that helper is
2390/// already caught on its own way out — this guard is for everything that does
2391/// not, from a raw `jni-sys` call to an attachment configured with `Ignore`.)
2392///
2393/// Answers `None` — *unknown*, never *clean* — when the platform handles are
2394/// not installed yet or the thread is not attached, both of which mean nothing
2395/// dispatched through here in the first place.
2396#[cfg(target_os = "android")]
2397fn guard_pending_exception_off_context(op: &str) -> Option<NativeWidgetError> {
2398    let vm = match frust_plugin::android::vm() {
2399        Ok(vm) => vm,
2400        Err(error) => {
2401            log::debug!(
2402                "frust-native-widgets: {op} boundary guard skipped — no platform handles: {error}"
2403            );
2404            return None;
2405        }
2406    };
2407    vm.with_top_local_frame(|env| {
2408        let mut ctx = PlatformCtx::detached(env);
2409        Ok::<Option<NativeWidgetError>, jni::errors::Error>(guard_pending_exception(&mut ctx, op))
2410    })
2411    .unwrap_or_else(|error: jni::errors::Error| {
2412        log::warn!("frust-native-widgets: {op} boundary guard could not reach a JNI env: {error}");
2413        None
2414    })
2415}
2416
2417/// The Apple arms (iOS and macOS) have nothing to guard: there is no
2418/// pending-exception channel between a component and the runtime here (an ObjC
2419/// exception is not a return path — `crate::apple::factory`'s and
2420/// `crate::appkit::factory`'s contracts answer a failed create with a
2421/// placeholder view instead), and no `Env` whose next call could be poisoned.
2422#[cfg(any(target_os = "ios", target_os = "macos"))]
2423fn guard_pending_exception(_ctx: &mut PlatformCtx<'_, '_>, _op: &str) -> Option<NativeWidgetError> {
2424    None
2425}
2426
2427/// See the context-carrying arm above: this platform has nothing to guard.
2428#[cfg(any(target_os = "ios", target_os = "macos"))]
2429fn guard_pending_exception_off_context(_op: &str) -> Option<NativeWidgetError> {
2430    None
2431}
2432
2433/// The host arm has no JNI to check, so it counts instead — see
2434/// `dispatch_guard_count`.
2435#[cfg(not(any(target_os = "android", target_os = "ios", target_os = "macos")))]
2436fn guard_pending_exception(_ctx: &mut PlatformCtx<'_, '_>, _op: &str) -> Option<NativeWidgetError> {
2437    DISPATCH_GUARDS.with(|guards| guards.set(guards.get() + 1));
2438    None
2439}
2440
2441/// The host arm of the context-free guard: it counts through the same tally, so
2442/// the wiring bar covers all four dispatches and not just the three that carry
2443/// a context.
2444#[cfg(not(any(target_os = "android", target_os = "ios", target_os = "macos")))]
2445fn guard_pending_exception_off_context(_op: &str) -> Option<NativeWidgetError> {
2446    DISPATCH_GUARDS.with(|guards| guards.set(guards.get() + 1));
2447    None
2448}
2449
2450#[cfg(not(any(target_os = "android", target_os = "ios", target_os = "macos")))]
2451thread_local! {
2452    /// How many times the dispatch-boundary guard ran on this thread — the
2453    /// host arm's stand-in for a check it cannot make, the same shape
2454    /// [`LIVE_CHILDREN`] uses for the leak bar. What a host test can prove is
2455    /// that **every** dispatch is *wired* to the guard — the three that carry a
2456    /// context and `on_event`, which reaches the VM instead; whether the JNI
2457    /// check itself finds a pending exception is Android-only, and this repo
2458    /// has no embedded-JVM harness to run it (`crate::android`'s own
2459    /// `create_failure_message` note).
2460    static DISPATCH_GUARDS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
2461}
2462
2463/// How many times the dispatch-boundary exception guard has run on this
2464/// thread.
2465#[cfg(not(any(target_os = "android", target_os = "ios", target_os = "macos")))]
2466#[allow(dead_code)] // the guard's wiring bar: tests only, by design
2467pub(crate) fn dispatch_guard_count() -> usize {
2468    DISPATCH_GUARDS.with(|guards| guards.get())
2469}
2470
2471/// Keep the first failure and log the second — [`ComponentCtx::latch`]'s rule
2472/// one level up, where a component's own latched error meets whatever the
2473/// dispatch-boundary guard found behind it.
2474fn fold_error(
2475    first: Option<NativeWidgetError>,
2476    later: Option<NativeWidgetError>,
2477) -> Option<NativeWidgetError> {
2478    match (first, later) {
2479        (Some(first), Some(later)) => {
2480            log::warn!(
2481                "frust-native-widgets: component dispatch reported '{first}' and also left \
2482                 '{later}' behind it — reporting the first"
2483            );
2484            Some(first)
2485        }
2486        (first, later) => first.or(later),
2487    }
2488}
2489
2490impl<C: NativeComponent> NativeWidget for Bridge<C> {
2491    type Props = BridgeProps<C>;
2492    type State = BridgeState<C>;
2493
2494    /// The wire carries only identity and a generation, so "decoding" a
2495    /// component's props means taking the typed value the app staged for this
2496    /// slot (module doc's *Props travel beside the wire*).
2497    fn decode_props(params: &Params<'_>) -> Result<Self::Props, NativeWidgetError> {
2498        let (kind, slot) = params.identity()?;
2499        // A miss here is one of two very different things — a reaped entry, or
2500        // a slot mounted under a kind registered to another component — and
2501        // the message says which (module doc's *Kind and type must agree*).
2502        let props =
2503            staged_props::<C>(slot).map_err(|miss| miss.describe(slot, "props", Some(&kind)))?;
2504        Ok(BridgeProps { slot, props })
2505    }
2506
2507    fn create(
2508        ctx: &mut PlatformCtx<'_, '_>,
2509        props: &Self::Props,
2510    ) -> Result<(NativeView, Self::State), NativeWidgetError> {
2511        // The kind is not carried this far (the runtime holds it, the props do
2512        // not), so the mismatch message names the slot alone — reachable here
2513        // rather than in `decode_props` only when the two components happen to
2514        // share one `Props` type (module doc's fourth row).
2515        let component = staged_component::<C>(props.slot)
2516            .map_err(|miss| miss.describe(props.slot, "component", None))?;
2517
2518        let mut cx = ComponentCtx::new(ctx, props.slot);
2519        let built = component.create(&mut cx, &props.props);
2520        let (error, listening) = cx.into_parts();
2521        let latched = fold_error(
2522            error,
2523            guard_pending_exception(ctx, "NativeComponent::create"),
2524        );
2525        match built {
2526            Some((root, state)) => {
2527                // The impl had the final say (the trait's `create` doc): a
2528                // latched error it recovered from is a warning, not a dead slot.
2529                if let Some(error) = latched {
2530                    log::warn!(
2531                        "frust-native-widgets: component slot {} created despite: {error}",
2532                        props.slot
2533                    );
2534                }
2535                Ok((
2536                    root.into_inner(),
2537                    BridgeState {
2538                        slot: props.slot,
2539                        component,
2540                        state,
2541                        props: props.props.clone(),
2542                        listening,
2543                    },
2544                ))
2545            }
2546            None => Err(latched.unwrap_or_else(|| {
2547                NativeWidgetError::Platform(format!(
2548                    "native component slot {} built no view",
2549                    props.slot
2550                ))
2551            })),
2552        }
2553    }
2554
2555    fn update(
2556        ctx: &mut PlatformCtx<'_, '_>,
2557        state: &mut Self::State,
2558        old: &Self::Props,
2559        new: &Self::Props,
2560    ) -> Result<(), NativeWidgetError> {
2561        // The app constructs its component value fresh every rebuild, so the
2562        // newest staged one wins here; a slot whose entry was already reaped
2563        // keeps the one it was created with.
2564        state.refresh_component();
2565        let component = Rc::clone(&state.component);
2566        let mut cx = ComponentCtx::new(ctx, new.slot);
2567        component.update(&mut cx, &mut state.state, &old.props, &new.props);
2568        let (error, attached) = cx.into_parts();
2569        // Recorded whatever the update's outcome: a listener attached before a
2570        // later setter failed is live, and its handle is in the component's
2571        // state now.
2572        state.listening |= attached;
2573        let error = fold_error(
2574            error,
2575            guard_pending_exception(ctx, "NativeComponent::update"),
2576        );
2577        match error {
2578            Some(error) => {
2579                // Reported, so the runtime keeps `old` as the diff baseline —
2580                // and marked, so the next rebuild's `publish` bumps the wire
2581                // even if the app republishes identical props, which is the
2582                // only thing that gets the runtime a second attempt. The mark
2583                // is refused once this slot has spent its consecutive-failure
2584                // budget, which is what keeps a permanently failing component
2585                // from costing a dispatch per rebuild forever (module doc's
2586                // *A failed `update` is retried, up to a cap*).
2587                request_update_retry(new.slot);
2588                Err(error)
2589            }
2590            None => {
2591                // A success ends the run of failures, so a flaky platform never
2592                // accumulates its way to the cap — and moves the baseline
2593                // `on_event` is handed, in step with the runtime's own.
2594                clear_update_retry_budget(new.slot);
2595                state.props = new.props.clone();
2596                Ok(())
2597            }
2598        }
2599    }
2600
2601    /// The event gate, then the component's own answer (module doc's
2602    /// *Listener attachment*).
2603    ///
2604    /// **The gate:** an event whose [`NativeEvent::family`] this slot never
2605    /// attached a listener for answers `None` without reaching the component
2606    /// at all. The runtime routes on the slot id alone, so without this a
2607    /// hand-built Android `FrustNativeListener` carrying a fabricated id that
2608    /// named a live component's slot would be delivered like a real event; with
2609    /// it, only the families the component itself asked for arrive. (A
2610    /// fabricated id naming a slot that *did* attach that family is still
2611    /// indistinguishable from the real listener — the runtime asks nothing
2612    /// about which object fired, for components and the built-in controls alike.)
2613    ///
2614    /// **The answer:** [`NativeComponent::on_event`]'s, mapped into the
2615    /// built-in controls' `EventPayload` vocabulary so it rides their callback table to
2616    /// the app's hook (`crate::api::mount`). A kind that vocabulary has no word
2617    /// for is dropped and logged.
2618    ///
2619    /// The staged value is re-read first — an event can arrive after any
2620    /// number of rebuilds that changed the component but not its props, and
2621    /// the props diff gate skips `update` on every one of them
2622    /// ([`BridgeState::refresh_component`]).
2623    ///
2624    /// **Guarded like the other three, through the VM rather than a context**,
2625    /// because this signature carries neither a context nor an `Env`: the
2626    /// leftover-exception hazard is the dispatch's, not the context's, and the
2627    /// only error channel here is the log (this returns `Option`, not
2628    /// `Result`). See `guard_pending_exception_off_context` and the module
2629    /// doc's *Dispatch-boundary exception guard*. The gate's early `None`
2630    /// crosses no FFI and runs no component code, so it has nothing to guard.
2631    fn on_event(state: &mut Self::State, event: WireEvent) -> Option<EventPayload> {
2632        let event = NativeEvent::from_wire(event);
2633        let family = event.family();
2634        if family.is_empty() || !state.listening.contains(family) {
2635            log::debug!(
2636                "frust-native-widgets: component slot {} got event kind {} but attached no \
2637                 listener for it (attached: {}) — dropped",
2638                state.slot,
2639                event.kind(),
2640                state.listening
2641            );
2642            return None;
2643        }
2644        state.refresh_component();
2645        let component = Rc::clone(&state.component);
2646        let answer = component.on_event(&mut state.state, &state.props, event);
2647        if let Some(error) = guard_pending_exception_off_context("NativeComponent::on_event") {
2648            log::warn!(
2649                "frust-native-widgets: component slot {} left a Java exception pending after \
2650                 on_event — cleared here, but the rest of that dispatch ran on undefined \
2651                 ground: {error}",
2652                state.slot
2653            );
2654        }
2655        let answer = answer?;
2656        let payload = answer.into_payload();
2657        if payload.is_none() {
2658            log::debug!(
2659                "frust-native-widgets: component slot {} answered event kind {}, which the \
2660                 app-facing callback has no vocabulary for — dropped",
2661                state.slot,
2662                answer.kind()
2663            );
2664        }
2665        payload
2666    }
2667
2668    /// The staged value is re-read first, for the same reason `on_event` does
2669    /// it: a dispose reaching a still-mounted slot (the differ's culling
2670    /// backstop, a `suspend_all` on surface teardown) must run the component
2671    /// the app last published, not whatever the last props change left behind
2672    /// ([`BridgeState::refresh_component`]).
2673    fn dispose(
2674        ctx: &mut PlatformCtx<'_, '_>,
2675        mut state: Self::State,
2676    ) -> Result<(), NativeWidgetError> {
2677        state.refresh_component();
2678        let BridgeState {
2679            slot,
2680            component,
2681            state,
2682            props: _,
2683            listening: _,
2684        } = state;
2685        let mut cx = ComponentCtx::new(ctx, slot);
2686        component.dispose(&mut cx, state);
2687        let (error, _attached) = cx.into_parts();
2688        match fold_error(
2689            error,
2690            guard_pending_exception(ctx, "NativeComponent::dispose"),
2691        ) {
2692            Some(error) => Err(error),
2693            None => Ok(()),
2694        }
2695    }
2696}
2697
2698// Gated on the host arm, not merely on `test` (the same gate `crate::runtime`'s
2699// own tests carry): these drive the public trait through the real runtime using
2700// the host stand-in context, which a platform build (macOS included, since it
2701// has a real AppKit arm) replaces with the platform-only types.
2702#[cfg(all(
2703    test,
2704    not(any(target_os = "android", target_os = "ios", target_os = "macos"))
2705))]
2706mod tests {
2707    use std::cell::{Cell, RefCell};
2708    use std::sync::{Mutex, Once};
2709
2710    use super::*;
2711    use crate::runtime::{DisposeOutcome, UpdateOutcome};
2712
2713    // --- the log sink -------------------------------------------------------
2714
2715    /// Everything logged since the sink was installed.
2716    static CAPTURED: Mutex<Vec<String>> = Mutex::new(Vec::new());
2717
2718    /// A process-wide `log` sink, so a test can prove a failure was *logged*
2719    /// rather than merely dropped.
2720    ///
2721    /// The latch keeps the first error and reports nothing else — by design —
2722    /// so "a later error is not silently swallowed" has exactly one observable
2723    /// channel, and this is it. Shared across test threads and never cleared:
2724    /// assertions match on a marker string unique to their own test rather
2725    /// than on the sink's contents as a whole.
2726    struct CapturedLog;
2727
2728    impl log::Log for CapturedLog {
2729        fn enabled(&self, _metadata: &log::Metadata<'_>) -> bool {
2730            true
2731        }
2732
2733        fn log(&self, record: &log::Record<'_>) {
2734            if let Ok(mut captured) = CAPTURED.lock() {
2735                captured.push(record.args().to_string());
2736            }
2737        }
2738
2739        fn flush(&self) {}
2740    }
2741
2742    static CAPTURE: CapturedLog = CapturedLog;
2743
2744    /// Install the sink, once per process. `log`'s runtime max level defaults
2745    /// to `Off`, so it is raised here too or `log::warn!` would compile to
2746    /// nothing observable.
2747    fn install_log_capture() {
2748        static INSTALL: Once = Once::new();
2749        INSTALL.call_once(|| {
2750            if log::set_logger(&CAPTURE).is_ok() {
2751                log::set_max_level(log::LevelFilter::Warn);
2752            }
2753        });
2754    }
2755
2756    /// Whether any captured line contains `needle`.
2757    fn logged(needle: &str) -> bool {
2758        CAPTURED
2759            .lock()
2760            .map(|captured| captured.iter().any(|line| line.contains(needle)))
2761            .unwrap_or(false)
2762    }
2763
2764    /// A component defined **outside the built-in controls** — the whole point of the
2765    /// acceptance bar: it implements nothing but the public
2766    /// [`NativeComponent`] trait, using only the public [`ComponentCtx`]/
2767    /// [`NativeRoot`]/[`NativeEvent`] surface, exactly as a third-party crate
2768    /// would.
2769    struct Gauge {
2770        /// Where this component records what it did. `Rc` on purpose: a test
2771        /// holding the other end can assert both the calls *and* (via
2772        /// `strong_count`) that the runtime actually dropped the component
2773        /// value rather than stranding it — the same leak-probe pattern
2774        /// established for every slot-keyed table in this crate.
2775        log: Rc<RefCell<Vec<String>>>,
2776        /// Distinguishes the component *value* from its props, so the tests
2777        /// can prove `update` sees the value the app published this rebuild
2778        /// rather than the one `create` ran with.
2779        tag: &'static str,
2780    }
2781
2782    #[derive(Clone, Debug, PartialEq)]
2783    struct GaugeProps {
2784        label: String,
2785        value: i32,
2786    }
2787
2788    struct GaugeState {
2789        identity: u64,
2790        events: Vec<NativeEvent>,
2791        /// The listener `create` attached to the gauge's root — a click and a
2792        /// value-changed listener, the two kinds these tests fire.
2793        _listener: ListenerHandle,
2794    }
2795
2796    const GAUGE_KIND: &str = "test-gauge";
2797
2798    /// The [`GaugeProps::label`] that makes `create` answer `None` — the
2799    /// failed-create test's opt-in, mirroring `crate::runtime`'s own
2800    /// `FAIL_CREATE` sentinel.
2801    const FAIL_CREATE: &str = "FAIL_CREATE";
2802
2803    /// The [`GaugeProps::label`] that makes `update` latch an error.
2804    const FAIL_UPDATE: &str = "FAIL_UPDATE";
2805
2806    /// The [`GaugeProps::label`] that makes `update` latch an error on its
2807    /// **first** attempt only — the retry test's opt-in, so a retry can be
2808    /// observed succeeding rather than merely being attempted again.
2809    const FAIL_UPDATE_ONCE: &str = "FAIL_UPDATE_ONCE";
2810
2811    /// The [`GaugeProps::label`] that makes `update` latch an error on **every**
2812    /// attempt *and record each one* — the retry-cap test's opt-in. It is a
2813    /// separate sentinel from [`FAIL_UPDATE`] only because that one records
2814    /// nothing, and the cap is a statement about *how many times* the component
2815    /// was asked.
2816    const FAIL_UPDATE_ALWAYS: &str = "FAIL_UPDATE_ALWAYS";
2817
2818    thread_local! {
2819        /// Whether [`FAIL_UPDATE_ONCE`]'s single failure has been spent. Per
2820        /// test thread, like every other thread-local this module's tests
2821        /// lean on (the staging table, the runtime itself).
2822        static UPDATE_FAILED_ONCE: Cell<bool> = const { Cell::new(false) };
2823    }
2824
2825    impl Gauge {
2826        fn new(log: &Rc<RefCell<Vec<String>>>, tag: &'static str) -> Self {
2827            Self {
2828                log: Rc::clone(log),
2829                tag,
2830            }
2831        }
2832
2833        fn note(&self, entry: String) {
2834            self.log.borrow_mut().push(entry);
2835        }
2836    }
2837
2838    impl NativeComponent for Gauge {
2839        type Props = GaugeProps;
2840        type State = GaugeState;
2841
2842        fn create(
2843            &self,
2844            ctx: &mut ComponentCtx<'_, '_, '_>,
2845            props: &Self::Props,
2846        ) -> Option<(NativeRoot, Self::State)> {
2847            if props.label == FAIL_CREATE {
2848                ctx.report_error("gauge: no native view today");
2849                return None;
2850            }
2851            let identity = next_identity();
2852            ctx.record(format!("gauge create {identity} '{}'", props.label));
2853            self.note(format!("{} create '{}'", self.tag, props.label));
2854            let listener = ctx.attach_listener(
2855                identity,
2856                ListenerKinds::CLICK | ListenerKinds::VALUE_CHANGED,
2857            )?;
2858            let root = ctx.root(identity)?;
2859            Some((
2860                root,
2861                GaugeState {
2862                    identity,
2863                    events: Vec::new(),
2864                    _listener: listener,
2865                },
2866            ))
2867        }
2868
2869        fn update(
2870            &self,
2871            ctx: &mut ComponentCtx<'_, '_, '_>,
2872            state: &mut Self::State,
2873            old: &Self::Props,
2874            new: &Self::Props,
2875        ) {
2876            if new.label == FAIL_UPDATE {
2877                ctx.report_error("gauge: setter threw");
2878                return;
2879            }
2880            if new.label == FAIL_UPDATE_ALWAYS {
2881                self.note(format!("{} update refused", self.tag));
2882                ctx.report_error("gauge: setter always throws");
2883                return;
2884            }
2885            if new.label == FAIL_UPDATE_ONCE && !UPDATE_FAILED_ONCE.replace(true) {
2886                self.note(format!("{} update failed", self.tag));
2887                ctx.report_error("gauge: setter threw once");
2888                return;
2889            }
2890            ctx.record(format!(
2891                "gauge update {} {} -> {}",
2892                state.identity, old.value, new.value
2893            ));
2894            self.note(format!(
2895                "{} update {} -> {}",
2896                self.tag, old.value, new.value
2897            ));
2898        }
2899
2900        fn on_event(
2901            &self,
2902            state: &mut Self::State,
2903            _props: &Self::Props,
2904            event: NativeEvent,
2905        ) -> Option<NativeEvent> {
2906            state.events.push(event);
2907            self.note(format!(
2908                "{} event {}/{}",
2909                self.tag,
2910                event.kind(),
2911                event.detail()
2912            ));
2913            Some(event)
2914        }
2915
2916        fn dispose(&self, ctx: &mut ComponentCtx<'_, '_, '_>, state: Self::State) {
2917            ctx.record(format!("gauge dispose {}", state.identity));
2918            self.note(format!("{} dispose", self.tag));
2919        }
2920    }
2921
2922    /// Monotonic stand-in for "the platform handed us a fresh object".
2923    fn next_identity() -> u64 {
2924        use std::sync::atomic::{AtomicU64, Ordering};
2925        static NEXT: AtomicU64 = AtomicU64::new(1);
2926        NEXT.fetch_add(1, Ordering::Relaxed)
2927    }
2928
2929    fn props(label: &str, value: i32) -> GaugeProps {
2930        GaugeProps {
2931            label: label.to_string(),
2932            value,
2933        }
2934    }
2935
2936    /// Publish `component` + `props` for `slot` and return the `params_json`
2937    /// its `platform_view` would carry — the exact sequence the app-facing
2938    /// builder will run on every rebuild. Brightness is out of scope for
2939    /// every test that calls this (`publish_bumps_the_generation_only_when_
2940    /// the_props_change` exercises the `dark` bit directly instead), so it is
2941    /// pinned to `false` here.
2942    fn mount(slot: SlotId, component: Gauge, props: GaugeProps) -> String {
2943        let generation = publish(slot, Rc::new(component), props);
2944        component_params(GAUGE_KIND, slot, generation, false)
2945    }
2946
2947    #[test]
2948    fn a_component_outside_the_built_in_controls_lives_the_whole_lifecycle() {
2949        // The acceptance bar: create → update → event → dispose, driven
2950        // by the real runtime through the public trait alone.
2951        let log = Rc::new(RefCell::new(Vec::new()));
2952        assert!(register_component::<Gauge>(GAUGE_KIND));
2953
2954        let params = mount(1, Gauge::new(&log, "first"), props("CPU", 10));
2955        let mut calls = Vec::new();
2956        {
2957            let mut ctx = PlatformCtx::new(&mut calls);
2958            with_runtime(|runtime| {
2959                assert_eq!(runtime.create(&mut ctx, &params).unwrap(), 1);
2960                assert_eq!(runtime.live_count(), 1);
2961
2962                // Same props republished: the diff gate stops before the
2963                // component is asked to do anything at all.
2964                let params = mount(1, Gauge::new(&log, "second"), props("CPU", 10));
2965                assert_eq!(
2966                    runtime.update_params(&mut ctx, &params).unwrap(),
2967                    UpdateOutcome::Unchanged
2968                );
2969
2970                // Changed props: applied once, and by the component value the
2971                // app published most recently.
2972                let params = mount(1, Gauge::new(&log, "third"), props("CPU", 42));
2973                assert_eq!(
2974                    runtime.update_params(&mut ctx, &params).unwrap(),
2975                    UpdateOutcome::Applied
2976                );
2977
2978                runtime.on_event(1, WireEvent { kind: 1, detail: 7 });
2979                assert_eq!(runtime.dispose_slot(&mut ctx, 1), DisposeOutcome::Disposed);
2980                assert_eq!(runtime.live_count(), 0);
2981            })
2982            .expect("the thread's runtime");
2983        }
2984
2985        assert_eq!(
2986            *log.borrow(),
2987            vec![
2988                "first create 'CPU'".to_string(),
2989                "third update 10 -> 42".to_string(),
2990                "third event 1/7".to_string(),
2991                "third dispose".to_string(),
2992            ],
2993            "one create, one update (the equal republish crossed nothing), \
2994             and both later calls ran on the newest published component value"
2995        );
2996        assert_eq!(
2997            calls.len(),
2998            4,
2999            "one platform call each, plus create's listener attach: {calls:?}"
3000        );
3001        assert!(calls[0].starts_with("gauge create"));
3002        assert!(calls[1].starts_with("attachListener "), "{calls:?}");
3003        assert!(calls[2].contains("10 -> 42"));
3004        assert!(calls[3].starts_with("gauge dispose"));
3005    }
3006
3007    #[test]
3008    fn an_unchanged_props_republish_still_reaches_the_newest_component_value() {
3009        // The regression this test exists for: the props diff gate
3010        // (`crate::runtime`'s `update_params`) returns `Unchanged` before
3011        // touching the vtable, so if `update` were the only thing refreshing
3012        // the bridge's retained component value, a rebuild republishing equal
3013        // props with functionally different closures would leave
3014        // `on_event`/`dispose` running the value `create` ran with.
3015        //
3016        // Two distinct log sinks stand in for those closures: each rebuild's
3017        // component captures its own `Rc`, exactly as an app's `move |v|
3018        // sig.set(v)` captures this rebuild's signal.
3019        let first = Rc::new(RefCell::new(Vec::new()));
3020        let second = Rc::new(RefCell::new(Vec::new()));
3021        register_component::<Gauge>(GAUGE_KIND);
3022
3023        let params = mount(20, Gauge::new(&first, "first"), props("CPU", 10));
3024        let mut calls = Vec::new();
3025        {
3026            let mut ctx = PlatformCtx::new(&mut calls);
3027            with_runtime(|runtime| {
3028                runtime.create(&mut ctx, &params).unwrap();
3029
3030                // The rebuild the defect hid behind: same props, a different
3031                // component value. The wire is byte-identical, so the differ
3032                // emits no `UpdateParams` at all — nothing calls `update`, and
3033                // this republish is the whole of what the runtime is told.
3034                let republished = mount(20, Gauge::new(&second, "second"), props("CPU", 10));
3035                assert_eq!(
3036                    republished, params,
3037                    "an equal-props rebuild leaves the wire untouched, which is \
3038                     exactly why `update` never runs to refresh anything"
3039                );
3040
3041                runtime.on_event(20, WireEvent { kind: 3, detail: 9 });
3042                assert_eq!(runtime.dispose_slot(&mut ctx, 20), DisposeOutcome::Disposed);
3043                assert_eq!(runtime.live_count(), 0);
3044            })
3045            .expect("the thread's runtime");
3046        }
3047        forget(20);
3048
3049        assert_eq!(
3050            *first.borrow(),
3051            vec!["first create 'CPU'".to_string()],
3052            "the value `create` ran with must not keep serving a later \
3053             rebuild's events and disposal"
3054        );
3055        assert_eq!(
3056            *second.borrow(),
3057            vec!["second event 3/9".to_string(), "second dispose".to_string(),],
3058            "both later calls ran on the component value the app published \
3059             most recently — the trait's `on_event` guarantee, literally"
3060        );
3061    }
3062
3063    #[test]
3064    fn a_dispose_after_the_staging_reaper_keeps_the_retained_component() {
3065        // The ordering seam of the fix above: on the PRIMARY teardown path the
3066        // mounting widget's `forget` reaper (`crate::api::mount`'s
3067        // `on_cleanup`) runs a frame or more BEFORE `retire`'s `Dispose` is
3068        // drained, so the staging lookup finds nothing by then. The retained
3069        // value answers, nothing resurrects the reaped entry, nothing panics —
3070        // and there is no newer value to be had, because a torn-down widget
3071        // published none.
3072        let first = Rc::new(RefCell::new(Vec::new()));
3073        let second = Rc::new(RefCell::new(Vec::new()));
3074        register_component::<Gauge>(GAUGE_KIND);
3075
3076        let params = mount(21, Gauge::new(&first, "first"), props("Mem", 1));
3077        let mut calls = Vec::new();
3078        {
3079            let mut ctx = PlatformCtx::new(&mut calls);
3080            with_runtime(|runtime| {
3081                runtime.create(&mut ctx, &params).unwrap();
3082                mount(21, Gauge::new(&second, "second"), props("Mem", 1));
3083
3084                // Teardown order: the reaper first, the native dispose after.
3085                forget(21);
3086                assert_eq!(staged_count(), 0);
3087                assert_eq!(runtime.dispose_slot(&mut ctx, 21), DisposeOutcome::Disposed);
3088                assert_eq!(runtime.live_count(), 0);
3089            })
3090            .expect("the thread's runtime");
3091        }
3092
3093        assert_eq!(
3094            *first.borrow(),
3095            vec![
3096                "first create 'Mem'".to_string(),
3097                "first dispose".to_string()
3098            ],
3099            "with the staged entry gone the retained value runs the dispose"
3100        );
3101        assert!(
3102            second.borrow().is_empty(),
3103            "a reaped entry is never resurrected: {:?}",
3104            second.borrow()
3105        );
3106    }
3107
3108    /// A control shaped exactly like the built-in ones: an **internal**
3109    /// `NativeWidget`, props decoded out of `params_json`, no staging table
3110    /// involved. Its only job here is to prove the two trait families share one
3111    /// dispatch table, since the real built-in controls compile on device targets only.
3112    struct LegacyControl;
3113
3114    #[derive(Clone, Debug, PartialEq)]
3115    struct LegacyProps {
3116        text: String,
3117    }
3118
3119    const LEGACY_KIND: &str = "test-legacy";
3120
3121    impl NativeWidget for LegacyControl {
3122        type Props = LegacyProps;
3123        type State = u64;
3124
3125        fn decode_props(params: &Params<'_>) -> Result<Self::Props, NativeWidgetError> {
3126            Ok(LegacyProps {
3127                text: params
3128                    .string("text")
3129                    .ok_or_else(|| NativeWidgetError::Params("no `text`".into()))?
3130                    .into_owned(),
3131            })
3132        }
3133
3134        fn create(
3135            ctx: &mut PlatformCtx<'_, '_>,
3136            props: &Self::Props,
3137        ) -> Result<(NativeView, Self::State), NativeWidgetError> {
3138            let identity = next_identity();
3139            ctx.record(format!("legacy create {identity} '{}'", props.text));
3140            Ok((NativeView { identity }, identity))
3141        }
3142
3143        fn update(
3144            ctx: &mut PlatformCtx<'_, '_>,
3145            state: &mut Self::State,
3146            _old: &Self::Props,
3147            new: &Self::Props,
3148        ) -> Result<(), NativeWidgetError> {
3149            ctx.record(format!("legacy update {state} '{}'", new.text));
3150            Ok(())
3151        }
3152
3153        fn dispose(
3154            ctx: &mut PlatformCtx<'_, '_>,
3155            state: Self::State,
3156        ) -> Result<(), NativeWidgetError> {
3157            ctx.record(format!("legacy dispose {state}"));
3158            Ok(())
3159        }
3160    }
3161
3162    #[test]
3163    fn a_public_component_and_an_internal_widget_share_one_dispatch_table() {
3164        // The bridge's actual claim (module doc): a public `NativeComponent`
3165        // is not a second runtime beside the built-in controls' — it is the same
3166        // registry, the same diff gate, the same disposal path, dispatched by
3167        // kind. Both families live side by side here, in one runtime, with no
3168        // cross-talk.
3169        let log = Rc::new(RefCell::new(Vec::new()));
3170        assert!(register_component::<Gauge>(GAUGE_KIND));
3171        assert!(
3172            with_runtime(|runtime| runtime.register_if_free::<LegacyControl>(LEGACY_KIND))
3173                .expect("the thread's runtime")
3174        );
3175
3176        let component = mount(10, Gauge::new(&log, "g"), props("Load", 1));
3177        let legacy = crate::runtime::with_identity(LEGACY_KIND, 11, "\"text\":\"Save\"");
3178        let mut calls = Vec::new();
3179        {
3180            let mut ctx = PlatformCtx::new(&mut calls);
3181            with_runtime(|runtime| {
3182                runtime.create(&mut ctx, &component).unwrap();
3183                runtime.create(&mut ctx, &legacy).unwrap();
3184                assert_eq!(runtime.live_count(), 2);
3185
3186                // Each one's diff gate answers for its own props only.
3187                assert_eq!(
3188                    runtime.update_params(&mut ctx, &legacy).unwrap(),
3189                    UpdateOutcome::Unchanged
3190                );
3191                let changed = mount(10, Gauge::new(&log, "g"), props("Load", 2));
3192                assert_eq!(
3193                    runtime.update_params(&mut ctx, &changed).unwrap(),
3194                    UpdateOutcome::Applied
3195                );
3196
3197                assert_eq!(runtime.dispose_slot(&mut ctx, 10), DisposeOutcome::Disposed);
3198                assert_eq!(runtime.dispose_slot(&mut ctx, 11), DisposeOutcome::Disposed);
3199                assert_eq!(runtime.live_count(), 0, "the shared leak bar returns to 0");
3200            })
3201            .expect("the thread's runtime");
3202        }
3203        forget(10);
3204
3205        let kinds: Vec<&str> = calls
3206            .iter()
3207            .map(|call| call.split(' ').next().unwrap())
3208            .collect();
3209        assert_eq!(
3210            kinds,
3211            vec![
3212                "gauge",
3213                "attachListener",
3214                "legacy",
3215                "gauge",
3216                "gauge",
3217                "legacy"
3218            ],
3219            "each command reached its own kind's impl and nobody else's: {calls:?}"
3220        );
3221    }
3222
3223    #[test]
3224    fn the_staging_table_is_reaped_by_forget_and_strands_nothing() {
3225        // The same leak shape, one table over: `forget` (the mounting
3226        // widget's `on_cleanup`) is the bound, not disposal — Android's
3227        // production dispose resolves by view identity and never names a slot.
3228        let log = Rc::new(RefCell::new(Vec::new()));
3229        register_component::<Gauge>(GAUGE_KIND);
3230
3231        let params = mount(2, Gauge::new(&log, "only"), props("Mem", 1));
3232        assert_eq!(staged_count(), 1);
3233        assert_eq!(Rc::strong_count(&log), 2, "the staging table holds it");
3234
3235        let mut calls = Vec::new();
3236        {
3237            let mut ctx = PlatformCtx::new(&mut calls);
3238            with_runtime(|runtime| {
3239                runtime.create(&mut ctx, &params).unwrap();
3240                // The identity-resolved dispose the differ actually emits.
3241                let identity = runtime.instance(2).unwrap().view().identity;
3242                let (slot, instance) = runtime
3243                    .take_matching(|view| view.identity == identity)
3244                    .expect("the live view");
3245                assert_eq!(slot, 2);
3246                instance.dispose(&mut ctx).unwrap();
3247                assert_eq!(runtime.live_count(), 0);
3248            })
3249            .expect("the thread's runtime");
3250        }
3251        assert_eq!(
3252            staged_count(),
3253            1,
3254            "disposal alone leaves the staged entry — the same leak shape as above"
3255        );
3256
3257        forget(2);
3258        assert_eq!(staged_count(), 0);
3259        assert_eq!(
3260            Rc::strong_count(&log),
3261            1,
3262            "the component value was dropped, not merely unreachable"
3263        );
3264        // Idempotent.
3265        forget(2);
3266        assert_eq!(staged_count(), 0);
3267    }
3268
3269    #[test]
3270    fn publish_bumps_the_generation_only_when_the_props_change() {
3271        // The wire-visible change signal: identical props must leave
3272        // `params_json` byte-identical, or the differ would emit an
3273        // `UpdateParams` for every rebuild and forfeit the whole diff gate.
3274        let log = Rc::new(RefCell::new(Vec::new()));
3275        let first = mount(3, Gauge::new(&log, "a"), props("Disk", 1));
3276        let same = mount(3, Gauge::new(&log, "b"), props("Disk", 1));
3277        let changed = mount(3, Gauge::new(&log, "c"), props("Disk", 2));
3278
3279        assert_eq!(
3280            first, same,
3281            "an unchanged publish changes nothing on the wire"
3282        );
3283        assert_ne!(same, changed);
3284        assert!(changed.contains(PROPS_GENERATION_KEY));
3285
3286        // Theme ladder L1: `component_params` also carries the app's active
3287        // brightness directly on the wire, not through the staged/diffed
3288        // props above — so an unchanged republish with only the brightness
3289        // flipped must still differ, and the flag round-trips exactly the
3290        // way `crate::appkit::theme`'s/`crate::apple::theme`'s shared
3291        // `brightness_is_dark` reads it back (`Params::flag(DARK)`, the same
3292        // call both make).
3293        let generation = publish(3, Rc::new(Gauge::new(&log, "c")), props("Disk", 2));
3294        let light = component_params(GAUGE_KIND, 3, generation, false);
3295        let dark = component_params(GAUGE_KIND, 3, generation, true);
3296        assert_ne!(
3297            light, dark,
3298            "an unchanged props republish with a flipped brightness must still change the wire"
3299        );
3300        assert_eq!(Params::new(&light).flag(DARK), Some(false));
3301        assert_eq!(Params::new(&dark).flag(DARK), Some(true));
3302
3303        forget(3);
3304        // A fresh mount after teardown starts over at generation 0.
3305        assert_eq!(mount(3, Gauge::new(&log, "d"), props("Disk", 2)), first);
3306    }
3307
3308    #[test]
3309    fn registration_is_first_wins_and_never_shadows_a_registered_kind() {
3310        assert!(register_component::<Gauge>(GAUGE_KIND));
3311        assert!(
3312            !register_component::<Gauge>(GAUGE_KIND),
3313            "a second registration is refused, not silently replaced"
3314        );
3315    }
3316
3317    #[test]
3318    fn a_slot_with_no_published_props_fails_instead_of_dispatching() {
3319        register_component::<Gauge>(GAUGE_KIND);
3320        // Params for a slot whose staged entry was already reaped — the
3321        // replay-after-teardown case.
3322        let params = component_params(GAUGE_KIND, 4, 0, false);
3323        let mut calls = Vec::new();
3324        let mut ctx = PlatformCtx::new(&mut calls);
3325
3326        let error = with_runtime(|runtime| runtime.create(&mut ctx, &params))
3327            .expect("the thread's runtime")
3328            .expect_err("nothing was published for slot 4");
3329
3330        assert!(matches!(error, NativeWidgetError::Params(_)), "{error:?}");
3331        assert!(calls.is_empty(), "nothing crossed the boundary");
3332    }
3333
3334    #[test]
3335    fn a_component_that_builds_no_view_fails_its_slot_with_the_latched_error() {
3336        let log = Rc::new(RefCell::new(Vec::new()));
3337        register_component::<Gauge>(GAUGE_KIND);
3338        let params = mount(5, Gauge::new(&log, "doomed"), props(FAIL_CREATE, 0));
3339
3340        let mut calls = Vec::new();
3341        let mut ctx = PlatformCtx::new(&mut calls);
3342        let error = with_runtime(|runtime| {
3343            let outcome = runtime.create(&mut ctx, &params);
3344            assert_eq!(runtime.live_count(), 0, "no instance was retained");
3345            outcome
3346        })
3347        .expect("the thread's runtime")
3348        .expect_err("the component answered None");
3349
3350        assert!(
3351            matches!(&error, NativeWidgetError::Platform(message)
3352                if message.contains("no native view today")),
3353            "the latched error names the failure, not a generic one: {error:?}"
3354        );
3355        forget(5);
3356    }
3357
3358    #[test]
3359    fn an_update_that_latches_keeps_the_diff_baseline_so_the_change_retries() {
3360        let log = Rc::new(RefCell::new(Vec::new()));
3361        register_component::<Gauge>(GAUGE_KIND);
3362        let params = mount(6, Gauge::new(&log, "g"), props("Net", 1));
3363
3364        let mut calls = Vec::new();
3365        {
3366            let mut ctx = PlatformCtx::new(&mut calls);
3367            with_runtime(|runtime| {
3368                runtime.create(&mut ctx, &params).unwrap();
3369
3370                let failing = mount(6, Gauge::new(&log, "g"), props(FAIL_UPDATE, 2));
3371                assert!(runtime.update_params(&mut ctx, &failing).is_err());
3372
3373                // The baseline is still the props `create` applied, so a
3374                // *different* change still reads as changed rather than being
3375                // swallowed by a baseline the failed update advanced.
3376                let recovered = mount(6, Gauge::new(&log, "g"), props("Net", 3));
3377                assert_eq!(
3378                    runtime.update_params(&mut ctx, &recovered).unwrap(),
3379                    UpdateOutcome::Applied
3380                );
3381            })
3382            .expect("the thread's runtime");
3383        }
3384
3385        assert_eq!(
3386            *log.borrow(),
3387            vec!["g create 'Net'".to_string(), "g update 1 -> 3".to_string()],
3388            "the failed update applied nothing and left the baseline at create's props"
3389        );
3390        forget(6);
3391    }
3392
3393    #[test]
3394    fn a_failed_update_retries_even_when_the_app_republishes_identical_props() {
3395        // Keeping the diff baseline (the test above) is only half
3396        // a retry: the runtime cannot re-apply anything it is never handed
3397        // again, and it is handed props only when the differ emits an
3398        // `UpdateParams`, which it does only when the wire changes. An app
3399        // whose props settled — the ordinary shape of a value that failed to
3400        // apply once and is simply still true — would republish a
3401        // byte-identical wire forever and the failed change would never be
3402        // retried at all.
3403        let log = Rc::new(RefCell::new(Vec::new()));
3404        register_component::<Gauge>(GAUGE_KIND);
3405        let created = mount(60, Gauge::new(&log, "g"), props("Net", 1));
3406
3407        let mut calls = Vec::new();
3408        {
3409            let mut ctx = PlatformCtx::new(&mut calls);
3410            with_runtime(|runtime| {
3411                runtime.create(&mut ctx, &created).unwrap();
3412
3413                // The change the platform refuses on its first attempt.
3414                let failing = mount(60, Gauge::new(&log, "g"), props(FAIL_UPDATE_ONCE, 2));
3415                assert_ne!(failing, created, "changed props changed the wire");
3416                assert!(runtime.update_params(&mut ctx, &failing).is_err());
3417
3418                // The next rebuild publishes *exactly the same props again* —
3419                // and must still move the wire, or nothing will ever ask the
3420                // runtime to try again.
3421                let republished = mount(60, Gauge::new(&log, "g"), props(FAIL_UPDATE_ONCE, 2));
3422                assert_ne!(
3423                    republished, failing,
3424                    "a failed update must force the next publish to bump the props \
3425                     generation, identical props or not — otherwise the differ emits \
3426                     nothing and the change is lost for the process lifetime"
3427                );
3428                assert_eq!(
3429                    runtime.update_params(&mut ctx, &republished).unwrap(),
3430                    UpdateOutcome::Applied,
3431                    "the retry reached the component and applied"
3432                );
3433
3434                // One failure buys exactly one retry: with the change applied,
3435                // an unchanged rebuild is back to costing nothing.
3436                let settled = mount(60, Gauge::new(&log, "g"), props(FAIL_UPDATE_ONCE, 2));
3437                assert_eq!(
3438                    settled, republished,
3439                    "the retry mark is consumed by the bump it forced"
3440                );
3441                assert_eq!(
3442                    runtime.update_params(&mut ctx, &settled).unwrap(),
3443                    UpdateOutcome::Unchanged
3444                );
3445
3446                assert_eq!(runtime.dispose_slot(&mut ctx, 60), DisposeOutcome::Disposed);
3447            })
3448            .expect("the thread's runtime");
3449        }
3450        forget(60);
3451
3452        assert_eq!(
3453            *log.borrow(),
3454            vec![
3455                "g create 'Net'".to_string(),
3456                "g update failed".to_string(),
3457                "g update 1 -> 2".to_string(),
3458                "g dispose".to_string(),
3459            ],
3460            "the change was attempted, failed, retried against the ORIGINAL baseline \
3461             (1, not 2 — the failed attempt advanced nothing), and applied"
3462        );
3463    }
3464
3465    /// Drive `rebuilds` rebuilds that republish **byte-identical** props for
3466    /// `slot`, dispatching one `update_params` per rebuild whose wire actually
3467    /// moved — which is exactly what the platform-view differ does — and return
3468    /// the `params_json` each rebuild produced.
3469    ///
3470    /// The wire check is the whole point: the differ emits an `UpdateParams`
3471    /// only when a slot's `params_json` changes, so a rebuild whose params come
3472    /// back identical costs the runtime nothing at all. Calling
3473    /// `update_params` unconditionally would model a differ this repo does not
3474    /// have and would make the cap look ineffective (the runtime compares props
3475    /// against its own baseline, which a failed update never advances).
3476    fn republish_identical(
3477        runtime: &mut crate::runtime::NativeRuntime,
3478        ctx: &mut PlatformCtx<'_, '_>,
3479        slot: SlotId,
3480        log: &Rc<RefCell<Vec<String>>>,
3481        label: &str,
3482        value: i32,
3483        rebuilds: usize,
3484    ) -> Vec<String> {
3485        let mut wire = Vec::with_capacity(rebuilds);
3486        let mut previous: Option<String> = None;
3487        for _ in 0..rebuilds {
3488            let params = mount(slot, Gauge::new(log, "g"), props(label, value));
3489            if previous.as_deref() != Some(params.as_str()) {
3490                let _ = runtime.update_params(ctx, &params);
3491            }
3492            previous = Some(params.clone());
3493            wire.push(params);
3494        }
3495        wire
3496    }
3497
3498    #[test]
3499    fn a_permanently_failing_update_stops_retrying_at_the_cap() {
3500        // The retry mechanism above is unbounded on its own:
3501        // every failed dispatch re-marks the slot, the next rebuild's `publish`
3502        // bumps the generation for byte-identical props, the differ emits an
3503        // `UpdateParams`, the retry fails and re-marks — one FFI dispatch plus
3504        // one `log::warn!` per rebuild, forever, on the platform main thread.
3505        // On a screen that rebuilds every frame that is per-frame JNI traffic
3506        // and per-frame log volume, and it falsifies the crate's headline
3507        // "an unchanged rebuild costs zero FFI crossings" for the process
3508        // lifetime.
3509        //
3510        // So the budget caps it. What is asserted here is the *observable*
3511        // consequence: the wire stops moving, so the differ stops asking, so
3512        // the component stops being called.
3513        install_log_capture();
3514        let log = Rc::new(RefCell::new(Vec::new()));
3515        register_component::<Gauge>(GAUGE_KIND);
3516        let created = mount(62, Gauge::new(&log, "g"), props("Net", 1));
3517
3518        let mut calls = Vec::new();
3519        {
3520            let mut ctx = PlatformCtx::new(&mut calls);
3521            with_runtime(|runtime| {
3522                runtime.create(&mut ctx, &created).unwrap();
3523
3524                // Ten rebuilds of a screen whose props settled on a value the
3525                // platform refuses. Pre-cap, all ten would have dispatched.
3526                let wire =
3527                    republish_identical(runtime, &mut ctx, 62, &log, FAIL_UPDATE_ALWAYS, 2, 10);
3528
3529                // The first rebuild moved the wire because the props really
3530                // changed; the next two moved it because a failure re-marked
3531                // the slot. From the fourth on the budget is spent and the wire
3532                // is frozen — no `UpdateParams`, nothing dispatched, nothing
3533                // logged.
3534                assert_ne!(wire[0], created, "changed props changed the wire");
3535                assert_ne!(wire[1], wire[0], "failure 1 forced a retry bump");
3536                assert_ne!(wire[2], wire[1], "failure 2 forced a retry bump");
3537                for (index, params) in wire.iter().enumerate().skip(3) {
3538                    assert_eq!(
3539                        *params, wire[2],
3540                        "rebuild {index} must leave the wire untouched: three consecutive \
3541                         failures spend the budget, and an unchanged rebuild is back to \
3542                         costing zero FFI crossings"
3543                    );
3544                }
3545
3546                assert_eq!(runtime.dispose_slot(&mut ctx, 62), DisposeOutcome::Disposed);
3547            })
3548            .expect("the thread's runtime");
3549        }
3550        forget(62);
3551
3552        assert_eq!(
3553            *log.borrow(),
3554            vec![
3555                "g create 'Net'".to_string(),
3556                "g update refused".to_string(),
3557                "g update refused".to_string(),
3558                "g update refused".to_string(),
3559                "g dispose".to_string(),
3560            ],
3561            "the component was asked exactly RETRY_UPDATE_BUDGET (3) times across ten \
3562             rebuilds, then never again — the observed behaviour at the cap is *inert*, \
3563             not slower retries"
3564        );
3565        assert!(
3566            logged("no further retries will be scheduled"),
3567            "giving up is reported once, at warn — a slot that silently stops trying is \
3568             the other way to lose a native view"
3569        );
3570    }
3571
3572    #[test]
3573    fn a_successful_update_restores_the_whole_retry_budget() {
3574        // The cap counts *consecutive* failures, which is what keeps a flaky
3575        // platform from accumulating its way to inert over a long session: two
3576        // refusals, one success, and the next bad patch gets the full three
3577        // attempts again rather than the one it would have left.
3578        let log = Rc::new(RefCell::new(Vec::new()));
3579        register_component::<Gauge>(GAUGE_KIND);
3580        let created = mount(63, Gauge::new(&log, "g"), props("Net", 1));
3581
3582        let mut calls = Vec::new();
3583        {
3584            let mut ctx = PlatformCtx::new(&mut calls);
3585            with_runtime(|runtime| {
3586                runtime.create(&mut ctx, &created).unwrap();
3587
3588                // Two failures — one short of the cap.
3589                republish_identical(runtime, &mut ctx, 63, &log, FAIL_UPDATE_ALWAYS, 2, 2);
3590
3591                // A props change the component accepts. This is what clears the
3592                // count; the retry mark plays no part (changed props bump the
3593                // generation on their own).
3594                let recovered = mount(63, Gauge::new(&log, "g"), props("Net", 3));
3595                assert_eq!(
3596                    runtime.update_params(&mut ctx, &recovered).unwrap(),
3597                    UpdateOutcome::Applied
3598                );
3599
3600                // A fresh bad patch now gets three attempts, not one.
3601                republish_identical(runtime, &mut ctx, 63, &log, FAIL_UPDATE_ALWAYS, 6, 10);
3602
3603                assert_eq!(runtime.dispose_slot(&mut ctx, 63), DisposeOutcome::Disposed);
3604            })
3605            .expect("the thread's runtime");
3606        }
3607        forget(63);
3608
3609        assert_eq!(
3610            *log.borrow(),
3611            vec![
3612                "g create 'Net'".to_string(),
3613                "g update refused".to_string(),
3614                "g update refused".to_string(),
3615                "g update 1 -> 3".to_string(),
3616                "g update refused".to_string(),
3617                "g update refused".to_string(),
3618                "g update refused".to_string(),
3619                "g dispose".to_string(),
3620            ],
3621            "two refusals, a success that cleared the count, then a full budget of three"
3622        );
3623    }
3624
3625    #[test]
3626    fn a_later_error_inside_a_local_frame_is_logged_rather_than_swallowed() {
3627        // Two properties, pinned at once.
3628        //
3629        // The latch is first-wins, so the frame's error cannot *replace* the
3630        // root failure — but it must not vanish without a trace either, and it
3631        // would vanish twice over on Android if that arm handed the closure a
3632        // FRESH latch: `failed()` would read `false` inside a frame where this
3633        // arm (and iOS) read `true`, and the merge back would be a second,
3634        // silent first-wins drop on top of the latch's own.
3635        //
3636        // Android's fresh *context* is structurally forced (a pushed frame's
3637        // references carry other lifetimes); what it carries is this arm's
3638        // semantics, which is what this test pins.
3639        const ROOT: &str = "m-01 root failure";
3640        const INSIDE_FRAME: &str = "m-01 failure raised inside the frame";
3641        install_log_capture();
3642
3643        let mut calls = Vec::new();
3644        let mut ctx = PlatformCtx::new(&mut calls);
3645        let mut cx = ComponentCtx::new(&mut ctx, 0);
3646
3647        cx.report_error(ROOT);
3648        assert!(cx.failed());
3649
3650        let value = cx.with_local_frame(4, |inner| {
3651            assert!(
3652                inner.failed(),
3653                "a frame carries its caller's latch — the property Android used to \
3654                 answer `false` to"
3655            );
3656            inner.report_error(INSIDE_FRAME);
3657            None::<u64>
3658        });
3659
3660        assert!(value.is_none());
3661        let error = cx.into_parts().0.expect("the root failure still reports");
3662        assert!(
3663            matches!(&error, NativeWidgetError::Platform(message) if message == ROOT),
3664            "the first error stays the reported one: {error:?}"
3665        );
3666        assert!(
3667            logged(INSIDE_FRAME),
3668            "the frame's error must still reach the log — a swallowed platform \
3669             failure is the defect, not the first-wins report"
3670        );
3671    }
3672
3673    #[test]
3674    fn every_dispatch_runs_the_boundary_exception_guard() {
3675        // What a host can prove is the wiring: ALL FOUR dispatches run the
3676        // guard. `create`, `update` and `dispose` reach it through their
3677        // context; `on_event` carries none — `NativeWidget::on_event` takes
3678        // `(state, event)` and nothing else — so it reaches a JNI env through
3679        // the process VM instead (`guard_pending_exception_off_context`).
3680        //
3681        // Four, not three: "on_event has no context to guard through" is not a
3682        // licence to skip it. The export runs `debug_assert_main_thread` and
3683        // further JNI after `runtime.on_event` returns, so a leftover exception
3684        // is *ours* to trip over, and an attached listener's event reaches
3685        // the trait method on every platform arm (module doc's *Listener
3686        // attachment*). A reachable UB path guarded on three of four
3687        // dispatches is not a resting place.
3688        //
3689        // Whether the guard's JNI check actually *finds* a pending exception is
3690        // Android-only and unrunnable here (no embedded-JVM harness in this
3691        // repo); the host arm counts instead.
3692        let log = Rc::new(RefCell::new(Vec::new()));
3693        register_component::<Gauge>(GAUGE_KIND);
3694        let before = dispatch_guard_count();
3695
3696        let params = mount(61, Gauge::new(&log, "g"), props("Net", 1));
3697        let mut calls = Vec::new();
3698        {
3699            let mut ctx = PlatformCtx::new(&mut calls);
3700            with_runtime(|runtime| {
3701                runtime.create(&mut ctx, &params).unwrap();
3702                let changed = mount(61, Gauge::new(&log, "g"), props("Net", 2));
3703                assert_eq!(
3704                    runtime.update_params(&mut ctx, &changed).unwrap(),
3705                    UpdateOutcome::Applied
3706                );
3707                runtime.on_event(61, WireEvent { kind: 1, detail: 0 });
3708                assert_eq!(runtime.dispose_slot(&mut ctx, 61), DisposeOutcome::Disposed);
3709            })
3710            .expect("the thread's runtime");
3711        }
3712        forget(61);
3713
3714        assert_eq!(
3715            dispatch_guard_count() - before,
3716            4,
3717            "create, update, dispose AND on_event are guarded — the fourth is \
3718             the one this test used to license the absence of"
3719        );
3720    }
3721
3722    // --- The four kind/type mismatch cases -----------------------------------
3723
3724    /// A component that shares [`Gauge`]'s `Props` type but not its identity —
3725    /// the case where the props downcast *succeeds* and the mismatch surfaces
3726    /// one step later, at the component downcast.
3727    struct Twin;
3728
3729    impl NativeComponent for Twin {
3730        type Props = GaugeProps;
3731        type State = u64;
3732
3733        fn create(
3734            &self,
3735            ctx: &mut ComponentCtx<'_, '_, '_>,
3736            _props: &Self::Props,
3737        ) -> Option<(NativeRoot, Self::State)> {
3738            let identity = next_identity();
3739            Some((ctx.root(identity)?, identity))
3740        }
3741
3742        fn update(
3743            &self,
3744            _ctx: &mut ComponentCtx<'_, '_, '_>,
3745            _state: &mut Self::State,
3746            _old: &Self::Props,
3747            _new: &Self::Props,
3748        ) {
3749        }
3750    }
3751
3752    #[test]
3753    fn case_a_a_kind_nothing_registered_fails_with_unknown_control() {
3754        // The only one of the four that surfaces as `UnknownControl`, raised by
3755        // the runtime's own dispatch before any decode runs.
3756        let log = Rc::new(RefCell::new(Vec::new()));
3757        let generation = publish(70, Rc::new(Gauge::new(&log, "g")), props("CPU", 1));
3758        let params = component_params("test-never-registered", 70, generation, false);
3759
3760        let mut calls = Vec::new();
3761        let mut ctx = PlatformCtx::new(&mut calls);
3762        let error = with_runtime(|runtime| {
3763            let outcome = runtime.create(&mut ctx, &params);
3764            assert_eq!(runtime.live_count(), 0, "fails closed: no instance");
3765            outcome
3766        })
3767        .expect("the thread's runtime")
3768        .expect_err("nothing is registered under that kind");
3769
3770        assert!(
3771            matches!(&error, NativeWidgetError::UnknownControl(kind)
3772                if kind == "test-never-registered"),
3773            "{error:?}"
3774        );
3775        assert!(calls.is_empty(), "nothing crossed the boundary");
3776        forget(70);
3777    }
3778
3779    #[test]
3780    fn case_c_a_kind_registered_to_another_component_fails_at_decode() {
3781        // Registered to `Gauge`, mounted with `Card`: the staged props are
3782        // `CardProps`, so `Bridge::<Gauge>::decode_props` cannot downcast them.
3783        // This is a `Params` error, not `UnknownControl`, and its message names
3784        // the mismatch rather than reading like an ordinary reaped entry.
3785        let log = Rc::new(RefCell::new(Vec::new()));
3786        register_component::<Gauge>(GAUGE_KIND);
3787        // A `Card` staged under the `Gauge` kind — the typo a `kind` string
3788        // passed twice invites, and one nothing type-checks.
3789        let generation = publish(
3790            71,
3791            Rc::new(Card {
3792                children: 1,
3793                fail_after: None,
3794                log: Rc::clone(&log),
3795            }),
3796            CardProps {
3797                title: "wrong kind".to_string(),
3798            },
3799        );
3800        let params = component_params(GAUGE_KIND, 71, generation, false);
3801
3802        let mut calls = Vec::new();
3803        let mut ctx = PlatformCtx::new(&mut calls);
3804        let error = with_runtime(|runtime| {
3805            let outcome = runtime.create(&mut ctx, &params);
3806            assert_eq!(runtime.live_count(), 0, "fails closed: no instance");
3807            outcome
3808        })
3809        .expect("the thread's runtime")
3810        .expect_err("the staged props are another component's");
3811
3812        assert!(
3813            matches!(&error, NativeWidgetError::Params(message)
3814                if message.contains("staged a different component's props")
3815                    && message.contains(GAUGE_KIND)),
3816            "the message must name the wiring bug, not read as a reaped entry: {error:?}"
3817        );
3818        assert!(calls.is_empty(), "nothing crossed the boundary");
3819        forget(71);
3820    }
3821
3822    #[test]
3823    fn case_c_two_components_sharing_a_props_type_fail_one_step_later() {
3824        // Registered to `Gauge`, mounted with `Twin`, whose `Props` type IS
3825        // `GaugeProps`: the props downcast succeeds, so the mismatch surfaces
3826        // in `create` instead — still a `Params` error, still fail-closed, but
3827        // a different message and a different call.
3828        register_component::<Gauge>(GAUGE_KIND);
3829        let generation = publish(72, Rc::new(Twin), props("CPU", 1));
3830        let params = component_params(GAUGE_KIND, 72, generation, false);
3831
3832        let mut calls = Vec::new();
3833        let mut ctx = PlatformCtx::new(&mut calls);
3834        let error = with_runtime(|runtime| {
3835            let outcome = runtime.create(&mut ctx, &params);
3836            assert_eq!(runtime.live_count(), 0, "fails closed: no instance");
3837            outcome
3838        })
3839        .expect("the thread's runtime")
3840        .expect_err("the staged component is a `Twin`, not a `Gauge`");
3841
3842        assert!(
3843            matches!(&error, NativeWidgetError::Params(message)
3844                if message.contains("staged a different component's component")),
3845            "{error:?}"
3846        );
3847        assert!(calls.is_empty(), "nothing crossed the boundary");
3848        forget(72);
3849    }
3850
3851    #[test]
3852    fn case_d_a_second_component_under_one_kind_is_refused_and_never_shadows() {
3853        // Two components, one kind: registration is first-wins, so the second
3854        // is refused with a warning and the incumbent keeps serving the kind.
3855        // Mounting the loser under it then reduces to case (c) — proven here
3856        // rather than assumed, since "refused" would be worth little if the
3857        // loser's slots quietly ran the winner's code.
3858        install_log_capture();
3859        assert!(register_component::<Gauge>(GAUGE_KIND));
3860        assert!(
3861            !register_component::<Twin>(GAUGE_KIND),
3862            "the second component under one kind is refused"
3863        );
3864        assert!(
3865            logged("is already registered"),
3866            "and says so — a silent refusal is how a component 'never appears'"
3867        );
3868
3869        let generation = publish(73, Rc::new(Twin), props("CPU", 1));
3870        let params = component_params(GAUGE_KIND, 73, generation, false);
3871        let mut calls = Vec::new();
3872        let mut ctx = PlatformCtx::new(&mut calls);
3873        let error = with_runtime(|runtime| runtime.create(&mut ctx, &params))
3874            .expect("the thread's runtime")
3875            .expect_err("the loser's slot must not run the incumbent's code");
3876
3877        assert!(matches!(&error, NativeWidgetError::Params(_)), "{error:?}");
3878        forget(73);
3879    }
3880
3881    // --- A component owns its own native subtree -----------------------------
3882
3883    /// A **composite**: one component, one slot, a parent view with N native
3884    /// children under it — the card-with-an-image-and-two-buttons shape the
3885    /// public trait exists to make shippable without leaking three slots to
3886    /// the consuming app.
3887    struct Card {
3888        /// How many children [`NativeComponent::create`] builds.
3889        children: usize,
3890        /// Stop after this many children and fail the slot — the
3891        /// half-built-subtree path, which must strand nothing.
3892        fail_after: Option<usize>,
3893        log: Rc<RefCell<Vec<String>>>,
3894    }
3895
3896    #[derive(Clone, Debug, PartialEq)]
3897    struct CardProps {
3898        title: String,
3899    }
3900
3901    struct CardState {
3902        root: u64,
3903        /// The children this component kept in order to drive them later.
3904        /// Dropping `State` — which the runtime does immediately after
3905        /// `dispose` returns — releases every one of them (module doc's
3906        /// *Teardown*).
3907        children: Vec<NativeChild>,
3908    }
3909
3910    const CARD_KIND: &str = "test-card";
3911
3912    impl NativeComponent for Card {
3913        type Props = CardProps;
3914        type State = CardState;
3915
3916        fn create(
3917            &self,
3918            ctx: &mut ComponentCtx<'_, '_, '_>,
3919            props: &Self::Props,
3920        ) -> Option<(NativeRoot, Self::State)> {
3921            let root = next_identity();
3922            ctx.record(format!("card create {root} '{}'", props.title));
3923            // Every child is built inside ONE local frame — the discipline
3924            // proved on device with fifty of them (module doc).
3925            let children = ctx.with_local_frame(self.children + 2, |ctx| {
3926                let mut retained = Vec::with_capacity(self.children);
3927                for index in 0..self.children {
3928                    if self.fail_after == Some(index) {
3929                        ctx.report_error("card: the platform ran out of views");
3930                        return None;
3931                    }
3932                    let child = next_identity();
3933                    ctx.add_child(root, child)?;
3934                    // Kept because this component drives its children later;
3935                    // a child it never touched again would need no handle.
3936                    retained.push(ctx.retain_child(child)?);
3937                }
3938                Some(retained)
3939            })?;
3940            self.log
3941                .borrow_mut()
3942                .push(format!("card create {} children", children.len()));
3943            Some((ctx.root(root)?, CardState { root, children }))
3944        }
3945
3946        fn update(
3947            &self,
3948            ctx: &mut ComponentCtx<'_, '_, '_>,
3949            state: &mut Self::State,
3950            _old: &Self::Props,
3951            new: &Self::Props,
3952        ) {
3953            // A real component would drive individual children here, off the
3954            // handles `State` retained.
3955            ctx.record(format!(
3956                "card update {} '{}' over {} children",
3957                state.root,
3958                new.title,
3959                state.children.len()
3960            ));
3961        }
3962
3963        fn dispose(&self, ctx: &mut ComponentCtx<'_, '_, '_>, state: Self::State) {
3964            ctx.record(format!(
3965                "card dispose {} with {} children",
3966                state.root,
3967                state.children.len()
3968            ));
3969            self.log.borrow_mut().push("card dispose".to_string());
3970            // `state` — every retained child with it — drops as this returns.
3971        }
3972    }
3973
3974    /// Publish `card` for `slot` and return the `params_json` its
3975    /// `platform_view` would carry.
3976    fn mount_card(slot: SlotId, card: Card, title: &str) -> String {
3977        let generation = publish(
3978            slot,
3979            Rc::new(card),
3980            CardProps {
3981                title: title.to_string(),
3982            },
3983        );
3984        component_params(CARD_KIND, slot, generation, false)
3985    }
3986
3987    #[test]
3988    fn a_component_builds_a_native_subtree_and_releases_every_child() {
3989        // The same on-device stress count that motivated this design: 50
3990        // children in ONE slot, 52 global refs at peak → 0 after the dispose
3991        // cycle.
3992        const CHILDREN: usize = 50;
3993
3994        assert_eq!(live_child_count(), 0, "this test thread starts clean");
3995        assert!(register_component::<Card>(CARD_KIND));
3996
3997        let log = Rc::new(RefCell::new(Vec::new()));
3998        let params = mount_card(
3999            7,
4000            Card {
4001                children: CHILDREN,
4002                fail_after: None,
4003                log: Rc::clone(&log),
4004            },
4005            "Now playing",
4006        );
4007
4008        let mut calls = Vec::new();
4009        {
4010            let mut ctx = PlatformCtx::new(&mut calls);
4011            with_runtime(|runtime| {
4012                assert_eq!(runtime.create(&mut ctx, &params).unwrap(), 7);
4013                assert_eq!(
4014                    runtime.live_count(),
4015                    1,
4016                    "N children ship as ONE slot — that is the whole point"
4017                );
4018                assert_eq!(
4019                    live_child_count(),
4020                    CHILDREN,
4021                    "every child is retained while the component is live"
4022                );
4023
4024                let changed = mount_card(
4025                    7,
4026                    Card {
4027                        children: CHILDREN,
4028                        fail_after: None,
4029                        log: Rc::clone(&log),
4030                    },
4031                    "Up next",
4032                );
4033                assert_eq!(
4034                    runtime.update_params(&mut ctx, &changed).unwrap(),
4035                    UpdateOutcome::Applied,
4036                    "a subtree component diffs exactly like a leaf one"
4037                );
4038
4039                assert_eq!(runtime.dispose_slot(&mut ctx, 7), DisposeOutcome::Disposed);
4040                assert_eq!(runtime.live_count(), 0);
4041            })
4042            .expect("the thread's runtime");
4043        }
4044        forget(7);
4045
4046        // The teardown bar, counted rather than assumed: both leak shapes
4047        // this design guards against looked fine until something counted.
4048        assert_eq!(
4049            live_child_count(),
4050            0,
4051            "every child was released with its parent"
4052        );
4053        assert_eq!(staged_count(), 0);
4054
4055        // …and the plan the component actually executed.
4056        assert!(calls[0].starts_with("card create"), "{calls:?}");
4057        assert_eq!(calls[1], format!("pushLocalFrame {}", CHILDREN + 2));
4058        let root = calls[0]
4059            .split(' ')
4060            .nth(2)
4061            .expect("the root identity")
4062            .to_string();
4063        let attached: Vec<&String> = calls
4064            .iter()
4065            .filter(|call| call.starts_with("addChild "))
4066            .collect();
4067        assert_eq!(attached.len(), CHILDREN, "one addChild per child");
4068        assert!(
4069            attached
4070                .iter()
4071                .all(|call| call.starts_with(&format!("addChild {root} <- "))),
4072            "every child went under the component's own root: {attached:?}"
4073        );
4074        assert_eq!(
4075            calls
4076                .iter()
4077                .filter(|call| call.starts_with("retainChild "))
4078                .count(),
4079            CHILDREN
4080        );
4081
4082        let first_add = calls
4083            .iter()
4084            .position(|call| call.starts_with("addChild "))
4085            .expect("an addChild");
4086        let last_add = calls
4087            .iter()
4088            .rposition(|call| call.starts_with("addChild "))
4089            .expect("an addChild");
4090        let popped = calls
4091            .iter()
4092            .position(|call| call == "popLocalFrame")
4093            .expect("the frame pops");
4094        assert!(
4095            first_add > 1 && last_add < popped,
4096            "the whole subtree build ran inside ONE local frame: {calls:?}"
4097        );
4098        assert!(calls[popped + 1].contains("over 50 children"), "{calls:?}");
4099        assert!(
4100            calls.last().unwrap().starts_with("card dispose"),
4101            "{calls:?}"
4102        );
4103        assert_eq!(
4104            *log.borrow(),
4105            vec![
4106                "card create 50 children".to_string(),
4107                "card dispose".to_string()
4108            ],
4109        );
4110    }
4111
4112    #[test]
4113    fn a_half_built_subtree_strands_no_children() {
4114        // The failure path of the same discipline: a component that gives up
4115        // partway through its subtree must release what it already retained,
4116        // and leave no live instance behind either.
4117        assert_eq!(live_child_count(), 0);
4118        register_component::<Card>(CARD_KIND);
4119
4120        let log = Rc::new(RefCell::new(Vec::new()));
4121        let params = mount_card(
4122            8,
4123            Card {
4124                children: 20,
4125                fail_after: Some(12),
4126                log: Rc::clone(&log),
4127            },
4128            "doomed",
4129        );
4130
4131        let mut calls = Vec::new();
4132        let mut ctx = PlatformCtx::new(&mut calls);
4133        let error = with_runtime(|runtime| {
4134            let outcome = runtime.create(&mut ctx, &params);
4135            assert_eq!(runtime.live_count(), 0, "no instance was retained");
4136            outcome
4137        })
4138        .expect("the thread's runtime")
4139        .expect_err("the component gave up on its subtree");
4140
4141        assert!(
4142            matches!(&error, NativeWidgetError::Platform(message)
4143                if message.contains("ran out of views")),
4144            "the latched error names the failure: {error:?}"
4145        );
4146        assert_eq!(
4147            live_child_count(),
4148            0,
4149            "the twelve children built before the failure were released, not stranded"
4150        );
4151        assert!(
4152            calls.iter().any(|call| call == "popLocalFrame"),
4153            "the local frame is popped on the failure path too: {calls:?}"
4154        );
4155        forget(8);
4156    }
4157
4158    // --- listener attachment (the retired display-only gap) -----------------
4159
4160    thread_local! {
4161        /// How many times [`Relay::on_event`] actually ran on this thread — the
4162        /// probe that tells "the bridge's gate dropped it" apart from "the
4163        /// component was asked and answered `None`".
4164        static RELAYED: Cell<usize> = const { Cell::new(0) };
4165    }
4166
4167    fn relayed() -> usize {
4168        RELAYED.with(Cell::get)
4169    }
4170
4171    /// The [`GaugeProps::label`] that makes [`Relay::create`] attach its click
4172    /// listener; any other label attaches nothing.
4173    const LISTEN: &str = "listen";
4174
4175    const RELAY_KIND: &str = "test-relay";
4176
4177    /// A component whose answer is distinguishable from a pass-through: it
4178    /// attaches a click listener to its root (when told to) and answers every
4179    /// click with a `Toggled` carrying whether its last-applied props' value is
4180    /// positive — so a test sees the *component's* answer, built from the
4181    /// props the bridge handed it, not the event that arrived.
4182    struct Relay;
4183
4184    impl NativeComponent for Relay {
4185        type Props = GaugeProps;
4186        type State = (u64, Option<ListenerHandle>);
4187
4188        fn create(
4189            &self,
4190            ctx: &mut ComponentCtx<'_, '_, '_>,
4191            props: &Self::Props,
4192        ) -> Option<(NativeRoot, Self::State)> {
4193            let identity = next_identity();
4194            let listener = if props.label == LISTEN {
4195                Some(ctx.attach_listener(identity, ListenerKinds::CLICK)?)
4196            } else {
4197                None
4198            };
4199            Some((ctx.root(identity)?, (identity, listener)))
4200        }
4201
4202        fn update(
4203            &self,
4204            _ctx: &mut ComponentCtx<'_, '_, '_>,
4205            _state: &mut Self::State,
4206            _old: &Self::Props,
4207            _new: &Self::Props,
4208        ) {
4209        }
4210
4211        fn on_event(
4212            &self,
4213            _state: &mut Self::State,
4214            props: &Self::Props,
4215            event: NativeEvent,
4216        ) -> Option<NativeEvent> {
4217            RELAYED.with(|count| count.set(count.get() + 1));
4218            event
4219                .is_click()
4220                .then(|| NativeEvent::from_payload(EventPayload::Toggled(props.value > 0)))
4221        }
4222    }
4223
4224    fn click() -> WireEvent {
4225        WireEvent {
4226            kind: EVENT_KIND_CLICK,
4227            detail: 0,
4228        }
4229    }
4230
4231    #[test]
4232    fn bridge_on_event_returns_the_component_answer_only_for_an_attached_family() {
4233        // The deliverable this test pins: `Bridge::on_event` used to answer
4234        // `None` unconditionally; now a routed event returns whatever the
4235        // component answers, and a slot without an attached listener still
4236        // answers `None` — without the component ever being asked.
4237        let mut calls = Vec::new();
4238        let mut ctx = PlatformCtx::new(&mut calls);
4239
4240        publish(90, Rc::new(Relay), props(LISTEN, 5));
4241        let (_view, mut listening) = Bridge::<Relay>::create(
4242            &mut ctx,
4243            &BridgeProps {
4244                slot: 90,
4245                props: props(LISTEN, 5),
4246            },
4247        )
4248        .expect("a listening relay builds");
4249        assert_eq!(live_listener_count(), 1, "create attached one listener");
4250
4251        let before = relayed();
4252        assert_eq!(
4253            Bridge::<Relay>::on_event(&mut listening, click()),
4254            Some(EventPayload::Toggled(true)),
4255            "a click on an attached CLICK listener returns the component's own \
4256             answer (a Toggled built from its props), not the click that arrived"
4257        );
4258        assert_eq!(relayed(), before + 1);
4259
4260        // A family this slot never attached is gated at the bridge.
4261        assert_eq!(
4262            Bridge::<Relay>::on_event(
4263                &mut listening,
4264                WireEvent {
4265                    kind: EVENT_KIND_VALUE_CHANGED,
4266                    detail: pack_value_changed(3, true),
4267                },
4268            ),
4269            None
4270        );
4271        assert_eq!(relayed(), before + 1, "the gate never asked the component");
4272
4273        // A slot that attached nothing answers `None` for every kind.
4274        publish(91, Rc::new(Relay), props("quiet", 5));
4275        let (_view, mut quiet) = Bridge::<Relay>::create(
4276            &mut ctx,
4277            &BridgeProps {
4278                slot: 91,
4279                props: props("quiet", 5),
4280            },
4281        )
4282        .expect("a quiet relay builds");
4283        assert_eq!(Bridge::<Relay>::on_event(&mut quiet, click()), None);
4284        assert_eq!(
4285            relayed(),
4286            before + 1,
4287            "an unattached slot never reaches the component — the misroute a \
4288             fabricated slot id used to be able to cause stops at the bridge"
4289        );
4290
4291        drop((listening, quiet));
4292        assert_eq!(
4293            calls
4294                .iter()
4295                .filter(|call| call.starts_with("attachListener "))
4296                .count(),
4297            1,
4298            "only the listening relay attached: {calls:?}"
4299        );
4300        assert_eq!(
4301            live_listener_count(),
4302            0,
4303            "the handle is released with the state"
4304        );
4305        forget(90);
4306        forget(91);
4307    }
4308
4309    #[test]
4310    fn an_attached_listener_reaches_the_slot_callback_through_the_runtime() {
4311        // End to end below the app hook: the platform listener's
4312        // `runtime.on_event(slot, ..)` → the bridge → the component → its
4313        // answer handed to the slot's registered callback, the table
4314        // `crate::api::mount`'s `.on_event` hook registers into.
4315        use std::sync::Arc;
4316
4317        assert!(register_component::<Relay>(RELAY_KIND));
4318        let generation = publish(92, Rc::new(Relay), props(LISTEN, 0));
4319        let params = component_params(RELAY_KIND, 92, generation, false);
4320        let fired: Arc<Mutex<Vec<EventPayload>>> = Arc::new(Mutex::new(Vec::new()));
4321        let recorder = Arc::clone(&fired);
4322
4323        let mut calls = Vec::new();
4324        {
4325            let mut ctx = PlatformCtx::new(&mut calls);
4326            with_runtime(|runtime| {
4327                assert!(runtime.set_callback(
4328                    92,
4329                    Arc::new(move |payload| recorder.lock().unwrap().push(payload)),
4330                ));
4331                runtime.create(&mut ctx, &params).unwrap();
4332                runtime.on_event(92, click());
4333                assert_eq!(runtime.dispose_slot(&mut ctx, 92), DisposeOutcome::Disposed);
4334            })
4335            .expect("the thread's runtime");
4336        }
4337        forget(92);
4338
4339        assert_eq!(
4340            *fired.lock().unwrap(),
4341            vec![EventPayload::Toggled(false)],
4342            "the component's answer — built from its value-0 props — reached the callback"
4343        );
4344        assert_eq!(live_listener_count(), 0);
4345    }
4346
4347    #[test]
4348    fn a_listener_attached_in_update_opens_the_gate_too() {
4349        // `listening` is the union across create AND update: a component that
4350        // wires a listener later (a child it only builds on some props) must
4351        // not be gated out of its own events.
4352        struct Late;
4353
4354        impl NativeComponent for Late {
4355            type Props = GaugeProps;
4356            type State = (u64, Option<ListenerHandle>);
4357
4358            fn create(
4359                &self,
4360                ctx: &mut ComponentCtx<'_, '_, '_>,
4361                _props: &Self::Props,
4362            ) -> Option<(NativeRoot, Self::State)> {
4363                let identity = next_identity();
4364                Some((ctx.root(identity)?, (identity, None)))
4365            }
4366
4367            fn update(
4368                &self,
4369                ctx: &mut ComponentCtx<'_, '_, '_>,
4370                state: &mut Self::State,
4371                _old: &Self::Props,
4372                _new: &Self::Props,
4373            ) {
4374                state.1 = ctx.attach_listener(state.0, ListenerKinds::TOGGLED);
4375            }
4376        }
4377
4378        let mut calls = Vec::new();
4379        let mut ctx = PlatformCtx::new(&mut calls);
4380        publish(93, Rc::new(Late), props("late", 1));
4381        let first = BridgeProps {
4382            slot: 93,
4383            props: props("late", 1),
4384        };
4385        let (_view, mut state) = Bridge::<Late>::create(&mut ctx, &first).unwrap();
4386        let toggled = WireEvent {
4387            kind: EVENT_KIND_TOGGLED,
4388            detail: pack_bool(true),
4389        };
4390        assert_eq!(Bridge::<Late>::on_event(&mut state, toggled), None);
4391
4392        let second = BridgeProps {
4393            slot: 93,
4394            props: props("late", 2),
4395        };
4396        Bridge::<Late>::update(&mut ctx, &mut state, &first, &second).unwrap();
4397        assert_eq!(
4398            Bridge::<Late>::on_event(&mut state, toggled),
4399            Some(EventPayload::Toggled(true)),
4400            "the default `on_event` forwards the event unchanged once attached"
4401        );
4402        forget(93);
4403    }
4404
4405    #[test]
4406    fn an_empty_listener_request_latches_instead_of_attaching() {
4407        let mut calls = Vec::new();
4408        let mut ctx = PlatformCtx::new(&mut calls);
4409        let mut cx = ComponentCtx::new(&mut ctx, 94);
4410        assert!(cx.attach_listener(1, ListenerKinds::NONE).is_none());
4411        assert!(cx.failed(), "an empty request is a wiring bug, reported");
4412        let (error, attached) = cx.into_parts();
4413        assert!(
4414            matches!(error, Some(NativeWidgetError::Params(_))),
4415            "{error:?}"
4416        );
4417        assert!(attached.is_empty());
4418        assert!(calls.is_empty(), "nothing was recorded as attached");
4419        assert_eq!(live_listener_count(), 0);
4420    }
4421
4422    #[test]
4423    fn detach_listener_records_the_detach_and_releases_the_handle() {
4424        let mut calls = Vec::new();
4425        let mut ctx = PlatformCtx::new(&mut calls);
4426        let mut cx = ComponentCtx::new(&mut ctx, 95);
4427        let handle = cx
4428            .attach_listener(7, ListenerKinds::CLICK | ListenerKinds::TOGGLED)
4429            .expect("the host arm always attaches");
4430        assert_eq!(
4431            handle.kinds(),
4432            ListenerKinds::CLICK | ListenerKinds::TOGGLED
4433        );
4434        assert_eq!(live_listener_count(), 1);
4435        assert_eq!(cx.detach_listener(handle), Some(()));
4436        assert_eq!(live_listener_count(), 0);
4437        drop(cx);
4438        assert_eq!(
4439            calls,
4440            vec![
4441                "attachListener 7 click|toggled".to_string(),
4442                "detachListener 7 click|toggled".to_string(),
4443            ]
4444        );
4445    }
4446
4447    #[test]
4448    fn replacing_a_handle_on_the_same_view_keeps_the_new_listener_live() {
4449        // The replace-handle pattern: `update` re-attaches the SAME view for
4450        // the SAME kinds and assigns over the old handle, which drops it. A
4451        // release that nulled the view's interface — the earlier defect this
4452        // handle's Drop is designed to avoid — would wipe the listener just
4453        // set; a release that touches only the old handle's own listener must
4454        // leave the new one live and the slot's family still admitted.
4455        struct Rewire;
4456
4457        impl NativeComponent for Rewire {
4458            type Props = GaugeProps;
4459            type State = (u64, Option<ListenerHandle>);
4460
4461            fn create(
4462                &self,
4463                ctx: &mut ComponentCtx<'_, '_, '_>,
4464                _props: &Self::Props,
4465            ) -> Option<(NativeRoot, Self::State)> {
4466                let identity = next_identity();
4467                let listener = ctx.attach_listener(identity, ListenerKinds::CLICK)?;
4468                Some((ctx.root(identity)?, (identity, Some(listener))))
4469            }
4470
4471            fn update(
4472                &self,
4473                ctx: &mut ComponentCtx<'_, '_, '_>,
4474                state: &mut Self::State,
4475                _old: &Self::Props,
4476                _new: &Self::Props,
4477            ) {
4478                // Handle A is dropped by this assignment, after handle B's
4479                // attach has already run.
4480                state.1 = ctx.attach_listener(state.0, ListenerKinds::CLICK);
4481            }
4482        }
4483
4484        let mut calls = Vec::new();
4485        let mut ctx = PlatformCtx::new(&mut calls);
4486        publish(96, Rc::new(Rewire), props("rewire", 1));
4487        let first = BridgeProps {
4488            slot: 96,
4489            props: props("rewire", 1),
4490        };
4491        let (_view, mut state) = Bridge::<Rewire>::create(&mut ctx, &first).unwrap();
4492        assert_eq!(live_listener_count(), 1, "create attached handle A");
4493
4494        let second = BridgeProps {
4495            slot: 96,
4496            props: props("rewire", 2),
4497        };
4498        Bridge::<Rewire>::update(&mut ctx, &mut state, &first, &second).unwrap();
4499        assert_eq!(
4500            live_listener_count(),
4501            1,
4502            "handle A was released by the reassignment and handle B is live"
4503        );
4504        assert_eq!(
4505            Bridge::<Rewire>::on_event(&mut state, click()),
4506            Some(EventPayload::Click),
4507            "a click is still admitted after the old handle dropped"
4508        );
4509
4510        drop(state);
4511        assert_eq!(live_listener_count(), 0, "handle B goes with the state");
4512        assert_eq!(
4513            calls
4514                .iter()
4515                .filter(|call| call.starts_with("attachListener "))
4516                .count(),
4517            2,
4518            "one attach in create, one in update: {calls:?}"
4519        );
4520        forget(96);
4521    }
4522}
4523
4524// Platform-neutral: the listener-kind mask and the public event pair's
4525// codec, which every arm (macOS included) shares — so, unlike `tests` above,
4526// these run on a Mac too.
4527#[cfg(test)]
4528mod listener_tests {
4529    use super::*;
4530
4531    #[test]
4532    fn listener_kinds_combine_and_spell_themselves() {
4533        let both = ListenerKinds::CLICK | ListenerKinds::VALUE_CHANGED;
4534        assert!(both.contains(ListenerKinds::CLICK));
4535        assert!(both.contains(ListenerKinds::VALUE_CHANGED));
4536        assert!(!both.contains(ListenerKinds::TOGGLED));
4537        assert!(both.contains(ListenerKinds::NONE));
4538        assert!(ListenerKinds::NONE.is_empty());
4539        assert!(!both.is_empty());
4540        assert_eq!(both.to_string(), "click|value_changed");
4541        assert_eq!(ListenerKinds::NONE.to_string(), "none");
4542        let mut grown = ListenerKinds::NONE;
4543        grown |= ListenerKinds::TOGGLED;
4544        assert_eq!(grown, ListenerKinds::TOGGLED);
4545    }
4546
4547    #[test]
4548    fn every_payload_round_trips_through_the_public_pair() {
4549        // The component answer's ride to the app hook: NativeEvent →
4550        // EventPayload (the built-in controls' callback table) → NativeEvent
4551        // (the hook's parameter). A lossy leg would hand the app something other than
4552        // what the component answered.
4553        for payload in [
4554            EventPayload::Click,
4555            EventPayload::Toggled(true),
4556            EventPayload::Toggled(false),
4557            EventPayload::ValueChanged {
4558                value: 42,
4559                from_user: true,
4560            },
4561            EventPayload::ValueChanged {
4562                value: 0,
4563                from_user: false,
4564            },
4565            EventPayload::DragStart,
4566            EventPayload::DragEnd,
4567        ] {
4568            let event = NativeEvent::from_payload(payload);
4569            assert_eq!(event.into_payload(), Some(payload), "{payload:?}");
4570        }
4571        let unknown = NativeEvent {
4572            kind: 99,
4573            detail: 0,
4574        };
4575        assert_eq!(unknown.into_payload(), None);
4576        assert_eq!(unknown.family(), ListenerKinds::NONE);
4577    }
4578
4579    #[test]
4580    fn the_public_decoders_read_only_their_own_kind() {
4581        let toggled = NativeEvent::from_payload(EventPayload::Toggled(true));
4582        assert_eq!(toggled.checked(), Some(true));
4583        assert_eq!(toggled.value(), None);
4584        assert!(!toggled.is_click());
4585        assert_eq!(toggled.family(), ListenerKinds::TOGGLED);
4586
4587        let moved = NativeEvent::from_payload(EventPayload::ValueChanged {
4588            value: 17,
4589            from_user: true,
4590        });
4591        assert_eq!(moved.value(), Some(17));
4592        assert_eq!(moved.checked(), None);
4593        assert_eq!(moved.family(), ListenerKinds::VALUE_CHANGED);
4594        assert_eq!(
4595            NativeEvent::from_payload(EventPayload::DragEnd).family(),
4596            ListenerKinds::VALUE_CHANGED,
4597            "the drag edges ride the VALUE_CHANGED attach"
4598        );
4599
4600        let click = NativeEvent::from_payload(EventPayload::Click);
4601        assert!(click.is_click());
4602        assert_eq!(click.kind(), NativeEvent::KIND_CLICK);
4603        assert_eq!(click.family(), ListenerKinds::CLICK);
4604    }
4605}