Scopes, bindings & epochs
Three mechanisms govern where a provider lives, how long it lives, and what happens when the JS runtime restarts.
A scope answers βwhich provider serves this call?β. It travels in every wire envelope.
| Scope | Meaning |
|---|---|
global | One provider for the whole app. |
feature(name) | Scoped to a feature. |
instance(feature, tag) | Scoped to a specific surface instance β the React root view tag. |
Resolution walks instance β feature β global. A feature-specific override transparently falls back to a global default.
The JS side injects the current feature + instance scope from the BridgeKit context β the React root view tag is the single source of truth for the instance tag. Proxies resolve scope at call time through the handle/context, so surface recreation replaces the handle and nothing caches a dead tag.
<BridgeScopeProvider feature="YourApp.Feature" instance={rootTag}> {children}</BridgeScopeProvider>On the native side, BridgeKitScope is a first-class object. A feature fragment creates one
when its surface starts (keyed by rootTag) and closes it in onDestroyView β closing
bindings, cancelling in-flight calls with CANCELLED, ending streams, and transitioning
state to Unprovided. On iOS, close the binding in viewDidDisappear(_:) / deinit, or in
SwiftUI via the .onDisappear modifier.
Bindings
Section titled βBindingsβA binding is a provider registered in a scope. Rules:
- Exactly one live binding per
(contract, scope). - A duplicate
provideon an occupied slot replaces with a dev warning. Binding.close()is handle-scoped and a no-op if it was already superseded β this kills the register/unregister race.- Closing a binding closes all its streams on both sides.
val binding = bridgekit.provide(AppHostContract, scope) { AppHostProvider(deps) }binding.close()Provider factories are lazy β resolved on first use, unless eager = true.
An epoch is one JS-runtime connection generation. The JS runtime dies and is recreated
as part of normal production lifecycle (ReactHost destroy/restart), not just dev reload β
so connectDispatcher is an epoch swap, not a one-time event.
On each connect, the native core allocates a monotonic epoch id and atomically supersedes the previous dispatcher. On supersession (or disconnect detection) it:
- cancels all prior-epoch stream pump jobs and Flow collections;
- fails prior-epoch in-flight native β JS calls with
BRIDGE_NOT_READY; - releases all prior-epoch callbacks;
- marks all JS-provided contracts unprovided (their mirrors go to
Unprovided) until the new bundle re-registers them.
Durable native-side interests β consume subscriptions to JS streams, state observations β
are re-parked and replayed against the new dispatcher. The park buffer serves both first
connect and reconnect.
connectDispatcher returns a state snapshot of all natively-provided state (the
hydration handshake), so BridgeKitReady on the JS side implies hydrated mirrors. Readiness
is not monotonic β it drops on runtime teardown. Stream and observer ids are
epoch-scoped, so late calls from a dead runtime are rejected cheaply.