use std::any::Any;
use std::cell::RefCell;
use std::collections::HashMap;
use std::marker::PhantomData;
use std::rc::Rc;
use crate::NativeWidgetError;
use crate::controls::DARK;
use crate::events::{
EVENT_KIND_CLICK, EVENT_KIND_DRAG_END, EVENT_KIND_DRAG_START, EVENT_KIND_TOGGLED,
EVENT_KIND_VALUE_CHANGED, EventPayload, pack_bool, pack_value_changed, unpack_bool,
unpack_value_changed,
};
use crate::registry::SlotId;
use crate::runtime::{
NativeCtx as PlatformCtx, NativeEvent as WireEvent, NativeView, NativeWidget, Params,
with_runtime,
};
const PROPS_GENERATION_KEY: &str = "__frustProps";
pub trait NativeComponent: 'static {
type Props: Clone + PartialEq + Send + 'static;
type State: 'static;
fn create(
&self,
ctx: &mut ComponentCtx<'_, '_, '_>,
props: &Self::Props,
) -> Option<(NativeRoot, Self::State)>;
fn update(
&self,
ctx: &mut ComponentCtx<'_, '_, '_>,
state: &mut Self::State,
old: &Self::Props,
new: &Self::Props,
);
fn on_event(
&self,
state: &mut Self::State,
props: &Self::Props,
event: NativeEvent,
) -> Option<NativeEvent> {
let _ = (state, props);
Some(event)
}
fn dispose(&self, ctx: &mut ComponentCtx<'_, '_, '_>, state: Self::State) {
let _ = (ctx, state);
}
}
pub struct ComponentCtx<'ctx, 'local, 'env> {
inner: &'ctx mut PlatformCtx<'local, 'env>,
error: Option<NativeWidgetError>,
slot: SlotId,
attached: ListenerKinds,
}
impl<'ctx, 'local, 'env> ComponentCtx<'ctx, 'local, 'env> {
fn new(inner: &'ctx mut PlatformCtx<'local, 'env>, slot: SlotId) -> Self {
Self {
inner,
error: None,
slot,
attached: ListenerKinds::NONE,
}
}
fn into_parts(self) -> (Option<NativeWidgetError>, ListenerKinds) {
(self.error, self.attached)
}
fn note_attached(&mut self, kinds: ListenerKinds) {
self.attached |= kinds;
log::debug!(
"frust-native-widgets: component slot {} attached a {kinds} listener",
self.slot
);
}
fn refuse_empty(&mut self, kinds: ListenerKinds) -> bool {
if kinds.is_empty() {
self.latch(NativeWidgetError::Params(
"attach_listener was asked for no ListenerKinds — nothing to attach".into(),
));
return true;
}
false
}
fn latch(&mut self, error: NativeWidgetError) {
match &self.error {
Some(first) => log::warn!(
"frust-native-widgets: component context already failed ({first}) — keeping that \
error and reporting this later one here only: {error}"
),
None => self.error = Some(error),
}
}
}
impl ComponentCtx<'_, '_, '_> {
pub fn failed(&self) -> bool {
self.error.is_some()
}
pub fn report_error(&mut self, message: impl Into<String>) {
self.latch(NativeWidgetError::Platform(message.into()));
}
}
#[cfg(target_os = "android")]
impl ComponentCtx<'_, '_, '_> {
pub fn add_child(
&mut self,
parent: &jni::objects::JObject<'_>,
child: &jni::objects::JObject<'_>,
) -> Option<()> {
match self.inner.add_child(parent, child) {
Ok(()) => Some(()),
Err(error) => {
self.latch(error);
None
}
}
}
pub fn retain_child(&mut self, view: &jni::objects::JObject<'_>) -> Option<NativeChild> {
match self.inner.retain(view) {
Ok(global) => Some(NativeChild(global)),
Err(error) => {
self.latch(error);
None
}
}
}
pub fn attach_listener(
&mut self,
view: &jni::objects::JObject<'_>,
kinds: ListenerKinds,
) -> Option<ListenerHandle> {
if self.refuse_empty(kinds) {
return None;
}
match self.inner.attach_listener(
view,
self.slot,
kinds.contains(ListenerKinds::CLICK),
kinds.contains(ListenerKinds::TOGGLED),
kinds.contains(ListenerKinds::VALUE_CHANGED),
) {
Ok((view, listener)) => {
self.note_attached(kinds);
Some(ListenerHandle {
kinds,
inner: ListenerInner {
_view: view,
listener,
_not_send: PhantomData,
},
})
}
Err(error) => {
self.latch(error);
None
}
}
}
pub fn detach_listener(&mut self, handle: ListenerHandle) -> Option<()> {
drop(handle);
Some(())
}
pub fn with_local_frame<T>(
&mut self,
capacity: usize,
f: impl FnOnce(&mut ComponentCtx<'_, '_, '_>) -> Option<T>,
) -> Option<T> {
let mut latched = self.error.take();
let slot = self.slot;
let mut attached = self.attached;
let outcome = self.inner.with_frame(capacity, |inner| {
let mut cx = ComponentCtx {
inner,
error: latched.take(),
slot,
attached,
};
let value = f(&mut cx);
(latched, attached) = cx.into_parts();
Ok::<Option<T>, NativeWidgetError>(value)
});
self.error = latched;
self.attached = attached;
match outcome {
Ok(value) => value,
Err(error) => {
self.latch(error);
None
}
}
}
}
#[cfg(target_os = "android")]
impl<'local, 'env> ComponentCtx<'_, 'local, 'env> {
pub fn env(&mut self) -> &mut jni::Env<'local> {
self.inner.env()
}
pub fn context(&self) -> Option<&'env jni::objects::JObject<'local>> {
self.inner.context().ok()
}
pub fn new_view(&mut self, binary_name: &'static str) -> Option<jni::objects::JObject<'local>> {
match self.inner.new_view(binary_name) {
Ok(view) => Some(view),
Err(error) => {
self.latch(error);
None
}
}
}
pub fn root(&mut self, view: &jni::objects::JObject<'_>) -> Option<NativeRoot> {
match self.inner.retain(view) {
Ok(global) => Some(NativeRoot(NativeView::new(global))),
Err(error) => {
self.latch(error);
None
}
}
}
}
#[cfg(target_os = "ios")]
impl ComponentCtx<'_, '_, '_> {
pub fn mtm(&self) -> objc2::MainThreadMarker {
self.inner.mtm()
}
pub fn root(&mut self, view: objc2::rc::Retained<objc2_ui_kit::UIView>) -> Option<NativeRoot> {
let mtm = self.inner.mtm();
Some(NativeRoot(NativeView::new(view, mtm)))
}
pub fn add_child(
&mut self,
parent: &objc2_ui_kit::UIView,
child: &objc2_ui_kit::UIView,
) -> Option<()> {
self.inner.add_child(parent, child);
Some(())
}
pub fn retain_child(&mut self, view: &objc2_ui_kit::UIView) -> Option<NativeChild> {
use objc2::Message as _;
Some(NativeChild(view.retain()))
}
pub fn attach_listener(
&mut self,
view: &objc2_ui_kit::UIView,
kinds: ListenerKinds,
) -> Option<ListenerHandle> {
if self.refuse_empty(kinds) {
return None;
}
let mtm = self.inner.mtm();
match crate::apple::FrustNativeControlTarget::attach_component(
mtm,
self.slot,
view,
kinds.contains(ListenerKinds::CLICK),
kinds.contains(ListenerKinds::TOGGLED),
kinds.contains(ListenerKinds::VALUE_CHANGED),
) {
Ok((target, control)) => {
self.note_attached(kinds);
Some(ListenerHandle {
kinds,
inner: ListenerInner { target, control },
})
}
Err(message) => {
self.latch(NativeWidgetError::Platform(message));
None
}
}
}
pub fn detach_listener(&mut self, handle: ListenerHandle) -> Option<()> {
drop(handle);
Some(())
}
pub fn with_local_frame<T>(
&mut self,
_capacity: usize,
f: impl FnOnce(&mut ComponentCtx<'_, '_, '_>) -> Option<T>,
) -> Option<T> {
f(self)
}
}
#[cfg(target_os = "macos")]
impl ComponentCtx<'_, '_, '_> {
pub fn mtm(&self) -> objc2::MainThreadMarker {
self.inner.mtm()
}
pub fn root(&mut self, view: objc2::rc::Retained<objc2_app_kit::NSView>) -> Option<NativeRoot> {
let mtm = self.inner.mtm();
Some(NativeRoot(NativeView::new(view, mtm)))
}
pub fn add_child(
&mut self,
parent: &objc2_app_kit::NSView,
child: &objc2_app_kit::NSView,
) -> Option<()> {
self.inner.add_child(parent, child);
Some(())
}
pub fn retain_child(&mut self, view: &objc2_app_kit::NSView) -> Option<NativeChild> {
use objc2::Message as _;
Some(NativeChild(view.retain()))
}
pub fn attach_listener(
&mut self,
view: &objc2_app_kit::NSView,
kinds: ListenerKinds,
) -> Option<ListenerHandle> {
if self.refuse_empty(kinds) {
return None;
}
let Some(kind) = kinds.single_event_kind() else {
self.latch(NativeWidgetError::Params(format!(
"macOS attach_listener: an NSControl carries one target/action pair, so attach \
exactly one ListenerKinds family per control (asked for {kinds})"
)));
return None;
};
let mtm = self.inner.mtm();
match crate::appkit::FrustNativeControlTarget::attach_view(mtm, view, self.slot, kind) {
Ok((target, control)) => {
self.note_attached(kinds);
Some(ListenerHandle {
kinds,
inner: ListenerInner { target, control },
})
}
Err(message) => {
self.latch(NativeWidgetError::Platform(message));
None
}
}
}
pub fn detach_listener(&mut self, handle: ListenerHandle) -> Option<()> {
drop(handle);
Some(())
}
pub fn with_local_frame<T>(
&mut self,
_capacity: usize,
f: impl FnOnce(&mut ComponentCtx<'_, '_, '_>) -> Option<T>,
) -> Option<T> {
f(self)
}
}
#[cfg(not(any(target_os = "android", target_os = "ios", target_os = "macos")))]
impl ComponentCtx<'_, '_, '_> {
pub fn record(&mut self, call: impl Into<String>) {
self.inner.record(call);
}
pub fn root(&mut self, identity: u64) -> Option<NativeRoot> {
Some(NativeRoot(NativeView { identity }))
}
pub fn add_child(&mut self, parent: u64, child: u64) -> Option<()> {
self.inner.record(format!("addChild {parent} <- {child}"));
Some(())
}
pub fn retain_child(&mut self, identity: u64) -> Option<NativeChild> {
self.inner.record(format!("retainChild {identity}"));
Some(NativeChild::new(identity))
}
pub fn attach_listener(&mut self, view: u64, kinds: ListenerKinds) -> Option<ListenerHandle> {
if self.refuse_empty(kinds) {
return None;
}
self.inner.record(format!("attachListener {view} {kinds}"));
self.note_attached(kinds);
Some(ListenerHandle {
kinds,
inner: HostListener::new(view),
})
}
pub fn detach_listener(&mut self, handle: ListenerHandle) -> Option<()> {
self.inner.record(format!(
"detachListener {} {}",
handle.inner.identity, handle.kinds
));
Some(())
}
pub fn with_local_frame<T>(
&mut self,
capacity: usize,
f: impl FnOnce(&mut ComponentCtx<'_, '_, '_>) -> Option<T>,
) -> Option<T> {
self.inner.record(format!("pushLocalFrame {capacity}"));
let value = f(self);
self.inner.record("popLocalFrame");
value
}
}
pub struct NativeRoot(NativeView);
impl NativeRoot {
fn into_inner(self) -> NativeView {
self.0
}
}
pub struct NativeChild(ChildHandle);
#[cfg(target_os = "android")]
type ChildHandle = jni::refs::Global<jni::objects::JObject<'static>>;
#[cfg(target_os = "ios")]
type ChildHandle = objc2::rc::Retained<objc2_ui_kit::UIView>;
#[cfg(target_os = "macos")]
type ChildHandle = objc2::rc::Retained<objc2_app_kit::NSView>;
#[cfg(not(any(target_os = "android", target_os = "ios", target_os = "macos")))]
type ChildHandle = HostChild;
#[cfg(target_os = "android")]
impl NativeChild {
pub fn as_object(&self) -> &jni::objects::JObject<'static> {
&self.0
}
}
#[cfg(target_os = "ios")]
impl NativeChild {
pub fn view(&self, _mtm: objc2::MainThreadMarker) -> &objc2_ui_kit::UIView {
&self.0
}
}
#[cfg(target_os = "macos")]
impl NativeChild {
pub fn view(&self, _mtm: objc2::MainThreadMarker) -> &objc2_app_kit::NSView {
&self.0
}
}
#[cfg(not(any(target_os = "android", target_os = "ios", target_os = "macos")))]
impl NativeChild {
fn new(identity: u64) -> Self {
LIVE_CHILDREN.with(|live| live.set(live.get() + 1));
Self(HostChild { identity })
}
pub fn identity(&self) -> u64 {
self.0.identity
}
}
#[cfg(not(any(target_os = "android", target_os = "ios", target_os = "macos")))]
pub(crate) struct HostChild {
identity: u64,
}
#[cfg(not(any(target_os = "android", target_os = "ios", target_os = "macos")))]
impl Drop for HostChild {
fn drop(&mut self) {
LIVE_CHILDREN.with(|live| live.set(live.get().saturating_sub(1)));
}
}
#[cfg(not(any(target_os = "android", target_os = "ios", target_os = "macos")))]
thread_local! {
static LIVE_CHILDREN: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
#[cfg(not(any(target_os = "android", target_os = "ios", target_os = "macos")))]
#[allow(dead_code)] pub(crate) fn live_child_count() -> usize {
LIVE_CHILDREN.with(|live| live.get())
}
#[derive(Clone, Copy, Default, PartialEq, Eq, Hash)]
pub struct ListenerKinds(u8);
impl ListenerKinds {
pub const NONE: Self = Self(0);
pub const CLICK: Self = Self(1);
pub const TOGGLED: Self = Self(1 << 1);
pub const VALUE_CHANGED: Self = Self(1 << 2);
pub const fn contains(self, other: Self) -> bool {
self.0 & other.0 == other.0
}
pub const fn is_empty(self) -> bool {
self.0 == 0
}
pub const fn union(self, other: Self) -> Self {
Self(self.0 | other.0)
}
#[cfg(target_os = "macos")]
fn single_event_kind(self) -> Option<i32> {
match self {
Self::CLICK => Some(EVENT_KIND_CLICK),
Self::TOGGLED => Some(EVENT_KIND_TOGGLED),
Self::VALUE_CHANGED => Some(EVENT_KIND_VALUE_CHANGED),
_ => None,
}
}
}
impl std::ops::BitOr for ListenerKinds {
type Output = Self;
fn bitor(self, other: Self) -> Self {
self.union(other)
}
}
impl std::ops::BitOrAssign for ListenerKinds {
fn bitor_assign(&mut self, other: Self) {
*self = self.union(other);
}
}
impl std::fmt::Display for ListenerKinds {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.is_empty() {
return f.write_str("none");
}
let names = [
(Self::CLICK, "click"),
(Self::TOGGLED, "toggled"),
(Self::VALUE_CHANGED, "value_changed"),
];
let mut first = true;
for (family, name) in names {
if self.contains(family) {
if !first {
f.write_str("|")?;
}
f.write_str(name)?;
first = false;
}
}
Ok(())
}
}
impl std::fmt::Debug for ListenerKinds {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "ListenerKinds({self})")
}
}
#[must_use = "a dropped ListenerHandle releases its listener at once — keep it in State"]
pub struct ListenerHandle {
kinds: ListenerKinds,
inner: ListenerInner,
}
impl ListenerHandle {
pub fn kinds(&self) -> ListenerKinds {
self.kinds
}
}
#[cfg(target_os = "android")]
struct ListenerInner {
_view: jni::refs::Global<jni::objects::JObject<'static>>,
listener: jni::refs::Global<jni::objects::JObject<'static>>,
_not_send: PhantomData<*const ()>,
}
#[cfg(target_os = "ios")]
struct ListenerInner {
target: objc2::rc::Retained<crate::apple::FrustNativeControlTarget>,
control: objc2::rc::Retained<objc2_ui_kit::UIControl>,
}
#[cfg(target_os = "macos")]
struct ListenerInner {
target: objc2::rc::Retained<crate::appkit::FrustNativeControlTarget>,
control: objc2::rc::Retained<objc2_app_kit::NSControl>,
}
#[cfg(not(any(target_os = "android", target_os = "ios", target_os = "macos")))]
type ListenerInner = HostListener;
#[cfg(target_os = "android")]
impl Drop for ListenerHandle {
fn drop(&mut self) {
let kinds = self.kinds;
let vm = match frust_plugin::android::vm() {
Ok(vm) => vm,
Err(error) => {
log::warn!(
"frust-native-widgets: ListenerHandle drop ({kinds}) could not reach a JNI \
env ({error}) — the listener stays armed; releasing only the global \
references"
);
return;
}
};
let listener = &self.inner.listener;
let outcome = vm.with_top_local_frame(|env| {
if env.exception_check() {
return Ok::<Option<Result<(), NativeWidgetError>>, jni::errors::Error>(None);
}
let mut ctx = PlatformCtx::detached(env);
Ok(Some(ctx.disarm_listener(listener)))
});
match outcome {
Ok(Some(Ok(()))) => {}
Ok(None) => log::warn!(
"frust-native-widgets: ListenerHandle drop ({kinds}) found a Java exception \
pending — skipped disarming the listener (no JNI call is safe with one pending); \
releasing only the global references"
),
Ok(Some(Err(error))) => log::warn!(
"frust-native-widgets: ListenerHandle drop ({kinds}) failed disarming its \
listener: {error}"
),
Err(error) => log::warn!(
"frust-native-widgets: ListenerHandle drop ({kinds}) could not reach a JNI env: \
{error}"
),
}
}
}
#[cfg(target_os = "ios")]
impl Drop for ListenerHandle {
fn drop(&mut self) {
self.inner.target.detach_component(
&self.inner.control,
self.kinds.contains(ListenerKinds::CLICK),
self.kinds.contains(ListenerKinds::TOGGLED),
self.kinds.contains(ListenerKinds::VALUE_CHANGED),
);
}
}
#[cfg(target_os = "macos")]
impl Drop for ListenerHandle {
fn drop(&mut self) {
self.inner.target.detach_if_current(&self.inner.control);
}
}
#[cfg(not(any(target_os = "android", target_os = "ios", target_os = "macos")))]
pub(crate) struct HostListener {
identity: u64,
}
#[cfg(not(any(target_os = "android", target_os = "ios", target_os = "macos")))]
impl HostListener {
fn new(identity: u64) -> Self {
LIVE_LISTENERS.with(|live| live.set(live.get() + 1));
Self { identity }
}
}
#[cfg(not(any(target_os = "android", target_os = "ios", target_os = "macos")))]
impl Drop for HostListener {
fn drop(&mut self) {
LIVE_LISTENERS.with(|live| live.set(live.get().saturating_sub(1)));
}
}
#[cfg(not(any(target_os = "android", target_os = "ios", target_os = "macos")))]
thread_local! {
static LIVE_LISTENERS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
#[cfg(not(any(target_os = "android", target_os = "ios", target_os = "macos")))]
#[allow(dead_code)] pub(crate) fn live_listener_count() -> usize {
LIVE_LISTENERS.with(|live| live.get())
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct NativeEvent {
kind: i32,
detail: i64,
}
impl NativeEvent {
pub const KIND_CLICK: i32 = EVENT_KIND_CLICK;
pub const KIND_TOGGLED: i32 = EVENT_KIND_TOGGLED;
pub const KIND_VALUE_CHANGED: i32 = EVENT_KIND_VALUE_CHANGED;
pub const KIND_DRAG_START: i32 = EVENT_KIND_DRAG_START;
pub const KIND_DRAG_END: i32 = EVENT_KIND_DRAG_END;
pub fn kind(self) -> i32 {
self.kind
}
pub fn detail(self) -> i64 {
self.detail
}
pub fn is_click(self) -> bool {
self.kind == EVENT_KIND_CLICK
}
pub fn checked(self) -> Option<bool> {
(self.kind == EVENT_KIND_TOGGLED).then(|| unpack_bool(self.detail))
}
pub fn value(self) -> Option<i32> {
(self.kind == EVENT_KIND_VALUE_CHANGED).then(|| unpack_value_changed(self.detail).0)
}
pub fn family(self) -> ListenerKinds {
match self.kind {
EVENT_KIND_CLICK => ListenerKinds::CLICK,
EVENT_KIND_TOGGLED => ListenerKinds::TOGGLED,
EVENT_KIND_VALUE_CHANGED | EVENT_KIND_DRAG_START | EVENT_KIND_DRAG_END => {
ListenerKinds::VALUE_CHANGED
}
_ => ListenerKinds::NONE,
}
}
fn from_wire(event: WireEvent) -> Self {
Self {
kind: event.kind,
detail: event.detail,
}
}
pub(crate) fn into_payload(self) -> Option<EventPayload> {
match self.kind {
EVENT_KIND_CLICK => Some(EventPayload::Click),
EVENT_KIND_TOGGLED => Some(EventPayload::Toggled(unpack_bool(self.detail))),
EVENT_KIND_VALUE_CHANGED => {
let (value, from_user) = unpack_value_changed(self.detail);
Some(EventPayload::ValueChanged { value, from_user })
}
EVENT_KIND_DRAG_START => Some(EventPayload::DragStart),
EVENT_KIND_DRAG_END => Some(EventPayload::DragEnd),
_ => None,
}
}
pub(crate) fn from_payload(payload: EventPayload) -> Self {
let (kind, detail) = match payload {
EventPayload::Click => (EVENT_KIND_CLICK, 0),
EventPayload::Toggled(checked) => (EVENT_KIND_TOGGLED, pack_bool(checked)),
EventPayload::ValueChanged { value, from_user } => (
EVENT_KIND_VALUE_CHANGED,
pack_value_changed(value, from_user),
),
EventPayload::DragStart => (EVENT_KIND_DRAG_START, 0),
EventPayload::DragEnd => (EVENT_KIND_DRAG_END, 0),
EventPayload::Selected(index) => (
crate::events::EVENT_KIND_SELECTION,
crate::events::pack_index(index as isize),
),
EventPayload::Date(date) => (
crate::events::EVENT_KIND_DATE,
crate::events::pack_date(date),
),
EventPayload::Reselected(index) => (
crate::events::EVENT_KIND_RESELECTED,
crate::events::pack_index(index as isize),
),
};
Self { kind, detail }
}
}
pub fn register_component<C: NativeComponent>(kind: &'static str) -> bool {
if on_platform_main_thread() == Some(false) {
log::error!(
"frust-native-widgets: register_component('{kind}') was called off the platform main \
thread — the runtime is thread-local, so this registration would land in a runtime \
nothing ever dispatches through and the component would never appear. Refused; \
register from app or plugin init on the main thread."
);
return false;
}
crate::runtime::ensure_platform_factory();
with_runtime(|runtime| runtime.register_if_free::<Bridge<C>>(kind)).unwrap_or(false)
}
#[cfg(target_os = "android")]
fn on_platform_main_thread() -> Option<bool> {
use jni::{Env, jni_sig, jni_str};
fn on_main_looper(env: &mut Env<'_>) -> Result<bool, jni::errors::Error> {
let looper = env.find_class(jni_str!("android/os/Looper"))?;
let mine = env
.call_static_method(
&looper,
jni_str!("myLooper"),
jni_sig!("()Landroid/os/Looper;"),
&[],
)?
.l()?;
let main = env
.call_static_method(
&looper,
jni_str!("getMainLooper"),
jni_sig!("()Landroid/os/Looper;"),
&[],
)?
.l()?;
env.is_same_object(&mine, &main)
}
frust_plugin::android::with_jni_env(|env, _context| {
let probe = on_main_looper(env);
if env.exception_check() {
env.exception_clear();
return None;
}
probe.ok()
})
.ok()
.flatten()
}
#[cfg(any(target_os = "ios", target_os = "macos"))]
fn on_platform_main_thread() -> Option<bool> {
Some(objc2::MainThreadMarker::new().is_some())
}
#[cfg(not(any(target_os = "android", target_os = "ios", target_os = "macos")))]
fn on_platform_main_thread() -> Option<bool> {
None
}
const RETRY_UPDATE_BUDGET: u32 = 3;
struct Staged {
component: Rc<dyn Any>,
props: Box<dyn Any>,
generation: u64,
retry_update: bool,
consecutive_update_failures: u32,
}
thread_local! {
static STAGED: RefCell<HashMap<SlotId, Staged>> = RefCell::new(HashMap::new());
}
fn with_staged<T>(f: impl FnOnce(&mut HashMap<SlotId, Staged>) -> T) -> Option<T> {
STAGED
.try_with(|cell| match cell.try_borrow_mut() {
Ok(mut staged) => Some(f(&mut staged)),
Err(_) => {
log::warn!("frust-native-widgets: re-entrant component props publish ignored");
None
}
})
.unwrap_or_default()
}
pub(crate) fn publish<C: NativeComponent>(slot: SlotId, component: Rc<C>, props: C::Props) -> u64 {
with_staged(|staged| {
let (generation, failures) = match staged.get(&slot) {
Some(previous) => {
let unchanged = previous
.props
.downcast_ref::<C::Props>()
.is_some_and(|staged| *staged == props);
let generation = if unchanged && !previous.retry_update {
previous.generation
} else {
previous.generation.wrapping_add(1)
};
(generation, previous.consecutive_update_failures)
}
None => (0, 0),
};
staged.insert(
slot,
Staged {
component,
props: Box::new(props),
generation,
retry_update: false,
consecutive_update_failures: failures,
},
);
generation
})
.unwrap_or(0)
}
fn request_update_retry(slot: SlotId) {
with_staged(|staged| {
let Some(entry) = staged.get_mut(&slot) else {
return;
};
if entry.consecutive_update_failures >= RETRY_UPDATE_BUDGET {
return;
}
entry.consecutive_update_failures += 1;
if entry.consecutive_update_failures >= RETRY_UPDATE_BUDGET {
log::warn!(
"frust-native-widgets: native component slot {slot} failed \
{RETRY_UPDATE_BUDGET} consecutive updates — no further retries will be \
scheduled for it. Its native view keeps whatever state the last successful \
update left; a later props change is still dispatched (only the runtime's own \
retry is capped), and one that succeeds restores the full budget."
);
return;
}
entry.retry_update = true;
});
}
fn clear_update_retry_budget(slot: SlotId) {
with_staged(|staged| {
if let Some(entry) = staged.get_mut(&slot)
&& entry.consecutive_update_failures != 0
{
entry.consecutive_update_failures = 0;
}
});
}
pub(crate) fn forget(slot: SlotId) {
with_staged(|staged| staged.remove(&slot));
}
pub(crate) fn component_params(kind: &str, slot: SlotId, generation: u64, dark: bool) -> String {
crate::runtime::with_identity(
kind,
slot,
&format!("\"{PROPS_GENERATION_KEY}\":{generation},\"{DARK}\":{dark}"),
)
}
#[allow(dead_code)] pub(crate) fn staged_count() -> usize {
with_staged(|staged| staged.len()).unwrap_or(0)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum StagedMiss {
Unstaged,
OtherComponent,
}
impl StagedMiss {
fn describe(self, slot: SlotId, what: &str, kind: Option<&str>) -> NativeWidgetError {
match self {
Self::Unstaged => NativeWidgetError::Params(format!(
"native component slot {slot} has no published {what}"
)),
Self::OtherComponent => {
let named = kind
.map(|kind| format!(" (kind '{kind}')"))
.unwrap_or_default();
NativeWidgetError::Params(format!(
"native component slot {slot}{named} staged a different component's {what} — \
the kind a slot is mounted under must be the one that component was \
registered under"
))
}
}
}
}
fn staged_props<C: NativeComponent>(slot: SlotId) -> Result<C::Props, StagedMiss> {
with_staged(|staged| match staged.get(&slot) {
None => Err(StagedMiss::Unstaged),
Some(entry) => entry
.props
.downcast_ref::<C::Props>()
.cloned()
.ok_or(StagedMiss::OtherComponent),
})
.unwrap_or(Err(StagedMiss::Unstaged))
}
fn staged_component<C: NativeComponent>(slot: SlotId) -> Result<Rc<C>, StagedMiss> {
with_staged(|staged| match staged.get(&slot) {
None => Err(StagedMiss::Unstaged),
Some(entry) => Rc::clone(&entry.component)
.downcast::<C>()
.map_err(|_| StagedMiss::OtherComponent),
})
.unwrap_or(Err(StagedMiss::Unstaged))
}
pub(crate) struct Bridge<C>(PhantomData<fn() -> C>);
pub(crate) struct BridgeProps<C: NativeComponent> {
slot: SlotId,
props: C::Props,
}
impl<C: NativeComponent> Clone for BridgeProps<C> {
fn clone(&self) -> Self {
Self {
slot: self.slot,
props: self.props.clone(),
}
}
}
impl<C: NativeComponent> PartialEq for BridgeProps<C> {
fn eq(&self, other: &Self) -> bool {
self.slot == other.slot && self.props == other.props
}
}
pub(crate) struct BridgeState<C: NativeComponent> {
slot: SlotId,
component: Rc<C>,
state: C::State,
props: C::Props,
listening: ListenerKinds,
}
impl<C: NativeComponent> BridgeState<C> {
fn refresh_component(&mut self) {
if let Ok(component) = staged_component::<C>(self.slot) {
self.component = component;
}
}
}
#[cfg(target_os = "android")]
fn guard_pending_exception(ctx: &mut PlatformCtx<'_, '_>, op: &str) -> Option<NativeWidgetError> {
ctx.run_jni(op, |_env| Ok::<(), jni::errors::Error>(()))
.err()
}
#[cfg(target_os = "android")]
fn guard_pending_exception_off_context(op: &str) -> Option<NativeWidgetError> {
let vm = match frust_plugin::android::vm() {
Ok(vm) => vm,
Err(error) => {
log::debug!(
"frust-native-widgets: {op} boundary guard skipped — no platform handles: {error}"
);
return None;
}
};
vm.with_top_local_frame(|env| {
let mut ctx = PlatformCtx::detached(env);
Ok::<Option<NativeWidgetError>, jni::errors::Error>(guard_pending_exception(&mut ctx, op))
})
.unwrap_or_else(|error: jni::errors::Error| {
log::warn!("frust-native-widgets: {op} boundary guard could not reach a JNI env: {error}");
None
})
}
#[cfg(any(target_os = "ios", target_os = "macos"))]
fn guard_pending_exception(_ctx: &mut PlatformCtx<'_, '_>, _op: &str) -> Option<NativeWidgetError> {
None
}
#[cfg(any(target_os = "ios", target_os = "macos"))]
fn guard_pending_exception_off_context(_op: &str) -> Option<NativeWidgetError> {
None
}
#[cfg(not(any(target_os = "android", target_os = "ios", target_os = "macos")))]
fn guard_pending_exception(_ctx: &mut PlatformCtx<'_, '_>, _op: &str) -> Option<NativeWidgetError> {
DISPATCH_GUARDS.with(|guards| guards.set(guards.get() + 1));
None
}
#[cfg(not(any(target_os = "android", target_os = "ios", target_os = "macos")))]
fn guard_pending_exception_off_context(_op: &str) -> Option<NativeWidgetError> {
DISPATCH_GUARDS.with(|guards| guards.set(guards.get() + 1));
None
}
#[cfg(not(any(target_os = "android", target_os = "ios", target_os = "macos")))]
thread_local! {
static DISPATCH_GUARDS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
#[cfg(not(any(target_os = "android", target_os = "ios", target_os = "macos")))]
#[allow(dead_code)] pub(crate) fn dispatch_guard_count() -> usize {
DISPATCH_GUARDS.with(|guards| guards.get())
}
fn fold_error(
first: Option<NativeWidgetError>,
later: Option<NativeWidgetError>,
) -> Option<NativeWidgetError> {
match (first, later) {
(Some(first), Some(later)) => {
log::warn!(
"frust-native-widgets: component dispatch reported '{first}' and also left \
'{later}' behind it — reporting the first"
);
Some(first)
}
(first, later) => first.or(later),
}
}
impl<C: NativeComponent> NativeWidget for Bridge<C> {
type Props = BridgeProps<C>;
type State = BridgeState<C>;
fn decode_props(params: &Params<'_>) -> Result<Self::Props, NativeWidgetError> {
let (kind, slot) = params.identity()?;
let props =
staged_props::<C>(slot).map_err(|miss| miss.describe(slot, "props", Some(&kind)))?;
Ok(BridgeProps { slot, props })
}
fn create(
ctx: &mut PlatformCtx<'_, '_>,
props: &Self::Props,
) -> Result<(NativeView, Self::State), NativeWidgetError> {
let component = staged_component::<C>(props.slot)
.map_err(|miss| miss.describe(props.slot, "component", None))?;
let mut cx = ComponentCtx::new(ctx, props.slot);
let built = component.create(&mut cx, &props.props);
let (error, listening) = cx.into_parts();
let latched = fold_error(
error,
guard_pending_exception(ctx, "NativeComponent::create"),
);
match built {
Some((root, state)) => {
if let Some(error) = latched {
log::warn!(
"frust-native-widgets: component slot {} created despite: {error}",
props.slot
);
}
Ok((
root.into_inner(),
BridgeState {
slot: props.slot,
component,
state,
props: props.props.clone(),
listening,
},
))
}
None => Err(latched.unwrap_or_else(|| {
NativeWidgetError::Platform(format!(
"native component slot {} built no view",
props.slot
))
})),
}
}
fn update(
ctx: &mut PlatformCtx<'_, '_>,
state: &mut Self::State,
old: &Self::Props,
new: &Self::Props,
) -> Result<(), NativeWidgetError> {
state.refresh_component();
let component = Rc::clone(&state.component);
let mut cx = ComponentCtx::new(ctx, new.slot);
component.update(&mut cx, &mut state.state, &old.props, &new.props);
let (error, attached) = cx.into_parts();
state.listening |= attached;
let error = fold_error(
error,
guard_pending_exception(ctx, "NativeComponent::update"),
);
match error {
Some(error) => {
request_update_retry(new.slot);
Err(error)
}
None => {
clear_update_retry_budget(new.slot);
state.props = new.props.clone();
Ok(())
}
}
}
fn on_event(state: &mut Self::State, event: WireEvent) -> Option<EventPayload> {
let event = NativeEvent::from_wire(event);
let family = event.family();
if family.is_empty() || !state.listening.contains(family) {
log::debug!(
"frust-native-widgets: component slot {} got event kind {} but attached no \
listener for it (attached: {}) — dropped",
state.slot,
event.kind(),
state.listening
);
return None;
}
state.refresh_component();
let component = Rc::clone(&state.component);
let answer = component.on_event(&mut state.state, &state.props, event);
if let Some(error) = guard_pending_exception_off_context("NativeComponent::on_event") {
log::warn!(
"frust-native-widgets: component slot {} left a Java exception pending after \
on_event — cleared here, but the rest of that dispatch ran on undefined \
ground: {error}",
state.slot
);
}
let answer = answer?;
let payload = answer.into_payload();
if payload.is_none() {
log::debug!(
"frust-native-widgets: component slot {} answered event kind {}, which the \
app-facing callback has no vocabulary for — dropped",
state.slot,
answer.kind()
);
}
payload
}
fn dispose(
ctx: &mut PlatformCtx<'_, '_>,
mut state: Self::State,
) -> Result<(), NativeWidgetError> {
state.refresh_component();
let BridgeState {
slot,
component,
state,
props: _,
listening: _,
} = state;
let mut cx = ComponentCtx::new(ctx, slot);
component.dispose(&mut cx, state);
let (error, _attached) = cx.into_parts();
match fold_error(
error,
guard_pending_exception(ctx, "NativeComponent::dispose"),
) {
Some(error) => Err(error),
None => Ok(()),
}
}
}
#[cfg(all(
test,
not(any(target_os = "android", target_os = "ios", target_os = "macos"))
))]
mod tests {
use std::cell::{Cell, RefCell};
use std::sync::{Mutex, Once};
use super::*;
use crate::runtime::{DisposeOutcome, UpdateOutcome};
static CAPTURED: Mutex<Vec<String>> = Mutex::new(Vec::new());
struct CapturedLog;
impl log::Log for CapturedLog {
fn enabled(&self, _metadata: &log::Metadata<'_>) -> bool {
true
}
fn log(&self, record: &log::Record<'_>) {
if let Ok(mut captured) = CAPTURED.lock() {
captured.push(record.args().to_string());
}
}
fn flush(&self) {}
}
static CAPTURE: CapturedLog = CapturedLog;
fn install_log_capture() {
static INSTALL: Once = Once::new();
INSTALL.call_once(|| {
if log::set_logger(&CAPTURE).is_ok() {
log::set_max_level(log::LevelFilter::Warn);
}
});
}
fn logged(needle: &str) -> bool {
CAPTURED
.lock()
.map(|captured| captured.iter().any(|line| line.contains(needle)))
.unwrap_or(false)
}
struct Gauge {
log: Rc<RefCell<Vec<String>>>,
tag: &'static str,
}
#[derive(Clone, Debug, PartialEq)]
struct GaugeProps {
label: String,
value: i32,
}
struct GaugeState {
identity: u64,
events: Vec<NativeEvent>,
_listener: ListenerHandle,
}
const GAUGE_KIND: &str = "test-gauge";
const FAIL_CREATE: &str = "FAIL_CREATE";
const FAIL_UPDATE: &str = "FAIL_UPDATE";
const FAIL_UPDATE_ONCE: &str = "FAIL_UPDATE_ONCE";
const FAIL_UPDATE_ALWAYS: &str = "FAIL_UPDATE_ALWAYS";
thread_local! {
static UPDATE_FAILED_ONCE: Cell<bool> = const { Cell::new(false) };
}
impl Gauge {
fn new(log: &Rc<RefCell<Vec<String>>>, tag: &'static str) -> Self {
Self {
log: Rc::clone(log),
tag,
}
}
fn note(&self, entry: String) {
self.log.borrow_mut().push(entry);
}
}
impl NativeComponent for Gauge {
type Props = GaugeProps;
type State = GaugeState;
fn create(
&self,
ctx: &mut ComponentCtx<'_, '_, '_>,
props: &Self::Props,
) -> Option<(NativeRoot, Self::State)> {
if props.label == FAIL_CREATE {
ctx.report_error("gauge: no native view today");
return None;
}
let identity = next_identity();
ctx.record(format!("gauge create {identity} '{}'", props.label));
self.note(format!("{} create '{}'", self.tag, props.label));
let listener = ctx.attach_listener(
identity,
ListenerKinds::CLICK | ListenerKinds::VALUE_CHANGED,
)?;
let root = ctx.root(identity)?;
Some((
root,
GaugeState {
identity,
events: Vec::new(),
_listener: listener,
},
))
}
fn update(
&self,
ctx: &mut ComponentCtx<'_, '_, '_>,
state: &mut Self::State,
old: &Self::Props,
new: &Self::Props,
) {
if new.label == FAIL_UPDATE {
ctx.report_error("gauge: setter threw");
return;
}
if new.label == FAIL_UPDATE_ALWAYS {
self.note(format!("{} update refused", self.tag));
ctx.report_error("gauge: setter always throws");
return;
}
if new.label == FAIL_UPDATE_ONCE && !UPDATE_FAILED_ONCE.replace(true) {
self.note(format!("{} update failed", self.tag));
ctx.report_error("gauge: setter threw once");
return;
}
ctx.record(format!(
"gauge update {} {} -> {}",
state.identity, old.value, new.value
));
self.note(format!(
"{} update {} -> {}",
self.tag, old.value, new.value
));
}
fn on_event(
&self,
state: &mut Self::State,
_props: &Self::Props,
event: NativeEvent,
) -> Option<NativeEvent> {
state.events.push(event);
self.note(format!(
"{} event {}/{}",
self.tag,
event.kind(),
event.detail()
));
Some(event)
}
fn dispose(&self, ctx: &mut ComponentCtx<'_, '_, '_>, state: Self::State) {
ctx.record(format!("gauge dispose {}", state.identity));
self.note(format!("{} dispose", self.tag));
}
}
fn next_identity() -> u64 {
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT: AtomicU64 = AtomicU64::new(1);
NEXT.fetch_add(1, Ordering::Relaxed)
}
fn props(label: &str, value: i32) -> GaugeProps {
GaugeProps {
label: label.to_string(),
value,
}
}
fn mount(slot: SlotId, component: Gauge, props: GaugeProps) -> String {
let generation = publish(slot, Rc::new(component), props);
component_params(GAUGE_KIND, slot, generation, false)
}
#[test]
fn a_component_outside_the_built_in_controls_lives_the_whole_lifecycle() {
let log = Rc::new(RefCell::new(Vec::new()));
assert!(register_component::<Gauge>(GAUGE_KIND));
let params = mount(1, Gauge::new(&log, "first"), props("CPU", 10));
let mut calls = Vec::new();
{
let mut ctx = PlatformCtx::new(&mut calls);
with_runtime(|runtime| {
assert_eq!(runtime.create(&mut ctx, ¶ms).unwrap(), 1);
assert_eq!(runtime.live_count(), 1);
let params = mount(1, Gauge::new(&log, "second"), props("CPU", 10));
assert_eq!(
runtime.update_params(&mut ctx, ¶ms).unwrap(),
UpdateOutcome::Unchanged
);
let params = mount(1, Gauge::new(&log, "third"), props("CPU", 42));
assert_eq!(
runtime.update_params(&mut ctx, ¶ms).unwrap(),
UpdateOutcome::Applied
);
runtime.on_event(1, WireEvent { kind: 1, detail: 7 });
assert_eq!(runtime.dispose_slot(&mut ctx, 1), DisposeOutcome::Disposed);
assert_eq!(runtime.live_count(), 0);
})
.expect("the thread's runtime");
}
assert_eq!(
*log.borrow(),
vec![
"first create 'CPU'".to_string(),
"third update 10 -> 42".to_string(),
"third event 1/7".to_string(),
"third dispose".to_string(),
],
"one create, one update (the equal republish crossed nothing), \
and both later calls ran on the newest published component value"
);
assert_eq!(
calls.len(),
4,
"one platform call each, plus create's listener attach: {calls:?}"
);
assert!(calls[0].starts_with("gauge create"));
assert!(calls[1].starts_with("attachListener "), "{calls:?}");
assert!(calls[2].contains("10 -> 42"));
assert!(calls[3].starts_with("gauge dispose"));
}
#[test]
fn an_unchanged_props_republish_still_reaches_the_newest_component_value() {
let first = Rc::new(RefCell::new(Vec::new()));
let second = Rc::new(RefCell::new(Vec::new()));
register_component::<Gauge>(GAUGE_KIND);
let params = mount(20, Gauge::new(&first, "first"), props("CPU", 10));
let mut calls = Vec::new();
{
let mut ctx = PlatformCtx::new(&mut calls);
with_runtime(|runtime| {
runtime.create(&mut ctx, ¶ms).unwrap();
let republished = mount(20, Gauge::new(&second, "second"), props("CPU", 10));
assert_eq!(
republished, params,
"an equal-props rebuild leaves the wire untouched, which is \
exactly why `update` never runs to refresh anything"
);
runtime.on_event(20, WireEvent { kind: 3, detail: 9 });
assert_eq!(runtime.dispose_slot(&mut ctx, 20), DisposeOutcome::Disposed);
assert_eq!(runtime.live_count(), 0);
})
.expect("the thread's runtime");
}
forget(20);
assert_eq!(
*first.borrow(),
vec!["first create 'CPU'".to_string()],
"the value `create` ran with must not keep serving a later \
rebuild's events and disposal"
);
assert_eq!(
*second.borrow(),
vec!["second event 3/9".to_string(), "second dispose".to_string(),],
"both later calls ran on the component value the app published \
most recently — the trait's `on_event` guarantee, literally"
);
}
#[test]
fn a_dispose_after_the_staging_reaper_keeps_the_retained_component() {
let first = Rc::new(RefCell::new(Vec::new()));
let second = Rc::new(RefCell::new(Vec::new()));
register_component::<Gauge>(GAUGE_KIND);
let params = mount(21, Gauge::new(&first, "first"), props("Mem", 1));
let mut calls = Vec::new();
{
let mut ctx = PlatformCtx::new(&mut calls);
with_runtime(|runtime| {
runtime.create(&mut ctx, ¶ms).unwrap();
mount(21, Gauge::new(&second, "second"), props("Mem", 1));
forget(21);
assert_eq!(staged_count(), 0);
assert_eq!(runtime.dispose_slot(&mut ctx, 21), DisposeOutcome::Disposed);
assert_eq!(runtime.live_count(), 0);
})
.expect("the thread's runtime");
}
assert_eq!(
*first.borrow(),
vec![
"first create 'Mem'".to_string(),
"first dispose".to_string()
],
"with the staged entry gone the retained value runs the dispose"
);
assert!(
second.borrow().is_empty(),
"a reaped entry is never resurrected: {:?}",
second.borrow()
);
}
struct LegacyControl;
#[derive(Clone, Debug, PartialEq)]
struct LegacyProps {
text: String,
}
const LEGACY_KIND: &str = "test-legacy";
impl NativeWidget for LegacyControl {
type Props = LegacyProps;
type State = u64;
fn decode_props(params: &Params<'_>) -> Result<Self::Props, NativeWidgetError> {
Ok(LegacyProps {
text: params
.string("text")
.ok_or_else(|| NativeWidgetError::Params("no `text`".into()))?
.into_owned(),
})
}
fn create(
ctx: &mut PlatformCtx<'_, '_>,
props: &Self::Props,
) -> Result<(NativeView, Self::State), NativeWidgetError> {
let identity = next_identity();
ctx.record(format!("legacy create {identity} '{}'", props.text));
Ok((NativeView { identity }, identity))
}
fn update(
ctx: &mut PlatformCtx<'_, '_>,
state: &mut Self::State,
_old: &Self::Props,
new: &Self::Props,
) -> Result<(), NativeWidgetError> {
ctx.record(format!("legacy update {state} '{}'", new.text));
Ok(())
}
fn dispose(
ctx: &mut PlatformCtx<'_, '_>,
state: Self::State,
) -> Result<(), NativeWidgetError> {
ctx.record(format!("legacy dispose {state}"));
Ok(())
}
}
#[test]
fn a_public_component_and_an_internal_widget_share_one_dispatch_table() {
let log = Rc::new(RefCell::new(Vec::new()));
assert!(register_component::<Gauge>(GAUGE_KIND));
assert!(
with_runtime(|runtime| runtime.register_if_free::<LegacyControl>(LEGACY_KIND))
.expect("the thread's runtime")
);
let component = mount(10, Gauge::new(&log, "g"), props("Load", 1));
let legacy = crate::runtime::with_identity(LEGACY_KIND, 11, "\"text\":\"Save\"");
let mut calls = Vec::new();
{
let mut ctx = PlatformCtx::new(&mut calls);
with_runtime(|runtime| {
runtime.create(&mut ctx, &component).unwrap();
runtime.create(&mut ctx, &legacy).unwrap();
assert_eq!(runtime.live_count(), 2);
assert_eq!(
runtime.update_params(&mut ctx, &legacy).unwrap(),
UpdateOutcome::Unchanged
);
let changed = mount(10, Gauge::new(&log, "g"), props("Load", 2));
assert_eq!(
runtime.update_params(&mut ctx, &changed).unwrap(),
UpdateOutcome::Applied
);
assert_eq!(runtime.dispose_slot(&mut ctx, 10), DisposeOutcome::Disposed);
assert_eq!(runtime.dispose_slot(&mut ctx, 11), DisposeOutcome::Disposed);
assert_eq!(runtime.live_count(), 0, "the shared leak bar returns to 0");
})
.expect("the thread's runtime");
}
forget(10);
let kinds: Vec<&str> = calls
.iter()
.map(|call| call.split(' ').next().unwrap())
.collect();
assert_eq!(
kinds,
vec![
"gauge",
"attachListener",
"legacy",
"gauge",
"gauge",
"legacy"
],
"each command reached its own kind's impl and nobody else's: {calls:?}"
);
}
#[test]
fn the_staging_table_is_reaped_by_forget_and_strands_nothing() {
let log = Rc::new(RefCell::new(Vec::new()));
register_component::<Gauge>(GAUGE_KIND);
let params = mount(2, Gauge::new(&log, "only"), props("Mem", 1));
assert_eq!(staged_count(), 1);
assert_eq!(Rc::strong_count(&log), 2, "the staging table holds it");
let mut calls = Vec::new();
{
let mut ctx = PlatformCtx::new(&mut calls);
with_runtime(|runtime| {
runtime.create(&mut ctx, ¶ms).unwrap();
let identity = runtime.instance(2).unwrap().view().identity;
let (slot, instance) = runtime
.take_matching(|view| view.identity == identity)
.expect("the live view");
assert_eq!(slot, 2);
instance.dispose(&mut ctx).unwrap();
assert_eq!(runtime.live_count(), 0);
})
.expect("the thread's runtime");
}
assert_eq!(
staged_count(),
1,
"disposal alone leaves the staged entry — the same leak shape as above"
);
forget(2);
assert_eq!(staged_count(), 0);
assert_eq!(
Rc::strong_count(&log),
1,
"the component value was dropped, not merely unreachable"
);
forget(2);
assert_eq!(staged_count(), 0);
}
#[test]
fn publish_bumps_the_generation_only_when_the_props_change() {
let log = Rc::new(RefCell::new(Vec::new()));
let first = mount(3, Gauge::new(&log, "a"), props("Disk", 1));
let same = mount(3, Gauge::new(&log, "b"), props("Disk", 1));
let changed = mount(3, Gauge::new(&log, "c"), props("Disk", 2));
assert_eq!(
first, same,
"an unchanged publish changes nothing on the wire"
);
assert_ne!(same, changed);
assert!(changed.contains(PROPS_GENERATION_KEY));
let generation = publish(3, Rc::new(Gauge::new(&log, "c")), props("Disk", 2));
let light = component_params(GAUGE_KIND, 3, generation, false);
let dark = component_params(GAUGE_KIND, 3, generation, true);
assert_ne!(
light, dark,
"an unchanged props republish with a flipped brightness must still change the wire"
);
assert_eq!(Params::new(&light).flag(DARK), Some(false));
assert_eq!(Params::new(&dark).flag(DARK), Some(true));
forget(3);
assert_eq!(mount(3, Gauge::new(&log, "d"), props("Disk", 2)), first);
}
#[test]
fn registration_is_first_wins_and_never_shadows_a_registered_kind() {
assert!(register_component::<Gauge>(GAUGE_KIND));
assert!(
!register_component::<Gauge>(GAUGE_KIND),
"a second registration is refused, not silently replaced"
);
}
#[test]
fn a_slot_with_no_published_props_fails_instead_of_dispatching() {
register_component::<Gauge>(GAUGE_KIND);
let params = component_params(GAUGE_KIND, 4, 0, false);
let mut calls = Vec::new();
let mut ctx = PlatformCtx::new(&mut calls);
let error = with_runtime(|runtime| runtime.create(&mut ctx, ¶ms))
.expect("the thread's runtime")
.expect_err("nothing was published for slot 4");
assert!(matches!(error, NativeWidgetError::Params(_)), "{error:?}");
assert!(calls.is_empty(), "nothing crossed the boundary");
}
#[test]
fn a_component_that_builds_no_view_fails_its_slot_with_the_latched_error() {
let log = Rc::new(RefCell::new(Vec::new()));
register_component::<Gauge>(GAUGE_KIND);
let params = mount(5, Gauge::new(&log, "doomed"), props(FAIL_CREATE, 0));
let mut calls = Vec::new();
let mut ctx = PlatformCtx::new(&mut calls);
let error = with_runtime(|runtime| {
let outcome = runtime.create(&mut ctx, ¶ms);
assert_eq!(runtime.live_count(), 0, "no instance was retained");
outcome
})
.expect("the thread's runtime")
.expect_err("the component answered None");
assert!(
matches!(&error, NativeWidgetError::Platform(message)
if message.contains("no native view today")),
"the latched error names the failure, not a generic one: {error:?}"
);
forget(5);
}
#[test]
fn an_update_that_latches_keeps_the_diff_baseline_so_the_change_retries() {
let log = Rc::new(RefCell::new(Vec::new()));
register_component::<Gauge>(GAUGE_KIND);
let params = mount(6, Gauge::new(&log, "g"), props("Net", 1));
let mut calls = Vec::new();
{
let mut ctx = PlatformCtx::new(&mut calls);
with_runtime(|runtime| {
runtime.create(&mut ctx, ¶ms).unwrap();
let failing = mount(6, Gauge::new(&log, "g"), props(FAIL_UPDATE, 2));
assert!(runtime.update_params(&mut ctx, &failing).is_err());
let recovered = mount(6, Gauge::new(&log, "g"), props("Net", 3));
assert_eq!(
runtime.update_params(&mut ctx, &recovered).unwrap(),
UpdateOutcome::Applied
);
})
.expect("the thread's runtime");
}
assert_eq!(
*log.borrow(),
vec!["g create 'Net'".to_string(), "g update 1 -> 3".to_string()],
"the failed update applied nothing and left the baseline at create's props"
);
forget(6);
}
#[test]
fn a_failed_update_retries_even_when_the_app_republishes_identical_props() {
let log = Rc::new(RefCell::new(Vec::new()));
register_component::<Gauge>(GAUGE_KIND);
let created = mount(60, Gauge::new(&log, "g"), props("Net", 1));
let mut calls = Vec::new();
{
let mut ctx = PlatformCtx::new(&mut calls);
with_runtime(|runtime| {
runtime.create(&mut ctx, &created).unwrap();
let failing = mount(60, Gauge::new(&log, "g"), props(FAIL_UPDATE_ONCE, 2));
assert_ne!(failing, created, "changed props changed the wire");
assert!(runtime.update_params(&mut ctx, &failing).is_err());
let republished = mount(60, Gauge::new(&log, "g"), props(FAIL_UPDATE_ONCE, 2));
assert_ne!(
republished, failing,
"a failed update must force the next publish to bump the props \
generation, identical props or not — otherwise the differ emits \
nothing and the change is lost for the process lifetime"
);
assert_eq!(
runtime.update_params(&mut ctx, &republished).unwrap(),
UpdateOutcome::Applied,
"the retry reached the component and applied"
);
let settled = mount(60, Gauge::new(&log, "g"), props(FAIL_UPDATE_ONCE, 2));
assert_eq!(
settled, republished,
"the retry mark is consumed by the bump it forced"
);
assert_eq!(
runtime.update_params(&mut ctx, &settled).unwrap(),
UpdateOutcome::Unchanged
);
assert_eq!(runtime.dispose_slot(&mut ctx, 60), DisposeOutcome::Disposed);
})
.expect("the thread's runtime");
}
forget(60);
assert_eq!(
*log.borrow(),
vec![
"g create 'Net'".to_string(),
"g update failed".to_string(),
"g update 1 -> 2".to_string(),
"g dispose".to_string(),
],
"the change was attempted, failed, retried against the ORIGINAL baseline \
(1, not 2 — the failed attempt advanced nothing), and applied"
);
}
fn republish_identical(
runtime: &mut crate::runtime::NativeRuntime,
ctx: &mut PlatformCtx<'_, '_>,
slot: SlotId,
log: &Rc<RefCell<Vec<String>>>,
label: &str,
value: i32,
rebuilds: usize,
) -> Vec<String> {
let mut wire = Vec::with_capacity(rebuilds);
let mut previous: Option<String> = None;
for _ in 0..rebuilds {
let params = mount(slot, Gauge::new(log, "g"), props(label, value));
if previous.as_deref() != Some(params.as_str()) {
let _ = runtime.update_params(ctx, ¶ms);
}
previous = Some(params.clone());
wire.push(params);
}
wire
}
#[test]
fn a_permanently_failing_update_stops_retrying_at_the_cap() {
install_log_capture();
let log = Rc::new(RefCell::new(Vec::new()));
register_component::<Gauge>(GAUGE_KIND);
let created = mount(62, Gauge::new(&log, "g"), props("Net", 1));
let mut calls = Vec::new();
{
let mut ctx = PlatformCtx::new(&mut calls);
with_runtime(|runtime| {
runtime.create(&mut ctx, &created).unwrap();
let wire =
republish_identical(runtime, &mut ctx, 62, &log, FAIL_UPDATE_ALWAYS, 2, 10);
assert_ne!(wire[0], created, "changed props changed the wire");
assert_ne!(wire[1], wire[0], "failure 1 forced a retry bump");
assert_ne!(wire[2], wire[1], "failure 2 forced a retry bump");
for (index, params) in wire.iter().enumerate().skip(3) {
assert_eq!(
*params, wire[2],
"rebuild {index} must leave the wire untouched: three consecutive \
failures spend the budget, and an unchanged rebuild is back to \
costing zero FFI crossings"
);
}
assert_eq!(runtime.dispose_slot(&mut ctx, 62), DisposeOutcome::Disposed);
})
.expect("the thread's runtime");
}
forget(62);
assert_eq!(
*log.borrow(),
vec![
"g create 'Net'".to_string(),
"g update refused".to_string(),
"g update refused".to_string(),
"g update refused".to_string(),
"g dispose".to_string(),
],
"the component was asked exactly RETRY_UPDATE_BUDGET (3) times across ten \
rebuilds, then never again — the observed behaviour at the cap is *inert*, \
not slower retries"
);
assert!(
logged("no further retries will be scheduled"),
"giving up is reported once, at warn — a slot that silently stops trying is \
the other way to lose a native view"
);
}
#[test]
fn a_successful_update_restores_the_whole_retry_budget() {
let log = Rc::new(RefCell::new(Vec::new()));
register_component::<Gauge>(GAUGE_KIND);
let created = mount(63, Gauge::new(&log, "g"), props("Net", 1));
let mut calls = Vec::new();
{
let mut ctx = PlatformCtx::new(&mut calls);
with_runtime(|runtime| {
runtime.create(&mut ctx, &created).unwrap();
republish_identical(runtime, &mut ctx, 63, &log, FAIL_UPDATE_ALWAYS, 2, 2);
let recovered = mount(63, Gauge::new(&log, "g"), props("Net", 3));
assert_eq!(
runtime.update_params(&mut ctx, &recovered).unwrap(),
UpdateOutcome::Applied
);
republish_identical(runtime, &mut ctx, 63, &log, FAIL_UPDATE_ALWAYS, 6, 10);
assert_eq!(runtime.dispose_slot(&mut ctx, 63), DisposeOutcome::Disposed);
})
.expect("the thread's runtime");
}
forget(63);
assert_eq!(
*log.borrow(),
vec![
"g create 'Net'".to_string(),
"g update refused".to_string(),
"g update refused".to_string(),
"g update 1 -> 3".to_string(),
"g update refused".to_string(),
"g update refused".to_string(),
"g update refused".to_string(),
"g dispose".to_string(),
],
"two refusals, a success that cleared the count, then a full budget of three"
);
}
#[test]
fn a_later_error_inside_a_local_frame_is_logged_rather_than_swallowed() {
const ROOT: &str = "m-01 root failure";
const INSIDE_FRAME: &str = "m-01 failure raised inside the frame";
install_log_capture();
let mut calls = Vec::new();
let mut ctx = PlatformCtx::new(&mut calls);
let mut cx = ComponentCtx::new(&mut ctx, 0);
cx.report_error(ROOT);
assert!(cx.failed());
let value = cx.with_local_frame(4, |inner| {
assert!(
inner.failed(),
"a frame carries its caller's latch — the property Android used to \
answer `false` to"
);
inner.report_error(INSIDE_FRAME);
None::<u64>
});
assert!(value.is_none());
let error = cx.into_parts().0.expect("the root failure still reports");
assert!(
matches!(&error, NativeWidgetError::Platform(message) if message == ROOT),
"the first error stays the reported one: {error:?}"
);
assert!(
logged(INSIDE_FRAME),
"the frame's error must still reach the log — a swallowed platform \
failure is the defect, not the first-wins report"
);
}
#[test]
fn every_dispatch_runs_the_boundary_exception_guard() {
let log = Rc::new(RefCell::new(Vec::new()));
register_component::<Gauge>(GAUGE_KIND);
let before = dispatch_guard_count();
let params = mount(61, Gauge::new(&log, "g"), props("Net", 1));
let mut calls = Vec::new();
{
let mut ctx = PlatformCtx::new(&mut calls);
with_runtime(|runtime| {
runtime.create(&mut ctx, ¶ms).unwrap();
let changed = mount(61, Gauge::new(&log, "g"), props("Net", 2));
assert_eq!(
runtime.update_params(&mut ctx, &changed).unwrap(),
UpdateOutcome::Applied
);
runtime.on_event(61, WireEvent { kind: 1, detail: 0 });
assert_eq!(runtime.dispose_slot(&mut ctx, 61), DisposeOutcome::Disposed);
})
.expect("the thread's runtime");
}
forget(61);
assert_eq!(
dispatch_guard_count() - before,
4,
"create, update, dispose AND on_event are guarded — the fourth is \
the one this test used to license the absence of"
);
}
struct Twin;
impl NativeComponent for Twin {
type Props = GaugeProps;
type State = u64;
fn create(
&self,
ctx: &mut ComponentCtx<'_, '_, '_>,
_props: &Self::Props,
) -> Option<(NativeRoot, Self::State)> {
let identity = next_identity();
Some((ctx.root(identity)?, identity))
}
fn update(
&self,
_ctx: &mut ComponentCtx<'_, '_, '_>,
_state: &mut Self::State,
_old: &Self::Props,
_new: &Self::Props,
) {
}
}
#[test]
fn case_a_a_kind_nothing_registered_fails_with_unknown_control() {
let log = Rc::new(RefCell::new(Vec::new()));
let generation = publish(70, Rc::new(Gauge::new(&log, "g")), props("CPU", 1));
let params = component_params("test-never-registered", 70, generation, false);
let mut calls = Vec::new();
let mut ctx = PlatformCtx::new(&mut calls);
let error = with_runtime(|runtime| {
let outcome = runtime.create(&mut ctx, ¶ms);
assert_eq!(runtime.live_count(), 0, "fails closed: no instance");
outcome
})
.expect("the thread's runtime")
.expect_err("nothing is registered under that kind");
assert!(
matches!(&error, NativeWidgetError::UnknownControl(kind)
if kind == "test-never-registered"),
"{error:?}"
);
assert!(calls.is_empty(), "nothing crossed the boundary");
forget(70);
}
#[test]
fn case_c_a_kind_registered_to_another_component_fails_at_decode() {
let log = Rc::new(RefCell::new(Vec::new()));
register_component::<Gauge>(GAUGE_KIND);
let generation = publish(
71,
Rc::new(Card {
children: 1,
fail_after: None,
log: Rc::clone(&log),
}),
CardProps {
title: "wrong kind".to_string(),
},
);
let params = component_params(GAUGE_KIND, 71, generation, false);
let mut calls = Vec::new();
let mut ctx = PlatformCtx::new(&mut calls);
let error = with_runtime(|runtime| {
let outcome = runtime.create(&mut ctx, ¶ms);
assert_eq!(runtime.live_count(), 0, "fails closed: no instance");
outcome
})
.expect("the thread's runtime")
.expect_err("the staged props are another component's");
assert!(
matches!(&error, NativeWidgetError::Params(message)
if message.contains("staged a different component's props")
&& message.contains(GAUGE_KIND)),
"the message must name the wiring bug, not read as a reaped entry: {error:?}"
);
assert!(calls.is_empty(), "nothing crossed the boundary");
forget(71);
}
#[test]
fn case_c_two_components_sharing_a_props_type_fail_one_step_later() {
register_component::<Gauge>(GAUGE_KIND);
let generation = publish(72, Rc::new(Twin), props("CPU", 1));
let params = component_params(GAUGE_KIND, 72, generation, false);
let mut calls = Vec::new();
let mut ctx = PlatformCtx::new(&mut calls);
let error = with_runtime(|runtime| {
let outcome = runtime.create(&mut ctx, ¶ms);
assert_eq!(runtime.live_count(), 0, "fails closed: no instance");
outcome
})
.expect("the thread's runtime")
.expect_err("the staged component is a `Twin`, not a `Gauge`");
assert!(
matches!(&error, NativeWidgetError::Params(message)
if message.contains("staged a different component's component")),
"{error:?}"
);
assert!(calls.is_empty(), "nothing crossed the boundary");
forget(72);
}
#[test]
fn case_d_a_second_component_under_one_kind_is_refused_and_never_shadows() {
install_log_capture();
assert!(register_component::<Gauge>(GAUGE_KIND));
assert!(
!register_component::<Twin>(GAUGE_KIND),
"the second component under one kind is refused"
);
assert!(
logged("is already registered"),
"and says so — a silent refusal is how a component 'never appears'"
);
let generation = publish(73, Rc::new(Twin), props("CPU", 1));
let params = component_params(GAUGE_KIND, 73, generation, false);
let mut calls = Vec::new();
let mut ctx = PlatformCtx::new(&mut calls);
let error = with_runtime(|runtime| runtime.create(&mut ctx, ¶ms))
.expect("the thread's runtime")
.expect_err("the loser's slot must not run the incumbent's code");
assert!(matches!(&error, NativeWidgetError::Params(_)), "{error:?}");
forget(73);
}
struct Card {
children: usize,
fail_after: Option<usize>,
log: Rc<RefCell<Vec<String>>>,
}
#[derive(Clone, Debug, PartialEq)]
struct CardProps {
title: String,
}
struct CardState {
root: u64,
children: Vec<NativeChild>,
}
const CARD_KIND: &str = "test-card";
impl NativeComponent for Card {
type Props = CardProps;
type State = CardState;
fn create(
&self,
ctx: &mut ComponentCtx<'_, '_, '_>,
props: &Self::Props,
) -> Option<(NativeRoot, Self::State)> {
let root = next_identity();
ctx.record(format!("card create {root} '{}'", props.title));
let children = ctx.with_local_frame(self.children + 2, |ctx| {
let mut retained = Vec::with_capacity(self.children);
for index in 0..self.children {
if self.fail_after == Some(index) {
ctx.report_error("card: the platform ran out of views");
return None;
}
let child = next_identity();
ctx.add_child(root, child)?;
retained.push(ctx.retain_child(child)?);
}
Some(retained)
})?;
self.log
.borrow_mut()
.push(format!("card create {} children", children.len()));
Some((ctx.root(root)?, CardState { root, children }))
}
fn update(
&self,
ctx: &mut ComponentCtx<'_, '_, '_>,
state: &mut Self::State,
_old: &Self::Props,
new: &Self::Props,
) {
ctx.record(format!(
"card update {} '{}' over {} children",
state.root,
new.title,
state.children.len()
));
}
fn dispose(&self, ctx: &mut ComponentCtx<'_, '_, '_>, state: Self::State) {
ctx.record(format!(
"card dispose {} with {} children",
state.root,
state.children.len()
));
self.log.borrow_mut().push("card dispose".to_string());
}
}
fn mount_card(slot: SlotId, card: Card, title: &str) -> String {
let generation = publish(
slot,
Rc::new(card),
CardProps {
title: title.to_string(),
},
);
component_params(CARD_KIND, slot, generation, false)
}
#[test]
fn a_component_builds_a_native_subtree_and_releases_every_child() {
const CHILDREN: usize = 50;
assert_eq!(live_child_count(), 0, "this test thread starts clean");
assert!(register_component::<Card>(CARD_KIND));
let log = Rc::new(RefCell::new(Vec::new()));
let params = mount_card(
7,
Card {
children: CHILDREN,
fail_after: None,
log: Rc::clone(&log),
},
"Now playing",
);
let mut calls = Vec::new();
{
let mut ctx = PlatformCtx::new(&mut calls);
with_runtime(|runtime| {
assert_eq!(runtime.create(&mut ctx, ¶ms).unwrap(), 7);
assert_eq!(
runtime.live_count(),
1,
"N children ship as ONE slot — that is the whole point"
);
assert_eq!(
live_child_count(),
CHILDREN,
"every child is retained while the component is live"
);
let changed = mount_card(
7,
Card {
children: CHILDREN,
fail_after: None,
log: Rc::clone(&log),
},
"Up next",
);
assert_eq!(
runtime.update_params(&mut ctx, &changed).unwrap(),
UpdateOutcome::Applied,
"a subtree component diffs exactly like a leaf one"
);
assert_eq!(runtime.dispose_slot(&mut ctx, 7), DisposeOutcome::Disposed);
assert_eq!(runtime.live_count(), 0);
})
.expect("the thread's runtime");
}
forget(7);
assert_eq!(
live_child_count(),
0,
"every child was released with its parent"
);
assert_eq!(staged_count(), 0);
assert!(calls[0].starts_with("card create"), "{calls:?}");
assert_eq!(calls[1], format!("pushLocalFrame {}", CHILDREN + 2));
let root = calls[0]
.split(' ')
.nth(2)
.expect("the root identity")
.to_string();
let attached: Vec<&String> = calls
.iter()
.filter(|call| call.starts_with("addChild "))
.collect();
assert_eq!(attached.len(), CHILDREN, "one addChild per child");
assert!(
attached
.iter()
.all(|call| call.starts_with(&format!("addChild {root} <- "))),
"every child went under the component's own root: {attached:?}"
);
assert_eq!(
calls
.iter()
.filter(|call| call.starts_with("retainChild "))
.count(),
CHILDREN
);
let first_add = calls
.iter()
.position(|call| call.starts_with("addChild "))
.expect("an addChild");
let last_add = calls
.iter()
.rposition(|call| call.starts_with("addChild "))
.expect("an addChild");
let popped = calls
.iter()
.position(|call| call == "popLocalFrame")
.expect("the frame pops");
assert!(
first_add > 1 && last_add < popped,
"the whole subtree build ran inside ONE local frame: {calls:?}"
);
assert!(calls[popped + 1].contains("over 50 children"), "{calls:?}");
assert!(
calls.last().unwrap().starts_with("card dispose"),
"{calls:?}"
);
assert_eq!(
*log.borrow(),
vec![
"card create 50 children".to_string(),
"card dispose".to_string()
],
);
}
#[test]
fn a_half_built_subtree_strands_no_children() {
assert_eq!(live_child_count(), 0);
register_component::<Card>(CARD_KIND);
let log = Rc::new(RefCell::new(Vec::new()));
let params = mount_card(
8,
Card {
children: 20,
fail_after: Some(12),
log: Rc::clone(&log),
},
"doomed",
);
let mut calls = Vec::new();
let mut ctx = PlatformCtx::new(&mut calls);
let error = with_runtime(|runtime| {
let outcome = runtime.create(&mut ctx, ¶ms);
assert_eq!(runtime.live_count(), 0, "no instance was retained");
outcome
})
.expect("the thread's runtime")
.expect_err("the component gave up on its subtree");
assert!(
matches!(&error, NativeWidgetError::Platform(message)
if message.contains("ran out of views")),
"the latched error names the failure: {error:?}"
);
assert_eq!(
live_child_count(),
0,
"the twelve children built before the failure were released, not stranded"
);
assert!(
calls.iter().any(|call| call == "popLocalFrame"),
"the local frame is popped on the failure path too: {calls:?}"
);
forget(8);
}
thread_local! {
static RELAYED: Cell<usize> = const { Cell::new(0) };
}
fn relayed() -> usize {
RELAYED.with(Cell::get)
}
const LISTEN: &str = "listen";
const RELAY_KIND: &str = "test-relay";
struct Relay;
impl NativeComponent for Relay {
type Props = GaugeProps;
type State = (u64, Option<ListenerHandle>);
fn create(
&self,
ctx: &mut ComponentCtx<'_, '_, '_>,
props: &Self::Props,
) -> Option<(NativeRoot, Self::State)> {
let identity = next_identity();
let listener = if props.label == LISTEN {
Some(ctx.attach_listener(identity, ListenerKinds::CLICK)?)
} else {
None
};
Some((ctx.root(identity)?, (identity, listener)))
}
fn update(
&self,
_ctx: &mut ComponentCtx<'_, '_, '_>,
_state: &mut Self::State,
_old: &Self::Props,
_new: &Self::Props,
) {
}
fn on_event(
&self,
_state: &mut Self::State,
props: &Self::Props,
event: NativeEvent,
) -> Option<NativeEvent> {
RELAYED.with(|count| count.set(count.get() + 1));
event
.is_click()
.then(|| NativeEvent::from_payload(EventPayload::Toggled(props.value > 0)))
}
}
fn click() -> WireEvent {
WireEvent {
kind: EVENT_KIND_CLICK,
detail: 0,
}
}
#[test]
fn bridge_on_event_returns_the_component_answer_only_for_an_attached_family() {
let mut calls = Vec::new();
let mut ctx = PlatformCtx::new(&mut calls);
publish(90, Rc::new(Relay), props(LISTEN, 5));
let (_view, mut listening) = Bridge::<Relay>::create(
&mut ctx,
&BridgeProps {
slot: 90,
props: props(LISTEN, 5),
},
)
.expect("a listening relay builds");
assert_eq!(live_listener_count(), 1, "create attached one listener");
let before = relayed();
assert_eq!(
Bridge::<Relay>::on_event(&mut listening, click()),
Some(EventPayload::Toggled(true)),
"a click on an attached CLICK listener returns the component's own \
answer (a Toggled built from its props), not the click that arrived"
);
assert_eq!(relayed(), before + 1);
assert_eq!(
Bridge::<Relay>::on_event(
&mut listening,
WireEvent {
kind: EVENT_KIND_VALUE_CHANGED,
detail: pack_value_changed(3, true),
},
),
None
);
assert_eq!(relayed(), before + 1, "the gate never asked the component");
publish(91, Rc::new(Relay), props("quiet", 5));
let (_view, mut quiet) = Bridge::<Relay>::create(
&mut ctx,
&BridgeProps {
slot: 91,
props: props("quiet", 5),
},
)
.expect("a quiet relay builds");
assert_eq!(Bridge::<Relay>::on_event(&mut quiet, click()), None);
assert_eq!(
relayed(),
before + 1,
"an unattached slot never reaches the component — the misroute a \
fabricated slot id used to be able to cause stops at the bridge"
);
drop((listening, quiet));
assert_eq!(
calls
.iter()
.filter(|call| call.starts_with("attachListener "))
.count(),
1,
"only the listening relay attached: {calls:?}"
);
assert_eq!(
live_listener_count(),
0,
"the handle is released with the state"
);
forget(90);
forget(91);
}
#[test]
fn an_attached_listener_reaches_the_slot_callback_through_the_runtime() {
use std::sync::Arc;
assert!(register_component::<Relay>(RELAY_KIND));
let generation = publish(92, Rc::new(Relay), props(LISTEN, 0));
let params = component_params(RELAY_KIND, 92, generation, false);
let fired: Arc<Mutex<Vec<EventPayload>>> = Arc::new(Mutex::new(Vec::new()));
let recorder = Arc::clone(&fired);
let mut calls = Vec::new();
{
let mut ctx = PlatformCtx::new(&mut calls);
with_runtime(|runtime| {
assert!(runtime.set_callback(
92,
Arc::new(move |payload| recorder.lock().unwrap().push(payload)),
));
runtime.create(&mut ctx, ¶ms).unwrap();
runtime.on_event(92, click());
assert_eq!(runtime.dispose_slot(&mut ctx, 92), DisposeOutcome::Disposed);
})
.expect("the thread's runtime");
}
forget(92);
assert_eq!(
*fired.lock().unwrap(),
vec![EventPayload::Toggled(false)],
"the component's answer — built from its value-0 props — reached the callback"
);
assert_eq!(live_listener_count(), 0);
}
#[test]
fn a_listener_attached_in_update_opens_the_gate_too() {
struct Late;
impl NativeComponent for Late {
type Props = GaugeProps;
type State = (u64, Option<ListenerHandle>);
fn create(
&self,
ctx: &mut ComponentCtx<'_, '_, '_>,
_props: &Self::Props,
) -> Option<(NativeRoot, Self::State)> {
let identity = next_identity();
Some((ctx.root(identity)?, (identity, None)))
}
fn update(
&self,
ctx: &mut ComponentCtx<'_, '_, '_>,
state: &mut Self::State,
_old: &Self::Props,
_new: &Self::Props,
) {
state.1 = ctx.attach_listener(state.0, ListenerKinds::TOGGLED);
}
}
let mut calls = Vec::new();
let mut ctx = PlatformCtx::new(&mut calls);
publish(93, Rc::new(Late), props("late", 1));
let first = BridgeProps {
slot: 93,
props: props("late", 1),
};
let (_view, mut state) = Bridge::<Late>::create(&mut ctx, &first).unwrap();
let toggled = WireEvent {
kind: EVENT_KIND_TOGGLED,
detail: pack_bool(true),
};
assert_eq!(Bridge::<Late>::on_event(&mut state, toggled), None);
let second = BridgeProps {
slot: 93,
props: props("late", 2),
};
Bridge::<Late>::update(&mut ctx, &mut state, &first, &second).unwrap();
assert_eq!(
Bridge::<Late>::on_event(&mut state, toggled),
Some(EventPayload::Toggled(true)),
"the default `on_event` forwards the event unchanged once attached"
);
forget(93);
}
#[test]
fn an_empty_listener_request_latches_instead_of_attaching() {
let mut calls = Vec::new();
let mut ctx = PlatformCtx::new(&mut calls);
let mut cx = ComponentCtx::new(&mut ctx, 94);
assert!(cx.attach_listener(1, ListenerKinds::NONE).is_none());
assert!(cx.failed(), "an empty request is a wiring bug, reported");
let (error, attached) = cx.into_parts();
assert!(
matches!(error, Some(NativeWidgetError::Params(_))),
"{error:?}"
);
assert!(attached.is_empty());
assert!(calls.is_empty(), "nothing was recorded as attached");
assert_eq!(live_listener_count(), 0);
}
#[test]
fn detach_listener_records_the_detach_and_releases_the_handle() {
let mut calls = Vec::new();
let mut ctx = PlatformCtx::new(&mut calls);
let mut cx = ComponentCtx::new(&mut ctx, 95);
let handle = cx
.attach_listener(7, ListenerKinds::CLICK | ListenerKinds::TOGGLED)
.expect("the host arm always attaches");
assert_eq!(
handle.kinds(),
ListenerKinds::CLICK | ListenerKinds::TOGGLED
);
assert_eq!(live_listener_count(), 1);
assert_eq!(cx.detach_listener(handle), Some(()));
assert_eq!(live_listener_count(), 0);
drop(cx);
assert_eq!(
calls,
vec![
"attachListener 7 click|toggled".to_string(),
"detachListener 7 click|toggled".to_string(),
]
);
}
#[test]
fn replacing_a_handle_on_the_same_view_keeps_the_new_listener_live() {
struct Rewire;
impl NativeComponent for Rewire {
type Props = GaugeProps;
type State = (u64, Option<ListenerHandle>);
fn create(
&self,
ctx: &mut ComponentCtx<'_, '_, '_>,
_props: &Self::Props,
) -> Option<(NativeRoot, Self::State)> {
let identity = next_identity();
let listener = ctx.attach_listener(identity, ListenerKinds::CLICK)?;
Some((ctx.root(identity)?, (identity, Some(listener))))
}
fn update(
&self,
ctx: &mut ComponentCtx<'_, '_, '_>,
state: &mut Self::State,
_old: &Self::Props,
_new: &Self::Props,
) {
state.1 = ctx.attach_listener(state.0, ListenerKinds::CLICK);
}
}
let mut calls = Vec::new();
let mut ctx = PlatformCtx::new(&mut calls);
publish(96, Rc::new(Rewire), props("rewire", 1));
let first = BridgeProps {
slot: 96,
props: props("rewire", 1),
};
let (_view, mut state) = Bridge::<Rewire>::create(&mut ctx, &first).unwrap();
assert_eq!(live_listener_count(), 1, "create attached handle A");
let second = BridgeProps {
slot: 96,
props: props("rewire", 2),
};
Bridge::<Rewire>::update(&mut ctx, &mut state, &first, &second).unwrap();
assert_eq!(
live_listener_count(),
1,
"handle A was released by the reassignment and handle B is live"
);
assert_eq!(
Bridge::<Rewire>::on_event(&mut state, click()),
Some(EventPayload::Click),
"a click is still admitted after the old handle dropped"
);
drop(state);
assert_eq!(live_listener_count(), 0, "handle B goes with the state");
assert_eq!(
calls
.iter()
.filter(|call| call.starts_with("attachListener "))
.count(),
2,
"one attach in create, one in update: {calls:?}"
);
forget(96);
}
}
#[cfg(test)]
mod listener_tests {
use super::*;
#[test]
fn listener_kinds_combine_and_spell_themselves() {
let both = ListenerKinds::CLICK | ListenerKinds::VALUE_CHANGED;
assert!(both.contains(ListenerKinds::CLICK));
assert!(both.contains(ListenerKinds::VALUE_CHANGED));
assert!(!both.contains(ListenerKinds::TOGGLED));
assert!(both.contains(ListenerKinds::NONE));
assert!(ListenerKinds::NONE.is_empty());
assert!(!both.is_empty());
assert_eq!(both.to_string(), "click|value_changed");
assert_eq!(ListenerKinds::NONE.to_string(), "none");
let mut grown = ListenerKinds::NONE;
grown |= ListenerKinds::TOGGLED;
assert_eq!(grown, ListenerKinds::TOGGLED);
}
#[test]
fn every_payload_round_trips_through_the_public_pair() {
for payload in [
EventPayload::Click,
EventPayload::Toggled(true),
EventPayload::Toggled(false),
EventPayload::ValueChanged {
value: 42,
from_user: true,
},
EventPayload::ValueChanged {
value: 0,
from_user: false,
},
EventPayload::DragStart,
EventPayload::DragEnd,
] {
let event = NativeEvent::from_payload(payload);
assert_eq!(event.into_payload(), Some(payload), "{payload:?}");
}
let unknown = NativeEvent {
kind: 99,
detail: 0,
};
assert_eq!(unknown.into_payload(), None);
assert_eq!(unknown.family(), ListenerKinds::NONE);
}
#[test]
fn the_public_decoders_read_only_their_own_kind() {
let toggled = NativeEvent::from_payload(EventPayload::Toggled(true));
assert_eq!(toggled.checked(), Some(true));
assert_eq!(toggled.value(), None);
assert!(!toggled.is_click());
assert_eq!(toggled.family(), ListenerKinds::TOGGLED);
let moved = NativeEvent::from_payload(EventPayload::ValueChanged {
value: 17,
from_user: true,
});
assert_eq!(moved.value(), Some(17));
assert_eq!(moved.checked(), None);
assert_eq!(moved.family(), ListenerKinds::VALUE_CHANGED);
assert_eq!(
NativeEvent::from_payload(EventPayload::DragEnd).family(),
ListenerKinds::VALUE_CHANGED,
"the drag edges ride the VALUE_CHANGED attach"
);
let click = NativeEvent::from_payload(EventPayload::Click);
assert!(click.is_click());
assert_eq!(click.kind(), NativeEvent::KIND_CLICK);
assert_eq!(click.family(), ListenerKinds::CLICK);
}
}