Skip to main content

Module component

Module component 

Source
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 NativeWidgetpublic NativeComponent
receiverassociated functions&self — the value the app constructs each rebuild
propsdecoded from params_json inside the implalready-typed Rust values the app hands over, staged beside the wire (Props travel beside the wire, below)
errorsevery method returns Resultlatched on the context (ComponentCtx::report_error); create may answer None
eventsdecoded into the crate’s typed EventPayloadthe NativeEvent pair from a listener the component attached (ComponentCtx::attach_listener), answered with the event (if any) the app’s .on_event hook receives
contextthe platform’s own NativeCtxthe 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).

  1. create arrives a frame or more after the widget mounts. Mounting a slot publishes a Create command; the host drains its backlog on the next post-frame poll, and that is what calls NativeComponent::create. Anything the component retains lives in NativeComponent::State, born there — there is nothing native to hold before it.
  2. 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.
  3. update runs only when props actually differ. The runtime compares the typed props with PartialEq before any platform call, so an unchanged rebuild costs zero FFI crossings. The one exception is the bounded retry window a failed update opens (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.
  4. on_event fires between frames, for the listeners the component attached. A component attaches the platform’s one listener to any view it built — root or child — with ComponentCtx::attach_listener, and from then on that view’s clicks/toggles/value changes reach NativeComponent::on_event through 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 bypasses RenderRoot::event entirely: no EventCtx, no capture/focus, none of docs/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 inside update — is dropped with a warning, not delivered re-entrantly.
  5. The staged-&self re-read is component-only, and its dispose half is live today. The &self carried into on_event/dispose is re-read from the staging table on every dispatch, not only when props changed (BridgeState::refresh_component): the diff gate skips update on an equal-props rebuild, so anything less would run a stale rebuild’s closures (NativeComponent::dispose). The built-in controls decode params_json and never read this table.
  6. dispose is 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 warn and 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 a log::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):

casewhat surfacesloggedslot
kind never registeredNativeWidgetError::UnknownControl(kind) from the runtime’s own dispatch, before any decodeyes, by the platform exportdead
registered to C, mounted with Cnothing — the ordinary path—live
registered to C, mounted with DNativeWidgetError::Params, from Bridge::<C>::decode_props failing to downcast the staged props to C::Propsyes, by the platform exportdead
registered to C, mounted with D where D::Props == C::PropsNativeWidgetError::Params, one step later — the props downcast succeeds and Bridge::<C>::create fails to downcast the staged component to Cyes, by the platform exportdead

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§

ComponentCtx
The scoped, opaque call context every NativeComponent method builds through — see the module doc’s decision 1.
ListenerHandle
One platform listener a component attached through ComponentCtx::attach_listener — keep it in your NativeComponent::State.
ListenerKinds
Which of the platform listener’s event families ComponentCtx::attach_listener wires onto a view — the module doc’s Listener attachment. Combine with |.
NativeChild
One retained child of a component’s native subtree — the handle a component keeps in its NativeComponent::State when it wants to drive that child later (module doc’s A component owns its own native subtree).
NativeEvent
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.
NativeRoot
The root native view a NativeComponent::create hands 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§

NativeComponent
One retained native view — or native view hierarchy — created, updated and torn down entirely from Rust.

Functions§

register_component
Register C under kind, so a slot whose params name that kind is served by this component — the module doc’s decision 2.