Skip to content

Where we came from

BridgeKit did not appear in a vacuum. It is the declared successor to a stack of bridging patterns that accumulated over years. Understanding what came before is the fastest way to understand why BridgeKit is shaped the way it is.

The host app reached native capabilities through four overlapping mechanisms:

PatternWhat it wasWhat it cost
NativeModules (raw)Direct, lowest-level calls into native modules.Fully manual marshalling, no types, no streaming primitive.
connectGet*Per-function Nitro module calls.One bespoke module per function; codecs by hand.
feature-actionsAd-hoc per-feature action handlers.No contracts, no codegen, types not enforced at the boundary.
NativeEventEmitterModuleA separate channel for anything push-based.Event names are strings; emitters leak across runtime reloads.

Every native ↔ JS surface required wiring across several of these at once: a module to call in, an emitter to subscribe to, hand-written codecs in the middle, and no single place that described the contract. The result was predictable.

These are the failure modes that drove the design — each one is addressed directly by a BridgeKit decision.

A renamed field or a mistyped event name compiled fine and crashed at runtime. There was no shared definition that both sides were checked against.

BridgeKit’s answer: the contract is the single typed source of truth. The generator bakes a contract hash into both the Kotlin token and the JS descriptor; member sets are diffed at bind time and skew surfaces as one structured warning, not a silent break.

Raw user objects were handed to the bridge’s AnyMap converter, which choked on unexpected shapes ({ a: undefined }, functions, exotic values).

BridgeKit’s answer: schema-driven encoding is always on, prod included. The codec walks declared fields only, skipping undefined and functions — raw user objects never reach the converter. That kills the crash class at the root.

NativeEventEmitter had no backpressure and no lifecycle story. After a JS runtime reload, native could keep pushing into dead callbacks; rapid discrete events could be lost.

BridgeKit’s answer: streams replace events. Lossless bounded buffers by default, single multiplexed collection per source, and epoch-scoped callbacks that are released when a runtime tears down.

Reading a native value at render time meant guarding on “is it ready yet?” and threading a loading flag through the tree.

BridgeKit’s answer: state carries a required initial value that seeds both stores before any provider binds. get() is total; useBridgeState returns { value, status } where status distinguishes Initial / Available / Unprovided.

The same host capability was re-implemented per feature: getUserSegments ×7, openUrl ×5, closeFeature ×4, height-setters under five different names.

BridgeKit’s answer: shared host contracts owned by the platform team (e.g. lidlplus.host, axion.widget). Features depend on them; codegen warns when a feature member name shadows a host-contract member. setHeight is defined once.

BridgeKit is intentionally a brand-new, isolated package. It does not modify feature-actions or nitro-actions. The adoption path was deliberate and is now under way:

  1. BridgeKit shipped and was validated against an on-device acceptance matrix — on both Android (Kotlin) and iOS (Swift), at parity.
  2. With validation done, the old systems freeze — no new actions, modules or emitters.
  3. Existing features migrate contract by contract. A thin native adapter can back a BridgeKit provider with an existing FeatureActionsHandler registration, so migration never requires a dual implementation; a feature can keep a handful of actions on FeatureActions while moving the rest onto a contract.
  4. The first pilot was the highest-value, smallest surface: Connect’s OTP polling became an otpCodes stream — discrete codes replacing a poll loop.