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, ¶ms).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, ¶ms).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, ¶ms).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, ¶ms).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, ¶ms).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, ¶ms).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, ¶ms))
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, ¶ms);
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, ¶ms).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, ¶ms);
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, ¶ms).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, ¶ms);
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, ¶ms);
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, ¶ms);
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, ¶ms))
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, ¶ms).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, ¶ms);
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, ¶ms).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}