📚 React Native Module Federation series — read it in full on warrendeleon.com, where new parts land first.
Post 12 ended with the host offering the remotes a loan: its native side, through a bridge that launches a native screen from the Party tab and waits for the result. This post builds the bridge. A button in a federated tab opens a fully native screen, SwiftUI on one platform and Jetpack Compose on the other, and the battle's winner returns as one promise's resolution.
The constraint underneath has been in the series since post 1: runtime delivery moves JavaScript and nothing else. Post 11 met it at the design system, and Re.Pack's limitations list states the host's half of the deal: "Host application must have native modules used in other containers". A remote that wants a native screen cannot bring one. The host lends its own.
The loan's shape: a remote sees exactly one function, shellNavigate(destination, params?) from the contract, returning a promise. The title's shell.navigateTo is this function, under the contract's exported name. Behind it sit the host's routing table and the blog's first TurboModule (React Native's typed native-module system), with its first Swift and Kotlin beyond the template. The round trip:
📊 Diagram: view it on warrendeleon.com
One practical difference before the first command. Until now the clone was a convenience: everything was typed, and a tree built by hand since post 2 served just as well. This post is the first where the companion repo is required input: the native screens arrive from its tag, and by the time a TurboModule registers, your native project has to match the tag's. Start from post 12's finished state, the start tag:
git clone https://github.com/warrendeleon/react-native-module-federation
cd react-native-module-federation
git checkout post-12-a11y-testing
The finished state is the end tag, post-13-native-handoff; a fetch later in the build reaches into it, and everything else you type.
One function in the contract
The contract gains one file, the remote's entire view of the native world. Create packages/contracts/src/shellNavigation.ts:
import type { PartyMember } from './party';
// --- The shell's routing surface, and the whole of what a remote knows about native. A remote
// imports `shellNavigate` and nothing else: no TurboModule, no native types, no idea whether the
// destination it names is another micro-app or a fully native screen. The host owns the table that
// decides, so migrating a native flow to React Native later is one row edit here and no change in
// any remote. ---
export type RouteEntry =
/** Hands off to native: the host calls openNative, a native screen presents, and the promise
* resolves with whatever that screen passed back. */
{ type: 'native'; nativeId: string };
// One entry, because one thing dispatches. The union above is written as a union of one so a
// micro-app variant can join it without the readers of `entry.type` changing shape; a registry row
// with nothing behind it is a dead constant, and this series has already paid for one of those.
export const ROUTE_REGISTRY: Record<string, RouteEntry> = {
QuickBattle: { type: 'native', nativeId: 'quickBattle' },
};
// --- What crosses the boundary, in both directions. These types live in the contract because the
// HOST has to serialise them, not because another app dispatches anything: the battle's outcome is
// the party's own business and the party's own reducer handles it. ---
/** RN -> native. The party hands over its members; the native screen shows and battles them.
*
* The colour scheme travels with them because it has to. Every federated surface reads the one
* styling runtime the host mounts, and a native screen is the one consumer that cannot subscribe
* to it: there is no bundle to share and no provider to sit under. So the theme stops being
* ambient at this boundary and becomes an argument, sent at the moment of the call. */
export interface QuickBattleParams extends Record<string, unknown> {
members: PartyMember[];
colourScheme: 'light' | 'dark';
}
/** Native -> RN. The uid, not the id: two copies of the same Pokémon are two contestants, and the
* party has stamped a uid on each member since 3.1.0 for exactly that reason. Absent when the
* screen closed without a battle. */
export interface QuickBattleResult {
winnerUid?: string;
}
/** Native resolves with an object, or with nothing when the flow ended without a result. */
export type ShellNavigateResult = Record<string, unknown> | undefined;
export type ShellNavigateFn = (
destination: string,
params?: Record<string, unknown>,
) => Promise<ShellNavigateResult>;
// --- The slot the host fills and every remote reads. It is a globalThis key rather than a React
// context because this package is bundled into the host AND into each remote: a context created
// here would be a different object in every bundle, so a remote's useContext would never find the
// host's provider. The module identity that posts 3 and 6 made a singleton for is exactly what is
// missing at this seam, and globalThis is the one slot all three bundles genuinely share. ---
const GLOBAL_KEY = '__POKEDEX_SHELL_NAVIGATE__';
export function registerShellNavigateHandler(fn: ShellNavigateFn): void {
(globalThis as Record<string, unknown>)[GLOBAL_KEY] = fn;
}
export function shellNavigate(
destination: string,
params?: Record<string, unknown>,
): Promise<ShellNavigateResult> {
const fn = (globalThis as Record<string, unknown>)[GLOBAL_KEY] as ShellNavigateFn | undefined;
if (typeof fn !== 'function') {
// No handler: warn and resolve. A remote that awaits this gets undefined and renders something
// honest, which is the same tolerance the party slice's read shape is built on.
console.warn(`[shellNavigate] no handler registered (called with ${destination})`);
return Promise.resolve(undefined);
}
return fn(destination, params);
}
The comments carry the three decisions: one registry entry, since only one thing dispatches; the theme as an argument, since a native screen cannot subscribe to the styling runtime; and a globalThis slot rather than a React context, which bundling into every app would make a different object in each. Post 8's prepare-stamped uid gets its first cross-boundary consumer here: the value that leaves the app and comes back.
React Navigation's native stack deserves naming first, and for native-backed React Native screens inside a navigator it is the right tool. What it does not model is a screen the host owns natively, outside React's tree, taking input from one remote and returning a value to it. That round trip is the bridge's reason to exist.
Export it, bump, and publish through the same Verdaccio flow post 5 established. In packages/contracts/src/index.ts:
export * from './party';
export * from './shellNavigation';
In packages/contracts/package.json, "version": "3.3.0": an additive minor on post 12's 3.2.2. Two tooling edits come with it: the new file calls console.warn, so the contracts tsconfig.json adds "DOM" to its lib array as ui and a11y-testing already do, and the build script gains a dist clean so stale declaration files cannot survive into a publish:
"build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc",
Publish, then take it in all three apps:
( cd packages/contracts && npm install && npm run build && npm publish )
for app in host list party; do ( cd apps/$app && npm install @pokedex/contracts@3.3.0 ); done
The spec and the codegen
A TurboModule starts as a TypeScript spec, read at build time by React Native's code generation, which writes the base classes the native implementations extend. Create apps/host/specs/NativeShellNavigationModule.ts:
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';
// --- The TurboModule spec: the one place the host's JavaScript and its native code agree on a
// signature. Codegen reads this file at build time and writes the C++/ObjC++ and Kotlin base
// classes the native implementations extend, which is why the file lives outside src/ and why
// its name has to start with `Native`.
//
// One method, and it is asynchronous by shape: openNative hands a native flow its input and
// resolves with whatever that flow passed back. The promise is the whole design — the party's
// await does not return until the native screen has finished, and every exit path of that screen
// has to settle it exactly once.
//
// The boundary is JSON strings in both directions rather than codegen object types. Codegen can
// carry structured objects, and a bigger bridge should let it; a string keeps the serialisation
// visible in one place on each side, which is easier to follow while the bridge is one method
// wide. ---
export interface Spec extends TurboModule {
openNative(nativeId: string, paramsJson: string): Promise<string>;
}
// getEnforcing throws when no native module answers to the name: a binary built without the
// native half, or a codegen mismatch. React Native's own examples import a spec like this at
// module scope, and with the module in the binary that is fine. The host's handler requires this
// file lazily instead, as a choice about where that throw is allowed to land. Imported at boot,
// a missing native half kills the shell during bundle evaluation, before any error boundary
// exists; required at the call, the same fault surfaces at the button press, in a running app
// that can log it and carry on. Runtime delivery is what makes the skew real: JavaScript can
// arrive on a binary that never built the native side.
export default TurboModuleRegistry.getEnforcing<Spec>('ShellNavigationModule');
The boundary is JSON strings in both directions; the spec's opening comment ends on the why. Tell codegen where the spec lives, in apps/host/package.json:
"codegenConfig": {
"name": "HostSpecs",
"type": "modules",
"jsSrcsDir": "specs",
"android": {
"javaPackageName": "com.host.specs"
}
}
Codegen runs during pod install, so run it now:
( cd apps/host/ios && bundle exec pod install )
The host's handler is the routing table's reader. Create apps/host/src/shell/shellNavigation.ts:
import { ROUTE_REGISTRY, type ShellNavigateFn, type ShellNavigateResult } from '@pokedex/contracts';
import type { Spec as ShellNavigationSpec } from '../../specs/NativeShellNavigationModule';
// --- The host's half of shell.navigateTo. A remote calls the contract's shellNavigate; this is
// what runs. It looks the destination up in the routing table and hands native destinations to the
// TurboModule, JSON in and JSON out. The remote never learns which branch it took. ---
// The spec module calls TurboModuleRegistry.getEnforcing at import, and that throws when no
// native module answers to the name. Requiring it here, inside the call, decides where that
// throw can land: at the tap, in a running shell that logs it and carries on, rather than during
// boot evaluation, where a missing native half would kill the app with no error boundary.
function nativeModule(): ShellNavigationSpec {
return require('../../specs/NativeShellNavigationModule').default as ShellNavigationSpec;
}
export const shellNavigateHandler: ShellNavigateFn = async (destination, params) => {
const entry = ROUTE_REGISTRY[destination];
if (!entry) {
// An unknown destination is a caller's bug, not a crash: warn and resolve, so a remote built
// against a newer contract than the host degrades to nothing happening.
console.warn(`[shellNavigate] unknown destination: ${destination}`);
return undefined;
}
const resultJson = await nativeModule().openNative(entry.nativeId, JSON.stringify(params ?? {}));
if (!resultJson) {
return undefined;
}
try {
return JSON.parse(resultJson) as ShellNavigateResult;
} catch {
// A native result that does not parse is a native-side bug, and not a reason to detonate the
// remote's await: warn and resolve, the same tolerance every other edge of this seam shows.
console.warn(`[shellNavigate] unparseable native result for ${destination}`);
return undefined;
}
};
The lazy
require()chooses where a missing native half fails: at the tap, not at boot.getEnforcingthrows when no module answers to the name. React Native's own examples import a spec at module scope, which works while the module is in the binary; runtime delivery makes the skew real. At boot the fault is a white screen with no error boundary; at the call, a loggable failure on one button.
Registration is two lines in apps/host/App.tsx:
import { partyStateReady, registerShellNavigateHandler } from '@pokedex/contracts';
// ...
import { store } from './src/store';
import { shellNavigateHandler } from './src/shell/shellNavigation';
// The host fills the contract's navigation slot once, at module scope, before any remote can
// render and call shellNavigate. Registering the handler touches no native code — the TurboModule
// is not reached until a destination is actually navigated to — so this is safe at import in a way
// that requiring the spec here would not be.
registerShellNavigateHandler(shellNavigateHandler);
Type the bridge, fetch the screens
From here the work splits by what each half teaches. The bridge is federation, and you type all of it: the module holding a promise open across the boundary, its registration, the threading rules. The screens are SwiftUI and Compose UI code, so they arrive from this post's own end tag. Around a thousand lines stay off your keyboard, none unexplained.
npx degit@3.8.0 --force warrendeleon/react-native-module-federation#post-13-native-handoff /tmp/pokedex-ref-13
cp /tmp/pokedex-ref-13/apps/host/ios/Host/QuickBattle.swift apps/host/ios/Host/QuickBattle.swift
cp /tmp/pokedex-ref-13/apps/host/android/app/src/main/java/com/host/QuickBattleActivity.kt apps/host/android/app/src/main/java/com/host/QuickBattleActivity.kt
cp /tmp/pokedex-ref-13/apps/host/__tests__/shellNavigation.test.ts apps/host/__tests__/shellNavigation.test.ts
cp /tmp/pokedex-ref-13/apps/party/__tests__/quickBattle.test.tsx apps/party/__tests__/quickBattle.test.tsx
mkdir -p apps/host/android/app/src/test/java/com/host apps/host/ios/HostTests
cp /tmp/pokedex-ref-13/apps/host/android/app/src/test/java/com/host/ShellNavigationModuleTest.kt apps/host/android/app/src/test/java/com/host/ShellNavigationModuleTest.kt
cp /tmp/pokedex-ref-13/apps/host/ios/HostTests/QuickBattlePresenterTests.swift apps/host/ios/HostTests/QuickBattlePresenterTests.swift
Both screens wear the design system. No bundle crosses this boundary, so each file mirrors colours.ts and the eighteen type colours by hand, rebuilt into the card language the Pokédex grid renders: tinted sprite disc, hashed number pill, type badges, accent foot. Each resolves the colourScheme the params carry against the same pairs the dark: rules use; the Compose buttons declare Role.Button, since a styled Text otherwise announces to TalkBack as plain text; and each screen wears a purple NATIVE badge. No federated screen carries one, so a screenshot declares its side of the boundary on its own.
Four suites arrive with the screens. The JavaScript pair pins the bridge's seam: the host's, the contract slot and the handler, unparseable results included; the party's, the button's gating and the winner landing through the private action. The native pair holds the promise guarantees the next two sections build, wired in after the exit table.
iOS: every exit resolves
The iOS module is Objective-C++, and the extension is the point: getTurboModule returns the generated JSI module (JavaScript Interface, the C++ layer under the new architecture) as a std::shared_ptr, which Swift cannot express. The screen stays pure SwiftUI; the .mm is a thin adapter. In Xcode, create ShellNavigationModule.h and ShellNavigationModule.mm in the Host group, which puts them in the target, and add the fetched QuickBattle.swift with File → Add Files to "Host".
#import <Foundation/Foundation.h>
#import <React/RCTBridgeModule.h>
#import <HostSpecs/HostSpecs.h>
// --- The host's native half of shell.navigateTo on iOS. Codegen read specs/
// NativeShellNavigationModule.ts and wrote NativeShellNavigationModuleSpecBase and the
// NativeShellNavigationModuleSpec protocol into HostSpecs; this class subclasses the one and
// conforms to the other, which is what makes the JavaScript `openNative` land here.
//
// The header exists because the implementation is ObjC++ (.mm) rather than Swift: returning the
// generated C++ JSI module from getTurboModule needs C++, and Swift cannot. The screen it
// presents is pure SwiftUI — see QuickBattle.swift. ---
@interface ShellNavigationModule : NativeShellNavigationModuleSpecBase <NativeShellNavigationModuleSpec>
@end
#import "ShellNavigationModule.h"
#import <HostSpecs/HostSpecs.h>
// Host-Swift.h declares every @objc Swift class in this app, which is how this ObjC++ file reaches
// QuickBattlePresenter. Imported after the app-delegate header so the generated header's forward
// references resolve in this translation unit.
#import <React-RCTAppDelegate/RCTDefaultReactNativeFactoryDelegate.h>
#import "Host-Swift.h"
// --- The TurboModule implementation. Two things live here and nothing else: the registration
// that puts the module in the registry under the name the spec asked for, and openNative.
//
// openNative is where the promise starts. It hands the resolve block to the presenter and returns
// immediately; the block is called later, from the native screen, whenever that screen finishes.
// Until then the party's `await` is genuinely suspended. That is the whole handoff, and it is also
// the whole risk: a path through the native screen that never calls the block leaves the promise
// pending for the life of the process. ---
@implementation ShellNavigationModule
RCT_EXPORT_MODULE()
- (void)openNative:(NSString *)nativeId
paramsJson:(NSString *)paramsJson
resolve:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject
{
[QuickBattlePresenter presentWithNativeId:nativeId
paramsJson:paramsJson
completion:^(NSString *_Nonnull resultJson) {
resolve(resultJson);
}];
}
// The codegen C++ module. This method is the reason the file is ObjC++ rather than Swift: it
// returns a std::shared_ptr, which Swift has no way to express.
- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
(const facebook::react::ObjCTurboModule::InitParams &)params
{
return std::make_shared<facebook::react::NativeShellNavigationModuleSpecJSI>(params);
}
@end
What happens to that resolve block is the post's core. Every exit of the native screen settles the promise, exactly once, and every path lands in a settle-once wrapper. The call arrives off the main thread, so presentation hops before touching UIKit; the host controller comes from hostProvider, a static variable defaulting to a key-window scan, reassigned only by the unit bundle; a re-entrancy guard settles a refused second presentation; the sheet blocks interactive dismissal; and two safety nets close the exits no button took. One catches a sheet that left the screen outside finish(). The other catches a presentation UIKit silently refused, and its mechanism assumes nothing: whether the completion of a refused present() runs is not documented. One main-queue turn later a refused controller still has no presentingViewController, and none means nobody will ever dismiss the sheet, so the wrapper settles it:
// Every path below funnels into this, and it settles at most once. The battle's own exits
// race the two safety nets further down, and whichever lands first wins; the rest become
// no-ops instead of double settles.
var settled = false
let settle: (String) -> Void = { resultJson in
guard !settled else { return }
settled = true
isPresenting = false
completion(resultJson)
}
// openNative arrives on the TurboModule's own queue, not the main thread. Every UIKit call
// below has to be on main, so hop before touching anything.
DispatchQueue.main.async {
guard let host = Self.hostProvider(), !isPresenting else {
// Nothing to present from, or one flow already up: settle rather than wedge.
completion("{}")
return
}
isPresenting = true
// The result settles BEFORE the dismissal starts, not in its completion. viewDidDisappear
// fires while the sheet is still animating out, so a settle scheduled after the transition
// would lose the race to safety net one and the winner would arrive as "{}".
let view = QuickBattleView(contestants: contestants, isDark: isDark) { resultJson in
settle(resultJson)
host.dismiss(animated: true)
}
let controller = QuickBattleHostingController(rootView: view)
controller.modalPresentationStyle = .pageSheet
// Exit only through the screen's own controls. An interactive swipe-to-dismiss would tear
// the sheet away without reaching the settle above, leaving the promise pending.
controller.isModalInPresentation = true
// Safety net one: the sheet leaving the screen by any route the buttons did not take —
// an ancestor being torn down is enough. A resultless settle beats a pending promise.
controller.onDisappear = { settle("{}") }
// Safety net two: present() silently does nothing when the host is mid-transition for
// reasons this file cannot see. Whether UIKit runs the completion of a refused present is
// not documented, so nothing here depends on it: one main-queue turn later, a presented
// controller has a presentingViewController and a refused one still has none, and nil
// means nobody will ever dismiss this sheet, so settle now.
host.present(controller, animated: true)
DispatchQueue.main.async {
if controller.presentingViewController == nil {
settle("{}")
}
}
}
One order matters: the result settles before the dismissal starts, because viewDidDisappear fires mid-animation. In the view, both exits, Close and Done, land in one finish(), and a battle-free exit is a result rather than an error:
private func finish() {
guard let winnerId else {
onDone("{}")
return
}
let json = (try? JSONSerialization.data(withJSONObject: ["winnerUid": winnerId]))
.flatMap { String(data: $0, encoding: .utf8) } ?? "{}"
onDone(json)
}
A never-settled promise has no safety net. No timeout on the bridge, no garbage-collector rescue, no error to observe: the
awaitsimply never returns, and the resolve block and everything it captures stay retained for the life of the process. The opposite fault costs a log line at worst, since the bridge ignores a second settle. Resolving on every exit is mandatory; resolving twice is survivable.
Android: every exit is a result
Android gets most of the guarantee from structure: the screen is an Activity started for a result, the platform reports back every exit of a screen that opened, and the module's listener is the single place a delivered result settles it. What structure does not cover is the launch itself (a start that throws, or a teardown racing the UI-thread hop), so the module closes both by hand. Create apps/host/android/app/src/main/java/com/host/ShellNavigationModule.kt:
package com.host
import android.app.Activity
import android.content.Intent
import com.facebook.react.bridge.BaseActivityEventListener
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.host.specs.NativeShellNavigationModuleSpec
// --- The host's native half of shell.navigateTo on Android, and the mirror of the iOS
// ShellNavigationModule. Codegen read the same spec file and wrote NativeShellNavigationModuleSpec
// into com.host.specs; this class extends it, so the JavaScript `openNative` lands here.
//
// Android's shape does most of the promise discipline for it. The screen is an Activity started
// for result, so the platform delivers a result for every exit of a screen that opened — the Done
// button, the system back gesture — and onActivityResult is the single place a delivered result
// settles the promise. What structure does not cover is the launch itself: a start that throws,
// or a teardown racing the UI-thread hop. Those two windows are closed by hand below. ---
class ShellNavigationModule(reactContext: ReactApplicationContext) :
NativeShellNavigationModuleSpec(reactContext) {
// Held from the moment the Activity is launched until it returns. One at a time, which is the
// counterpart of the iOS presenter's isPresenting flag. Both fields are touched from the
// native-modules thread (openNative, invalidate) and the UI thread (the launch block, the
// result listener), so every mutation sits inside synchronized(this).
private var pendingPromise: Promise? = null
// Flipped once in invalidate and never back: a launch queued before teardown must not open a
// screen after it, because nobody is listening and its result could land on a later battle.
private var invalidated = false
private val activityEventListener =
object : BaseActivityEventListener() {
override fun onActivityResult(
activity: Activity,
requestCode: Int,
resultCode: Int,
data: Intent?,
) {
if (requestCode != QUICK_BATTLE_REQUEST) return
// Every exit lands here. A battle that finished carries its JSON; a back gesture carries
// no data at all, and an empty object is the honest answer for it — the party reads a
// missing winnerUid as "nothing happened".
val result = data?.getStringExtra(QuickBattleActivity.EXTRA_RESULT_JSON) ?: "{}"
synchronized(this@ShellNavigationModule) {
pendingPromise?.resolve(result)
pendingPromise = null
}
}
}
init {
reactApplicationContext.addActivityEventListener(activityEventListener)
}
override fun openNative(nativeId: String, paramsJson: String, promise: Promise) {
val activity = reactApplicationContext.currentActivity
if (activity == null) {
// Nothing to launch from: settle now rather than leave the caller waiting on a result
// that will never be delivered.
promise.resolve("{}")
return
}
synchronized(this) {
if (pendingPromise != null || invalidated) {
// A battle already running, or the module already torn down: same answer.
promise.resolve("{}")
return
}
pendingPromise = promise
}
// nativeId goes unread: one native flow exists today, and a second registry row would need a
// switch here first. openNative arrives on a background queue; hop to the UI thread to start
// the Activity rather than assume the queue is safe to start it from.
activity.runOnUiThread {
synchronized(this) {
// invalidate can run between the queueing above and this block: the promise is settled
// by then, and launching anyway would open a screen nobody is listening to.
if (invalidated || pendingPromise == null) return@runOnUiThread
try {
val intent =
Intent(activity, QuickBattleActivity::class.java).apply {
putExtra(QuickBattleActivity.EXTRA_PARAMS_JSON, paramsJson)
}
activity.startActivityForResult(intent, QUICK_BATTLE_REQUEST)
} catch (e: Exception) {
// A launch that throws leaves no Activity to deliver a result. Settle now, or the
// await outlives a screen that never opened.
pendingPromise?.resolve("{}")
pendingPromise = null
}
}
}
}
override fun invalidate() {
// A dev reload or host teardown while a battle is open would strand the caller's await:
// the listener is about to go away, so settle the pending promise the way a resultless
// exit does before it can no longer be settled at all, and refuse any launch still queued.
synchronized(this) {
invalidated = true
pendingPromise?.resolve("{}")
pendingPromise = null
}
reactApplicationContext.removeActivityEventListener(activityEventListener)
super.invalidate()
}
companion object {
private const val QUICK_BATTLE_REQUEST = 0xB47
}
}
iOS registered itself with one macro; Android writes the registration down, in a package class the template leaves a slot for. The com.host.specs base class appears at the first Gradle build, when Android's codegen runs, so an editor complaining before then is early rather than wrong. Create apps/host/android/app/src/main/java/com/host/HostNativePackage.kt:
package com.host
import com.facebook.react.BaseReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.module.model.ReactModuleInfo
import com.facebook.react.module.model.ReactModuleInfoProvider
import com.host.specs.NativeShellNavigationModuleSpec
// --- Registration. iOS gets this for free from RCT_EXPORT_MODULE and autolinking; Android wants
// it written down, in two halves. getModule builds the instance when the registry asks for it by
// name, and getReactModuleInfoProvider declares what that name is and how it behaves — the last
// flag is the one that matters here, because it is what marks the module as a TurboModule rather
// than a legacy bridge module.
//
// The package is added to MainApplication's list, in the slot the React Native template leaves
// commented for exactly this. ---
class HostNativePackage : BaseReactPackage() {
override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? =
when (name) {
NativeShellNavigationModuleSpec.NAME -> ShellNavigationModule(reactContext)
else -> null
}
override fun getReactModuleInfoProvider() = ReactModuleInfoProvider {
mapOf(
NativeShellNavigationModuleSpec.NAME to
ReactModuleInfo(
NativeShellNavigationModuleSpec.NAME,
ShellNavigationModule::class.java.name,
false, // canOverrideExistingModule
false, // needsEagerInit
false, // isCxxModule
true, // isTurboModule
)
)
}
}
In MainApplication.kt, the template's commented slot finally gets used:
PackageList(this).packages.apply {
// The host's own native modules. Autolinking finds packages in node_modules; a module
// that lives in the app itself is added here, in the slot the template leaves for it.
add(HostNativePackage())
},
The fetched screen is the repo's first Jetpack Compose, so the build declares the compiler and the libraries. In apps/host/android/build.gradle:
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin")
// Kotlin 2.x ships the Compose compiler as a separate plugin; the native Quick Battle
// screen is the only thing in the app that needs it.
classpath("org.jetbrains.kotlin:compose-compiler-gradle-plugin:$kotlinVersion")
In apps/host/android/app/build.gradle, the plugin, the build feature, and the dependencies:
apply plugin: "org.jetbrains.kotlin.android"
apply plugin: "org.jetbrains.kotlin.plugin.compose"
// Jetpack Compose, for the native Quick Battle screen (the SwiftUI counterpart on iOS).
buildFeatures {
compose true
}
// --- Jetpack Compose, for QuickBattleActivity. The BOM pins the Compose family from one
// version, so those artefacts carry none of their own; activity-compose sits outside the
// BOM and pins itself. ---
def composeBom = platform("androidx.compose:compose-bom:2024.09.03")
implementation(composeBom)
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.material3:material3")
implementation("androidx.activity:activity-compose:1.9.3")
The Activity joins AndroidManifest.xml, unexported:
<!-- The native Quick Battle screen. exported=false: nothing outside this app launches it;
the shell's TurboModule does, with startActivityForResult. -->
<activity
android:name=".QuickBattleActivity"
android:exported="false"
android:theme="@style/AppTheme" />
Read the fetched Activity's exit against the Swift version: Done calls deliverResult; the system back button calls nothing, and needs nothing:
// RESULT_OK with the JSON extra is what ShellNavigationModule's ActivityEventListener reads.
// A system back never reaches this method, and does not need to: the Activity finishes with no
// result, and the listener reads a null Intent as an empty object.
private fun deliverResult(resultJson: String) {
setResult(Activity.RESULT_OK, Intent().putExtra(EXTRA_RESULT_JSON, resultJson))
finish()
}
On iOS every exit resolves because the code closed each one; on Android, because the platform reports back every exit of a screen that opened, and the module closes the launch paths by hand. The full set:
| Exit | iOS | Android |
|---|---|---|
| Done after a battle | resolves {"winnerUid": …}
|
RESULT_OK extra, resolves {"winnerUid": …}
|
| Close before battling | resolves {}
|
RESULT_OK extra, resolves {}
|
| Swipe to dismiss | blocked by isModalInPresentation
|
not applicable, full-screen Activity |
| System back | no such gesture on a sheet | null Intent, resolves {}
|
Second openNative while one is open |
the re-entrancy guard resolves it with {}
|
the pending-promise guard resolves it with {}
|
| The launch itself fails | net two's nil check settles {}
|
the catch around the start resolves {}
|
| Shell torn down mid-battle | the disappearance net settles {}
|
invalidate() settles {} and refuses a queued launch |
The second-launch row is defence in depth: the party's in-flight flag, coming next, already blocks it from the UI.
Pin the guarantees
Half the table covers moments a simulator run cannot stage: a launch that throws, a teardown racing the hop, a presentation UIKit refuses. The two native suites fetched earlier hold them.
The Android suite is plain JVM: ShellNavigationModuleTest.kt drives the real module against mocked edges and walks the promise through eight paths. The mock Promise records its settlements; the mock Activity's runOnUiThread runs inline, or is captured and fired after invalidate(), which stages the teardown race. Two apps/host/android/app/build.gradle edits wire it up. In the android block:
// JVM unit tests for ShellNavigationModule run against the SDK's stub android.jar; default
// values instead of "not mocked" throws let a real Intent be constructed as a no-op while
// the mocks around it carry the assertions.
testOptions {
unitTests.returnDefaultValues = true
}
And in dependencies:
// --- JVM unit tests for the bridge module: the promise-settling contract under launch
// failure, teardown, and the result listener. Mockito 5 mocks finals by default, which is
// what lets Activity and the React context stand in without wrappers. ---
testImplementation("junit:junit:4.13.2")
testImplementation("org.mockito:mockito-core:5.14.2")
testImplementation("org.mockito.kotlin:mockito-kotlin:5.4.0")
The iOS suite needs one piece of project surgery, because the app is not its host. In Xcode: File → New → Target → Unit Testing Bundle, named HostTests, with "Target to be Tested" set to None; then select the fetched QuickBattlePresenterTests.swift and QuickBattle.swift and tick HostTests under Target Membership. If the new bundle is not in the Host scheme's Test action (Product → Scheme → Edit Scheme → Test), add it. The bundle compiles the presenter directly instead of loading the app, so its three tests need no React Native boot, dev server, or window hierarchy: no host to present from, a refused second presentation, and a presentation UIKit turns down. That last is the nil-check net observed as a test rather than trusted as a comment. The tests stage each case by reassigning the hostProvider seam.
Wire the button
The party's half starts in its slice; both new pieces stay private, in no contract, since nothing outside the party dispatches them. apps/party/src/partySlice.ts in full:
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
import { addToParty, MAX_PARTY, rootReducer, type PartyMember } from '@pokedex/contracts';
// --- The party's state, owned outright by the party app. The contract carries the action creator
// and the read shape; this file carries what they mean. Nothing outside this app imports it — the
// host loads it as a federated module at boot, for the side effect at the bottom. ---
export const partySlice = createSlice({
name: 'party',
initialState: { members: [] as PartyMember[], lastBattleWinnerUid: null as string | null },
reducers: {
// Private: nobody else dispatches remove, so it ships in no contract.
remove(state, action: PayloadAction<string>) {
state.members = state.members.filter(m => m.uid !== action.payload);
// A removed member cannot go on being the last winner; the banner would name a Pokémon
// that is no longer in the party.
if (state.lastBattleWinnerUid === action.payload) {
state.lastBattleWinnerUid = null;
}
},
// Also private, and deliberately so. The winner comes back from a native screen the HOST
// presented, but it lands in the party's own state and nothing outside this app dispatches
// it — so it appears in no contract. The handoff's params and result types are at the seam
// because the host has to serialise them, which is a different reason from crossing.
setLastBattleWinner(state, action: PayloadAction<string>) {
state.lastBattleWinnerUid = action.payload;
},
},
extraReducers: builder => {
// The crossing interaction. The list app dispatches the contract's addToParty, and this case
// matches it on the type string: addCase reads actionCreator.type and keys the reducer by
// `party/add`, so agreement on that string is what makes the match work, not the identity of
// the creator object. Sharing @pokedex/contracts as a singleton is what stops the two sides
// from retyping that string, the cap and the read shape separately.
builder.addCase(addToParty, (state, { payload }) => {
if (state.members.length >= MAX_PARTY) return; // the cap lives with the owner
state.members.push(payload);
});
},
});
export const { remove, setLastBattleWinner } = partySlice.actions;
// Importing this module is what adds the reducer to the shared store. rootReducer is the same
// object the host's configureStore wired in — that is why injection from a separately-built app
// works at all.
rootReducer.inject(partySlice);
Two of the party's own tests assert the slice's exact shape, so they grow the same field. In __tests__/partyStateReady.test.ts and __tests__/partySelfRecovery.test.tsx, the toEqual({ members: [] }) assertion becomes:
expect((store.getState() as PartySliceShape).party).toEqual({
members: [],
lastBattleWinnerUid: null,
});
PartyScreen.tsx grows in four places. First the imports:
import { useColorScheme } from 'nativewind';
import {
MAX_PARTY,
partyStateReady,
shellNavigate,
type PartyMember,
type PartySliceShape,
type QuickBattleResult,
} from '@pokedex/contracts';
import { Box, Button, ButtonText, EmptySlot, PokemonCard, ScreenContainer, Text, toast } from '@pokedex/ui';
import { remove, setLastBattleWinner } from './partySlice';
At module level, the owner's local read shape and the battle's floor:
// The owner's own view of its slice. The contract carries `members`, because foreign modules read
// it; the last winner is read here and nowhere else, so it stays out of the contract and is added
// to the shape locally instead.
type PartyOwnShape = PartySliceShape & {
party?: { lastBattleWinnerUid?: string | null };
};
// Two contestants is the floor for a battle. Stated as a constant because the button's disabled
// state and the hint under it are two readings of the same rule.
const MIN_CONTESTANTS = 2;
Inside the component, the selectors and the handler:
const { colorScheme } = useColorScheme();
const members = useSelector((s: PartyOwnShape) => s.party?.members ?? EMPTY_MEMBERS);
const lastBattleWinnerUid = useSelector(
(s: PartyOwnShape) => s.party?.lastBattleWinnerUid ?? null,
);
const lastWinner = members.find(m => m.uid === lastBattleWinnerUid) ?? null;
// The one call a remote makes to reach native. `shellNavigate` is the whole of what this app
// knows about the other side: no TurboModule import, no native types, no idea that the screen
// it opens is SwiftUI on one platform and Compose on the other. The host resolves the
// destination and owns everything past it.
//
// The await is the point. It does not return until the native flow has finished, and what comes
// back lands in this app's own state through this app's own action — the round trip starts and
// ends inside the party, so nothing about the battle needs to cross the contract.
const [battleInFlight, setBattleInFlight] = React.useState(false);
const onQuickBattle = React.useCallback(async () => {
if (battleInFlight) return;
setBattleInFlight(true);
try {
// The theme is read at the moment of the call, from the same NativeWind observable every
// dark: class in the federation subscribes to. It is sent rather than observed because the
// native screen has no way to subscribe: a toggle while the battle is open will not reach it.
const result = (await shellNavigate('QuickBattle', {
members,
colourScheme: colorScheme === 'dark' ? 'dark' : 'light',
})) as QuickBattleResult | undefined;
// No winner is a real outcome, not a failure: the screen can be closed without battling,
// and the native side resolves with an empty object when it is.
if (result?.winnerUid) {
dispatch(setLastBattleWinner(result.winnerUid));
}
} finally {
setBattleInFlight(false);
}
}, [battleInFlight, colorScheme, dispatch, members]);
The render gains the button, its hint, and the banner, after the slot grid:
{/* The handoff's one control. Disabled below two members, because a battle needs two
contestants; the hint under it says which rule it is rather than leaving a dead
button to explain itself. */}
<Button
onPress={onQuickBattle}
disabled={members.length < MIN_CONTESTANTS || battleInFlight}
size="lg"
className={`mt-6 rounded-xl ${
members.length < MIN_CONTESTANTS ? 'bg-lightGrey dark:bg-white/10' : 'bg-purple'
}`}
// The 44pt minimum is declared, not inherited from the size variant, the same way the
// detail's Add button declares it: the accessibility suite can only check what the
// control states about itself.
style={{ alignSelf: 'stretch', minWidth: 44, minHeight: 44 }}
accessibilityRole="button">
<ButtonText
className={members.length < MIN_CONTESTANTS ? 'text-midGrey' : 'text-white'}>
{battleInFlight ? 'Battling…' : 'Quick Battle'}
</ButtonText>
</Button>
{members.length < MIN_CONTESTANTS ? (
<Text size="xs" className="mt-2 text-center text-darkGrey dark:text-lightGrey">
Add at least 2 Pokémon to battle.
</Text>
) : null}
{/* What came back from native, rendered by the app that owns the state. */}
{lastWinner ? (
<Text
size="sm"
className="mt-3 text-center font-semi text-darkGrey dark:text-lightGrey"
accessibilityLiveRegion="polite">
Last battle winner: {lastWinner.name}
</Text>
) : null}
One change is easy to miss, and it cost this build a debugging session. The filled slots sit in a module-level React.memo, and a theme change alters none of their props, so the memo skips the subtree and the card keeps the old scheme's style objects: an empty white rectangle. The slot's key carries the scheme, so a toggle becomes a remount of six small cards:
// The key carries the colour scheme, and it has to. A theme change alters none of
// this slot's props, so the memo above finds them identical and skips the subtree;
// the card then keeps the style objects the styling runtime resolved for the old
// scheme and paints as an empty white rectangle. Keying by scheme makes a toggle a
// remount of six small cards, while the memo goes on doing its job within a theme.
// The Pokédex grid does not memoise its cards, which is why it never showed this.
<PartySlot
key={`${member.uid}:${colorScheme}`}
member={member}
onOpen={openDetail}
onRemoveMember={removeMember}
/>
Run it
Native code changed on both platforms, so both need real builds, as post 4's screens and post 11's animation pods did. This time the compiled code is the series' own. Three dev servers, each in its own terminal:
( cd apps/host && npm start )
( cd apps/list && npm run start:remote )
( cd apps/party && npm run start:remote )
Restart the host's dev server with
--reset-cacheif it was already running. A server started before contracts 3.3.0 was installed keeps serving the old resolution, and boot dies withTypeError: undefined is not a functionatApp.tsx: a cached contracts build with noregisterShellNavigateHandler.
Then the builds, and the suites with them:
( cd apps/host && npm run ios )
( cd apps/host && npm run android )
for d in apps/host apps/party; do ( cd $d && npx jest --silent ); done
And the native suites, swapping the simulator name for one you have installed:
( cd apps/host/android && ./gradlew :app:testDebugUnitTest )
( cd apps/host/ios && xcodebuild test -workspace Host.xcworkspace -scheme Host -only-testing:HostTests -destination 'platform=iOS Simulator,name=iPhone 17 Pro' )
Add two Pokémon, open the Party tab, press Quick Battle. The native sheet rises wearing the grid's own cards, badge aside; Battle rings the winner in its type colour; Done dismisses; the banner names the winner the promise carried back. Flip the theme first and the native screen follows, from the scheme the call sent:
🎞️ Animated demo: watch it on warrendeleon.com
The badge is the only giveaway. That is the design system holding across a boundary no bundle crosses:
Now break it
With the round trip working, the bridge's dangerous property deserves one deliberate viewing. The presenter funnels every exit into its settle-once wrapper, so the honest sabotage is the one line every path trusts. In the fetched QuickBattle.swift, find the wrapper and comment out its last call:
var settled = false
let settle: (String) -> Void = { resultJson in
guard !settled else { return }
settled = true
isPresenting = false
// completion(resultJson) <- break-it: the resolve never happens
}
Rebuild, open Quick Battle, battle, tap Done. The sheet dismisses normally, and back on the Party tab the button reads "Battling…" for the life of the process: the await never returns, so the finally that clears battleInFlight never runs. A second tap does nothing, blocked by the in-flight guard. No red box, no log line, no rejected promise. Neither safety net helped: net one fired its settle("{}") straight into the flipped settled guard, and net two had nothing to catch, since the presentation succeeded. The nets guard the paths into the wrapper, and nothing guards the wrapper's own last line. That is worth a moment: defence in depth ends somewhere, and wherever it ends is the line a review has to read hardest.
Nothing crashed, which is the problem.
The series has a small taxonomy of quiet failures, and this is the quietest. Post 8's lost add at least dispatched into a store and showed in devtools; this one leaves no trace, because nothing happened. Android cannot reproduce the same fault: empty out deliverResult, and the system back gesture still lands in onActivityResult with a null Intent and resolves {}. That is the exit table's asymmetry, observed. Restore the line and rebuild.
What you built, and what's next
A federated remote asked for a native screen and got one, without learning anything about native. The contract exposes one promise-returning function over a one-entry routing table; the host implements it with a TurboModule carrying JSON both ways, presenting SwiftUI on one platform and a Compose Activity on the other, both in the design system's tokens and theme. The winner's uid returns on the promise and lands through a private action, crossing no contract action, as post 7's ownership rules predict.
The honest limits: the bridge is one-way, so a native screen cannot yet ask the shell to navigate anywhere, and deep links do not route through the table; both are deliberate cuts and the bridge's next additions. The theme travels in the params, so a mid-battle toggle reaches the next battle, not the current one.
Next, the build gets real: a production bundle, and the first federated release artefact.
Sources
- Re.Pack: Module Federation — the limitations list, including "Host application must have native modules used in other containers"
- React Native: Turbo Native Modules introduction — the spec-first workflow this post follows on both platforms
- React Native New Architecture working group: Turbo Modules guide — the worked promise-returning module example
-
React Native New Architecture working group: appendix — the type mappings,
Promise<T>included -
React Native: legacy Android native modules — the deferred-promise pattern around
onActivityResultthat the Kotlin module carries into the new architecture - React Navigation: native stack — the right tool for native-backed React Native screens, and the alternative this bridge is not competing with
- Jetpack Compose BOM — the bill of materials pinning the Compose libraries to one version set
-
react-native-module-federation — the companion repo, the build at the tag
post-13-native-handoff

Top comments (0)