Expand description
NativeComponent — the public trait a plugin author writes a native
component against, from pure Rust, with no per-component Kotlin or Swift.
An app crate cannot implement this trait: create constructs real native
views, which means naming jni/objc2-ui-kit types in the implementing
crate, and this plugin re-exports neither FFI crate — see
docs/NATIVE_WIDGETS_ARCHITECTURE.md for that wall and the audience it
leaves. The only implementing type here is the non-default demo-components
composite (crate::demo).
Where crate::runtime’s NativeWidget is the plugin’s internal dispatch
contract, this trait is the shape a third party implements:
internal NativeWidget | public NativeComponent | |
|---|---|---|
| receiver | associated functions | &self — the value the app constructs each rebuild |
| 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) |
| errors | every method returns Result | latched on the context (ComponentCtx::report_error); create may answer None |
| 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 |
| context | the platform’s own NativeCtx | the opaque ComponentCtx wrapper |
The built-in controls are not ported onto it: they stay internal
NativeWidget impls, and this module bridges to them through
Bridge<C> (crate-private), one NativeWidget impl generic over every
public component, so both kinds reach the same runtime, registry, props diff
gate and disposal path. Porting them is not on the table — their whole wire
is params_json, and a public trait implemented by an internal one would
need a blanket impl that then blocks every third-party impl on coherence
grounds. Every guarantee below is therefore the runtime’s own.
§The lifecycle contract (what the runtime guarantees)
Every method here runs on the platform main thread — the host’s
post-frame command poll or a platform listener firing, never a frust rebuild
and never off-thread (crate::runtime’s main-thread confinement).
createarrives a frame or more after the widget mounts. Mounting a slot publishes aCreatecommand; the host drains its backlog on the next post-frame poll, and that is what callsNativeComponent::create. Anything the component retains lives inNativeComponent::State, born there — there is nothing native to hold before it.- Props coalesce until then, and are always whole state, never a delta. Each rebuild replaces a slot’s staged props outright, so a create landing after three rebuilds sees only the newest, and replaying a backlog prefix (a surface-recreate replay, a compaction) lands in the same place.
updateruns only when props actually differ. The runtime compares the typed props withPartialEqbefore any platform call, so an unchanged rebuild costs zero FFI crossings. The one exception is the bounded retry window a failedupdateopens (below): the next two rebuilds each cost one dispatch for byte-identical props. Field-level diffing inside a changed props value is the component’s own job — only it knows which setter is cheap and which forces a re-layout.on_eventfires between frames, for the listeners the component attached. A component attaches the platform’s one listener to any view it built — root or child — withComponentCtx::attach_listener, and from then on that view’s clicks/toggles/value changes reachNativeComponent::on_eventthrough the same slot-id routing the built-in controls use (Listener attachment, below). A slot that attached nothing for an event’s family never reaches the method at all. A native interaction bypassesRenderRoot::evententirely: noEventCtx, no capture/focus, none ofdocs/CODE_STANDARDS.md’s Interaction Semantics. And a listener that fires while the runtime is already borrowed — the classic case is a setter provoking its own listener synchronously from insideupdate— is dropped with a warning, not delivered re-entrantly.- The staged-
&selfre-read is component-only, and itsdisposehalf is live today. The&selfcarried intoon_event/disposeis re-read from the staging table on every dispatch, not only when props changed (BridgeState::refresh_component): the diff gate skipsupdateon an equal-props rebuild, so anything less would run a stale rebuild’s closures (NativeComponent::dispose). The built-in controls decodeparams_jsonand never read this table. disposeis best-effort-prompt, and may be late — below.
§Three design decisions this trait settles
1. The context a third-party impl receives is ComponentCtx, an
opaque wrapper rather than the plugin’s own per-platform NativeCtx, so
the internal helper surface stays free to change without breaking a public
impl; it also holds the error latch, which is what lets the trait’s methods
stay Result-free. Curated helpers can never cover “construct an arbitrary
native view”, so it carries each platform’s own #[cfg]-gated escape hatch
too (ComponentCtx::env, ComponentCtx::mtm) — already public types, so no
new dependency.
2. A component reaches the dispatch table through
register_component, explicitly, from app or plugin init:
inventory-style link-time auto-registration stays banned, being exactly
the mechanism that fails silently in a stripped, LTO’d device build.
Registration is first-wins — a kind already registered, including any of
the built-in controls (which the backend registers when the thread’s
runtime is first touched), is refused with a warning rather than replaced, so
a third-party kind can never shadow a shipped one.
3. Disposal promptness is exactly the guarantee the built-in controls
get, and no more: the framework’s retire() (driven from the mounting widget’s
teardown) is the prompt primary path, and the differ’s missing-frame streak
is the backstop. That streak only advances on gate-Run frames, so on an
idle screen a dispose can arrive many frames late — or after a replacement
create already re-used the slot id, in which case the stale command
resolves against the old view by identity, finds nothing, and is dropped.
So dispose may run long after the widget disappeared, and at process exit
may not run at all: anything whose release cannot wait belongs in State,
dropped immediately after NativeComponent::dispose returns, alongside
the runtime’s paired delete of the NativeRoot.
§Listener attachment
There is still exactly one listener class per platform — Android’s
dev.frust.nativewidgets.FrustNativeListener, and each Apple arm’s
FrustNativeControlTarget — and a component reaches it the same way the
built-in controls do, through one call: ComponentCtx::attach_listener,
given the view and the ListenerKinds to wire (click, toggled, value
changed). The context constructs the listener bound to this slot’s own
id, which it knows privately and never hands to the component, so a
component cannot route an event anywhere but home. It answers a
ListenerHandle the component keeps in its NativeComponent::State;
dropping it with the state is the release on every arm, and
ComponentCtx::detach_listener detaches explicitly (the same release,
run early instead of at drop). A release only ever affects the handle’s
own listener: iOS removes its own target’s action pairs, macOS clears the
control’s target/action only while they are still its own, and Android —
whose setOn*Listener setters hold one listener each, replace it outright
and expose no getter — disarms the handle’s own FrustNativeListener
(a @Volatile flag every callback checks) rather than nulling the view’s
interface, which may already hold a newer attach’s listener. The interface
keeps an inert object until a later attach replaces it or the view dies.
So re-attaching the same view for the same kinds and letting the old
handle drop is safe on every arm, and on Android a handle dropped off the
main thread never touches a View at all.
The dispatch then runs the path the built-in controls already use: the
platform listener fires on the main thread, crate::runtime‘s on_event
routes it by slot id to this module’s Bridge, which hands the
NativeEvent (and the component’s own state and last-applied props) to
NativeComponent::on_event. Whatever event that answers rides the
built-in controls’ EventPayload callback table to the app’s
NativeComponentView::on_event
hook — the events-as-signals idiom, unchanged. The bridge remembers which
ListenerKinds the slot attached and drops any other family before the
component sees it, so a component that attached nothing still answers
nothing.
§A component owns its own native subtree
One component may build a whole native view hierarchy — a parent with
native children — and ship it as ONE slot, which is what stops a composite
from leaking three slots to the consuming app. Five calls on
ComponentCtx, which documents each, are the entire surface: build a
child (ComponentCtx::new_view, or an objc2-ui-kit/objc2-app-kit
constructor off ComponentCtx::mtm — the one #[cfg]-gated pair), attach it
(ComponentCtx::add_child), keep talking to it
(ComponentCtx::retain_child → NativeChild), hear from it
(ComponentCtx::attach_listener → ListenerHandle), and bound the JNI
reference table (ComponentCtx::with_local_frame, a no-op under ARC). The
last four exist on every target, and the host arm’s stand-ins let an
ordinary cargo test assert a component’s create/update/dispose plan.
The platform lays the subtree out, and frust deliberately does not know
the children exist — the wire carries per-slot geometry only (a rect, an
optional clip, shields), so a component positions its own children the
platform’s way while frust keeps seeing one opaque slot with one rect
(NativeComponent’s No frust View children has the model and why).
No wire change: a subtree costs the differ exactly what a single leaf
control costs it, and a11y comes out ahead — the platform owns the subtree,
so it traverses it natively.
Teardown releases children with the parent, so peak global refs return
to zero over a dispose cycle by construction: a merely attached child
needs no handle at all (Android’s ViewGroup holds its own strong
reference, UIKit and AppKit retain a subview) and dies with the parent,
while a child you keep talking to lives in NativeComponent::State as a
NativeChild, whose Drop is the release (DeleteGlobalRef on
Android, Retained‘s own Drop on iOS and macOS). The leak bar is the built-in
controls’ own; tests::a_component_builds_a_native_subtree_and_releases_every_child
counts refs rather than merely surviving the cycle.
§Props travel beside the wire, not on it
The platform-view wire carries one params_json string per slot, and that
is what the differ diffs to decide whether to emit an UpdateParams at all.
A public component’s props are typed Rust values that never touch JSON, so
they ride a thread-local staging table here (written by this module’s
crate-private publish/forget pair, whose one production caller is the
generic mounting builder) while the slot’s params_json carries only the
runtime’s two identity keys plus a props generation counter that
publish bumps when — and only when — the published props actually changed.
The wire therefore changes exactly when the props do, which is what makes
the differ emit the UpdateParams the typed props ride along with. An app
stages props by rebuilding
native_component; none of this is public.
Like every other slot-keyed table here, it is bounded by an explicit reaper
(forget, from the mounting widget’s teardown), never by disposal alone —
the leak shape crate::runtime’s forget_pending_callback guards against
applies verbatim: a culled slot’s dispose resolves by view identity and
never sees this table.
§A failed update is retried, up to a cap
The retry trigger is a generation bump, not instance.props differing,
and that distinction is load-bearing. A failed update leaves old as the
runtime’s diff baseline, so the change would be re-applied by the next
UpdateParams — but the differ only emits one when params_json changes,
and publish moves the generation only when the app’s props change, so an
app republishing the same (already failed) props forever would emit none at
all and the view would stay stale, silently, for the process lifetime. The
staging table therefore carries the retry: a failed dispatch marks the slot
(request_update_retry), and the next publish for that slot bumps the
generation even for identical props — one wire change, one UpdateParams,
one retry. Three consequences:
- A retry needs a rebuild. Nothing here schedules one — the mark is consumed by the next rebuild that publishes this slot, so on a screen that never rebuilds again the change stays unapplied, exactly as any other props change would.
- Three consecutive failed dispatches spend the budget and the slot goes
inert. Failures one and two each re-mark the slot, so a transient
refusal gets two more attempts; failure three reports once at
warnand marks nothing further, so an unchanged rebuild is back to zero FFI crossings and zero log lines. Re-marking forever would instead cost a permanently failing slot one dispatch plus alog::warn!on every rebuild — per-frame main-thread JNI traffic and log volume, with one more warning per refused ctx call on top. Inert is the trade taken; retrying cleverly (backoff, a schedule of its own) is not a goal. - The cap counts consecutive failures and a success clears it, so a flaky platform never accumulates its way to inert. Only this synthetic retry is capped: a genuine props change bumps the generation on its own and is always dispatched, capped or not — the app’s intent, not ours.
§Kind and type must agree, and a mismatch fails closed four different ways
kind is passed twice — once to register_component, once to the
mounting builder — and nothing mechanically ties the two, so each mismatch
is named with its actual error (only the first is UnknownControl):
| case | what surfaces | logged | slot |
|---|---|---|---|
| kind never registered | NativeWidgetError::UnknownControl(kind) from the runtime’s own dispatch, before any decode | yes, by the platform export | dead |
registered to C, mounted with C | nothing — the ordinary path | — | live |
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 |
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 |
Registering two components under one kind reduces to the third row, since first-wins refuses the second. Every case fails closed — no half-created slot, no instance retained, no silent no-op — and both mismatch rows name the wiring bug in their message rather than reading like an ordinary “nothing staged here any more”.
§Dispatch-boundary exception guard (Android)
ComponentCtx::env (Android-only) hands a component the live jni::Env,
and a component is free to leave a Java exception pending on it — undefined
behaviour for the next JNI call, not for the one that threw. So all
four dispatches through this module — create, update, dispose and
on_event — check and clear one on the way out through the crate’s own
run_jni helper, whose report names the throwable’s class and message. On
the three carrying a context it folds into the same first-wins error channel
as a latched error; on_event has none, so it logs at warn.
on_event is guarded through the VM, not through a context, because it
is handed neither: NativeWidget::on_event takes only (state, event). Its
Env comes from JavaVM::with_top_local_frame on the process VM
(frust_plugin::android::vm), which borrows the existing top JNI frame
rather than pushing one, so the clean path is a GetEnv plus an
ExceptionCheck and a report’s locals die with nativeOnEvent’s own frame.
It is deliberately not frust_plugin::android::with_jni_env: jni 0.22’s
scoped attach defaults to AttachmentExceptionPolicy::PreReThrowPostCatch,
which stashes an already-pending exception before running the closure and
re-throws it after, so a check inside would read clean every time and clear
nothing. (That policy is also why a component reaching JNI through
with_jni_env is caught on its own way out; this guard covers the paths that
do not — a raw jni-sys call, an attach configured with Ignore, a
hand-recovered Env.)
env itself stays a safe fn: making it unsafe would tax the one
audience that can use this trait at all, for a hazard this guard contains.
Structs§
- Component
Ctx - The scoped, opaque call context every
NativeComponentmethod builds through — see the module doc’s decision 1. - Listener
Handle - One platform listener a component attached through
ComponentCtx::attach_listener— keep it in yourNativeComponent::State. - Listener
Kinds - Which of the platform listener’s event families
ComponentCtx::attach_listenerwires onto a view — the module doc’s Listener attachment. Combine with|. - Native
Child - One retained child of a component’s native subtree — the handle a
component keeps in its
NativeComponent::Statewhen it wants to drive that child later (module doc’s A component owns its own native subtree). - Native
Event - A platform listener firing for one component slot, as the generic listener
glue delivers it: a primitive
(kind, detail)pair, never an allocation on the hot path. - Native
Root - The root native view a
NativeComponent::createhands back — an opaque handle the runtime retains for the slot and releases on dispose (Android: the paired global-ref delete; iOS and macOS: ARC).
Traits§
- Native
Component - One retained native view — or native view hierarchy — created, updated and torn down entirely from Rust.
Functions§
- register_
component - Register
Cunderkind, so a slot whose params name that kind is served by this component — the module doc’s decision 2.