El post 12 terminó con una oferta del host a los remotes: les presta su lado nativo, a través de un puente que abre una pantalla nativa desde la pestaña Party y espera el resultado. Este post construye ese puente. Un botón de una pestaña federada abre una pantalla totalmente nativa, SwiftUI en una plataforma y Jetpack Compose en la otra, y el ganador de la batalla vuelve como el valor con el que se resuelve una promesa.
La restricción de fondo acompaña a la serie desde el post 1: la entrega en runtime mueve JavaScript y nada más. El post 11 la afrontó en el design system, y la lista de limitaciones de Re.Pack deja escrita la parte del trato que le toca al host: «Host application must have native modules used in other containers». Un remote que quiere una pantalla nativa no puede traerla. El host presta la suya.
En qué consiste el préstamo: un remote ve exactamente una función, shellNavigate(destination, params?) del contrato, que devuelve una promesa. El shell.navigateTo del título es esta función, con el nombre que exporta el contrato. Detrás están la tabla de rutas del host y el primer TurboModule del blog (el sistema de módulos nativos tipados de React Native), con su primer código Swift y Kotlin más allá de la plantilla. El viaje de ida y vuelta:
Una diferencia práctica antes del primer comando. Hasta ahora el clon era una comodidad: todo se tecleaba, y un árbol construido a mano desde el post 2 servía igual de bien. Este es el primer post en el que el repo de acompañamiento es imprescindible: las pantallas nativas salen de su tag, y en el momento en que un TurboModule se registra, tu proyecto nativo tiene que coincidir con el del tag. Empieza desde el estado final del post 12, el tag de inicio:
git clone https://github.com/warrendeleon/react-native-module-federation
cd react-native-module-federation
git checkout post-12-a11y-testing
El estado final es el tag de cierre, post-13-native-handoff; más adelante, durante la build, descargarás de él unos cuantos archivos, y todo lo demás lo tecleas tú.
Una función en el contrato
El contrato suma un archivo, que contiene todo lo que un remote llega a ver del lado nativo. Crea packages/contracts/src/shellNavigation.ts:
import type { PartyMember } from './party';
// --- La superficie de rutas del shell, y todo lo que un remote sabe de lo nativo. Un remote
// importa `shellNavigate` y nada más: sin TurboModule, sin tipos nativos, sin idea de si el
// destino que nombra es otra micro-app o una pantalla totalmente nativa. El host es dueño de la
// tabla que lo decide, así que migrar más adelante un flujo nativo a React Native es editar una
// fila aquí y no cambiar nada en ningún remote. ---
export type RouteEntry =
/** Traspasa a nativo: el host llama a openNative, se presenta una pantalla nativa y la promesa
* se resuelve con lo que esa pantalla haya devuelto. */
{ type: 'native'; nativeId: string };
// Una sola entrada, porque solo una cosa despacha. La unión de arriba está escrita como unión de
// uno para que una variante de micro-app pueda sumarse sin que los lectores de `entry.type`
// cambien de forma; una fila del registro sin nada detrás es una constante muerta, y esta serie
// ya ha pagado por una de esas.
export const ROUTE_REGISTRY: Record<string, RouteEntry> = {
QuickBattle: { type: 'native', nativeId: 'quickBattle' },
};
// --- Lo que cruza la frontera, en ambas direcciones. Estos tipos están en el contrato porque el
// HOST tiene que serializarlos, no porque otra app despache nada: el resultado de la batalla es
// asunto de la party y lo gestiona el reducer de la party. ---
/** RN -> nativo. La party entrega sus miembros; la pantalla nativa los muestra y los enfrenta.
*
* El esquema de color viaja con ellos porque no hay otra opción. Cada superficie federada lee el
* único runtime de estilos que monta el host, y una pantalla nativa es el único consumidor que
* no puede suscribirse a él: no hay bundle que compartir ni provider del que colgarse. Así que
* en esta frontera el tema deja de ser ambiental y pasa a ser un argumento, enviado en el
* momento de la llamada. */
export interface QuickBattleParams extends Record<string, unknown> {
members: PartyMember[];
colourScheme: 'light' | 'dark';
}
/** Nativo -> RN. El uid, no el id: dos copias del mismo Pokémon son dos contendientes, y por esa
* razón exactamente la party pone un uid a cada miembro desde la 3.1.0. Ausente cuando la
* pantalla se cerró sin batalla. */
export interface QuickBattleResult {
winnerUid?: string;
}
/** Nativo resuelve con un objeto, o con nada cuando el flujo terminó sin resultado. */
export type ShellNavigateResult = Record<string, unknown> | undefined;
export type ShellNavigateFn = (
destination: string,
params?: Record<string, unknown>,
) => Promise<ShellNavigateResult>;
// --- El slot que el host rellena y que todos los remotes leen. Es una clave de globalThis y no
// un contexto de React porque este paquete se empaqueta dentro del host Y dentro de cada remote:
// un contexto creado aquí sería un objeto distinto en cada bundle, así que el useContext de un
// remote nunca encontraría el provider del host. La identidad de módulo que los posts 3 y 6
// convirtieron en singleton es justo lo que falta en esta frontera, y globalThis es el único slot
// que los tres bundles comparten de verdad. ---
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') {
// Sin handler: avisa y resuelve. Un remote que espera esto con await recibe undefined y
// renderiza algo honesto, la misma tolerancia sobre la que está construida la forma de
// lectura del slice de la party.
console.warn(`[shellNavigate] no handler registered (called with ${destination})`);
return Promise.resolve(undefined);
}
return fn(destination, params);
}
Los comentarios documentan las tres decisiones: una sola entrada en el registro, porque solo una cosa despacha; el tema como argumento, porque una pantalla nativa no puede suscribirse al runtime de estilos; y un slot de globalThis en lugar de un contexto de React, que al empaquetarse en cada app sería un objeto distinto en cada una. El uid que el prepare del post 8 añade a cada miembro encuentra aquí su primer consumidor al otro lado de la frontera: es el valor que sale de la app y vuelve.
Conviene nombrar antes de nada el native stack de React Navigation: para pantallas de React Native con respaldo nativo dentro de un navigator es la herramienta correcta. Lo que no modela es una pantalla que pertenece al host y vive fuera del árbol de React, que toma la entrada de un remote y le devuelve un valor. Ese viaje de ida y vuelta es la razón de ser del puente.
Expórtalo, sube la versión y publica por el mismo flujo de Verdaccio que estableció el post 5. En packages/contracts/src/index.ts:
export * from './party';
export * from './shellNavigation';
En packages/contracts/package.json, "version": "3.3.0": una versión minor aditiva sobre la 3.2.2 del post 12. Con ella llegan dos ajustes de herramientas: el archivo nuevo llama a console.warn, así que el tsconfig.json de contracts añade "DOM" a su array lib como ya hacen ui y a11y-testing, y el script build incorpora una limpieza de dist para que ningún archivo de declaraciones obsoleto sobreviva hasta un publish:
"build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc",
Publica, y después instálalo en las tres 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
La spec y el codegen
Un TurboModule empieza como una spec de TypeScript, que la generación de código de React Native lee en build y usa para escribir las clases base que las implementaciones nativas extienden. Crea apps/host/specs/NativeShellNavigationModule.ts:
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';
// --- La spec del TurboModule: el único sitio donde el JavaScript del host y su código nativo
// acuerdan una firma. Codegen lee este archivo en build y escribe las clases base de C++/ObjC++
// y Kotlin que las implementaciones nativas extienden, y por eso el archivo está fuera de src/ y
// su nombre tiene que empezar por `Native`.
//
// Un método, y asíncrono por su propia forma: openNative entrega a un flujo nativo su entrada y
// se resuelve con lo que ese flujo haya devuelto. La promesa es todo el diseño — el await de la
// party no retorna hasta que la pantalla nativa ha terminado, y cada camino de salida de esa
// pantalla tiene que resolverla exactamente una vez.
//
// La frontera son strings JSON en ambas direcciones, en lugar de tipos de objeto de codegen.
// Codegen puede llevar objetos estructurados, y un puente más grande debería dejárselo; un string
// mantiene la serialización visible en un solo punto de cada lado, más fácil de seguir mientras
// el puente mide un método de ancho. ---
export interface Spec extends TurboModule {
openNative(nativeId: string, paramsJson: string): Promise<string>;
}
// getEnforcing lanza cuando ningún módulo nativo responde a ese nombre: un binario construido sin
// la mitad nativa, o un desajuste de codegen. Los propios ejemplos de React Native importan una
// spec así a nivel de módulo, y con el módulo dentro del binario eso está bien. El handler del
// host requiere este archivo de forma perezosa, como decisión sobre dónde se permite aparecer a
// ese throw. Importado en el arranque, si falta la mitad nativa la app muere durante la
// evaluación del bundle, antes de que exista ningún error boundary; requerido en la llamada, el
// mismo fallo aparece al pulsar el botón, en una app en marcha que puede registrarlo y seguir. La
// entrega en runtime es lo que hace real ese desfase: el JavaScript puede llegar a un binario que
// se construyó sin el lado nativo.
export default TurboModuleRegistry.getEnforcing<Spec>('ShellNavigationModule');
La frontera son strings JSON en ambas direcciones; el comentario inicial de la spec termina explicando el porqué. Dile a codegen dónde está la spec, en apps/host/package.json:
"codegenConfig": {
"name": "HostSpecs",
"type": "modules",
"jsSrcsDir": "specs",
"android": {
"javaPackageName": "com.host.specs"
}
}
Codegen se ejecuta durante pod install, así que ejecútalo ahora:
( cd apps/host/ios && bundle exec pod install )
El handler del host es el lector de la tabla de rutas. Crea apps/host/src/shell/shellNavigation.ts:
import { ROUTE_REGISTRY, type ShellNavigateFn, type ShellNavigateResult } from '@pokedex/contracts';
import type { Spec as ShellNavigationSpec } from '../../specs/NativeShellNavigationModule';
// --- La mitad del host de shell.navigateTo. Un remote llama al shellNavigate del contrato; esto
// es lo que se ejecuta. Busca el destino en la tabla de rutas y entrega los destinos nativos al
// TurboModule, JSON de entrada y JSON de salida. El remote nunca sabe qué rama tomó. ---
// El módulo de la spec llama a TurboModuleRegistry.getEnforcing al importarse, y eso lanza cuando
// ningún módulo nativo responde a ese nombre. Requerirlo aquí, dentro de la llamada, decide dónde
// puede aparecer ese throw: en el toque, en un shell en marcha que lo registra y sigue, en lugar
// de durante la evaluación del arranque, donde la falta de la mitad nativa mataría la app sin
// ningún 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) {
// Un destino desconocido es un bug del que llama, no un crash: avisa y resuelve, para que un
// remote construido contra un contrato más nuevo que el host se degrade a que no pase nada.
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 {
// Un resultado nativo que no parsea es un bug del lado nativo, y no una razón para hacer
// estallar el await del remote: avisa y resuelve, la misma tolerancia que se aplica en todos
// los demás puntos de esta frontera.
console.warn(`[shellNavigate] unparseable native result for ${destination}`);
return undefined;
}
};
El
require()perezoso elige dónde falla la app si falta la mitad nativa: en el toque, no en el arranque.getEnforcinglanza cuando ningún módulo responde a ese nombre. Los propios ejemplos de React Native importan la spec a nivel de módulo, y eso funciona mientras el módulo está en el binario; la entrega en runtime hace real el desfase. En el arranque, el fallo es una pantalla en blanco sin error boundary; en la llamada, un fallo en un solo botón que la app sí puede registrar en el log.
El registro son dos líneas en apps/host/App.tsx:
import { partyStateReady, registerShellNavigateHandler } from '@pokedex/contracts';
// ...
import { store } from './src/store';
import { shellNavigateHandler } from './src/shell/shellNavigation';
// El host rellena el slot de navegación del contrato una vez, a nivel de módulo, antes de que
// ningún remote pueda renderizar y llamar a shellNavigate. Registrar el handler no toca código
// nativo — el TurboModule no se alcanza hasta que de verdad se navega a un destino — así que esto
// es seguro al importar, de una forma en que requerir la spec aquí no lo sería.
registerShellNavigateHandler(shellNavigateHandler);
Escribe el puente, descarga las pantallas
Desde aquí el trabajo se divide según lo que enseña cada mitad. El puente es federación, y lo tecleas entero: el módulo que mantiene una promesa abierta a través de la frontera, su registro y el manejo de hilos. Las pantallas son código de UI en SwiftUI y Compose, así que llegan desde el tag de cierre de este mismo post. Alrededor de mil líneas se quedan fuera de tu teclado, ninguna sin explicar.
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
Las dos pantallas siguen el design system. Ningún bundle cruza esta frontera, así que cada archivo replica a mano colours.ts y los dieciocho colores de tipo, reconstruidos con el mismo aspecto de tarjeta que renderiza la cuadrícula de la Pokédex: el disco tintado del sprite, el número con almohadilla sobre su etiqueta redondeada, los badges de tipo y la franja de color de acento en la base. Cada pantalla traduce el colourScheme que llega en los params a los mismos pares de colores que usan las reglas dark:; los botones de Compose declaran Role.Button, porque un Text con estilos se anuncia a TalkBack como texto plano; y cada pantalla muestra un badge morado de NATIVE. Ninguna pantalla federada lo tiene, así que con una captura basta para saber de qué lado de la frontera está cada una.
Con las pantallas llegan cuatro suites. Las dos de JavaScript prueban la parte JavaScript del puente: la del host, el slot del contrato y el handler, resultados no parseables incluidos; la de la party, el bloqueo del botón y que el ganador llega a través de la acción privada. Las dos nativas prueban las garantías de la promesa que construyen las dos secciones siguientes, y se configuran después de la tabla de salidas.
iOS: cada salida resuelve
El módulo de iOS es Objective-C++, y la extensión es la clave: getTurboModule devuelve el módulo JSI generado (JavaScript Interface, la capa C++ bajo la nueva arquitectura) como un std::shared_ptr, que Swift no puede expresar. La pantalla sigue siendo SwiftUI puro; el .mm solo hace de adaptador. En Xcode, crea ShellNavigationModule.h y ShellNavigationModule.mm en el grupo Host, lo que los mete en el target, y añade el QuickBattle.swift descargado con File → Add Files to “Host”.
#import <Foundation/Foundation.h>
#import <React/RCTBridgeModule.h>
#import <HostSpecs/HostSpecs.h>
// --- La mitad nativa del host de shell.navigateTo en iOS. Codegen leyó specs/
// NativeShellNavigationModule.ts y escribió NativeShellNavigationModuleSpecBase y el protocolo
// NativeShellNavigationModuleSpec en HostSpecs; esta clase hereda del primero y se ajusta al
// segundo, y por eso el `openNative` de JavaScript acaba ejecutándose aquí.
//
// La cabecera existe porque la implementación es ObjC++ (.mm) y no Swift: devolver el módulo JSI
// de C++ generado desde getTurboModule necesita C++, y Swift no puede. La pantalla que presenta
// es SwiftUI puro — ver QuickBattle.swift. ---
@interface ShellNavigationModule : NativeShellNavigationModuleSpecBase <NativeShellNavigationModuleSpec>
@end
#import "ShellNavigationModule.h"
#import <HostSpecs/HostSpecs.h>
// Host-Swift.h declara todas las clases Swift @objc de esta app, y así es como este archivo
// ObjC++ llega a QuickBattlePresenter. Se importa después de la cabecera del app delegate para
// que las referencias adelantadas de la cabecera generada resuelvan en esta unidad de
// traducción.
#import <React-RCTAppDelegate/RCTDefaultReactNativeFactoryDelegate.h>
#import "Host-Swift.h"
// --- La implementación del TurboModule. Aquí hay dos cosas y nada más: el registro que pone el
// módulo en el registry bajo el nombre que pidió la spec, y openNative.
//
// openNative es donde empieza la promesa. Entrega el bloque resolve al presenter y retorna de
// inmediato; el bloque se llama más tarde, desde la pantalla nativa, cuando esa pantalla termine.
// Hasta entonces el `await` de la party está genuinamente suspendido. Ese es todo el traspaso, y
// también todo el riesgo: si algún recorrido por la pantalla nativa no llama nunca al bloque, la
// promesa queda pendiente para toda la vida del proceso. ---
@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);
}];
}
// El módulo C++ de codegen. Este método es la razón de que el archivo sea ObjC++ y no Swift:
// devuelve un std::shared_ptr, que Swift no tiene forma de expresar.
- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
(const facebook::react::ObjCTurboModule::InitParams &)params
{
return std::make_shared<facebook::react::NativeShellNavigationModuleSpecJSI>(params);
}
@end
Lo que ocurre con ese bloque resolve es el núcleo del post. Cada salida de la pantalla nativa resuelve la promesa, exactamente una vez, y todas pasan por un wrapper que solo acepta la primera resolución. La llamada llega fuera del hilo principal, así que antes de tocar UIKit se salta a main. El controller que presenta viene de hostProvider, una variable estática que por defecto busca en la key window; solo el bundle de tests la reasigna. Una guarda de reentrada resuelve al momento una segunda presentación; la hoja tiene bloqueado el cierre con gesto; y dos redes de seguridad cubren las salidas que no pasan por ningún botón. La primera cubre el caso de una hoja que desaparece de la pantalla sin pasar por finish(). La segunda cubre una presentación que UIKit rechaza en silencio, y no da nada por hecho: no está documentado si el completion de un present() rechazado llega a ejecutarse. Un turno más tarde en la cola principal, un controller rechazado sigue sin presentingViewController; si no lo tiene, nadie va a cerrar esa hoja nunca, así que el wrapper resuelve:
// Todas las rutas de abajo acaban aquí, y esto resuelve como mucho una vez. Las salidas
// propias de la batalla compiten con las dos redes de seguridad de más abajo, y gana la primera
// que llegue; el resto se convierten en no-ops en lugar de resoluciones dobles.
var settled = false
let settle: (String) -> Void = { resultJson in
guard !settled else { return }
settled = true
isPresenting = false
completion(resultJson)
}
// openNative llega en la cola propia del TurboModule, no en el hilo principal. Toda llamada a
// UIKit de aquí abajo tiene que ir en main, así que salta antes de tocar nada.
DispatchQueue.main.async {
guard let host = Self.hostProvider(), !isPresenting else {
// Nada desde lo que presentar, o un flujo ya en marcha: resuelve en lugar de quedarte
// atascado.
completion("{}")
return
}
isPresenting = true
// El resultado se resuelve ANTES de que empiece el cierre, no en su completion.
// viewDidDisappear se dispara mientras la hoja aún se está animando hacia fuera, así que una
// resolución programada tras la transición perdería la carrera contra la primera red y el
// ganador llegaría como "{}".
let view = QuickBattleView(contestants: contestants, isDark: isDark) { resultJson in
settle(resultJson)
host.dismiss(animated: true)
}
let controller = QuickBattleHostingController(rootView: view)
controller.modalPresentationStyle = .pageSheet
// Salir solo por los controles propios de la pantalla. Un cierre interactivo con swipe
// quitaría la hoja sin pasar por la resolución de arriba, dejando la promesa pendiente.
controller.isModalInPresentation = true
// Primera red de seguridad: la hoja desaparece de la pantalla por cualquier ruta que no pase
// por los botones — basta con que se desmonte un ancestro. Mejor una resolución sin resultado
// que una promesa pendiente.
controller.onDisappear = { settle("{}") }
// Segunda red de seguridad: present() no hace nada, en silencio, cuando el host está en mitad
// de una transición por razones que este archivo no puede ver. No está documentado si UIKit
// ejecuta el completion de un present rechazado, así que nada de aquí depende de eso: un turno
// más tarde en la cola principal, un controller presentado tiene presentingViewController y
// uno rechazado sigue sin él, y nil significa que nadie va a cerrar nunca esta hoja, así que
// resuelve ahora.
host.present(controller, animated: true)
DispatchQueue.main.async {
if controller.presentingViewController == nil {
settle("{}")
}
}
}
Hay un orden que importa: el resultado se resuelve antes de que empiece el cierre, porque viewDidDisappear se dispara en mitad de la animación. En la vista, las dos salidas, Close y Done, pasan por un único finish(), y una salida sin batalla es un resultado y no un 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)
}
Una promesa que no se resuelve nunca no tiene red de seguridad. Sin timeout en el puente, sin rescate del recolector de basura, sin error que observar: el
awaitsencillamente no retorna nunca, y el bloque resolve y todo lo que captura quedan retenidos para toda la vida del proceso. El fallo contrario cuesta como mucho una línea de log, porque el puente ignora una segunda resolución. Resolver en cada salida es obligatorio; resolver dos veces se tolera.
Android: cada salida es un resultado
Android obtiene la mayor parte de la garantía de la estructura: la pantalla es una Activity lanzada para un resultado, la plataforma devuelve cada salida de una pantalla que llegó a abrirse, y el listener del módulo es el único sitio donde la promesa se resuelve cuando llega un resultado. Lo que la estructura no cubre es el propio lanzamiento (un start que lanza una excepción, o un desmontaje que compite con el salto al hilo de UI), así que el módulo cubre los dos casos a mano. Crea 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
// --- La mitad nativa del host de shell.navigateTo en Android, y el espejo del
// ShellNavigationModule de iOS. Codegen leyó el mismo archivo de spec y escribió
// NativeShellNavigationModuleSpec en com.host.specs; esta clase lo extiende, y así el
// `openNative` de JavaScript acaba ejecutándose aquí.
//
// La estructura de Android resuelve por sí sola casi toda la disciplina de la promesa. La
// pantalla es una Activity lanzada para un resultado, así que la plataforma entrega un resultado
// por cada salida de una pantalla que llegó a abrirse — el botón Done, el gesto de volver del
// sistema — y onActivityResult es el único sitio donde la promesa se resuelve cuando llega un
// resultado. Lo que la estructura no cubre es el propio lanzamiento: un start que lanza una
// excepción, o un desmontaje que compite con el salto al hilo de UI. Esos dos casos se cubren a
// mano abajo. ---
class ShellNavigationModule(reactContext: ReactApplicationContext) :
NativeShellNavigationModuleSpec(reactContext) {
// Retenida desde que se lanza la Activity hasta que vuelve. De una en una, la contraparte del
// flag isPresenting del presenter de iOS. Los dos campos se tocan desde el hilo de módulos
// nativos (openNative, invalidate) y desde el hilo de UI (el bloque de lanzamiento, el listener
// del resultado), así que cada mutación va dentro de synchronized(this).
private var pendingPromise: Promise? = null
// Se activa una vez en invalidate y nunca vuelve atrás: un lanzamiento encolado antes del
// desmontaje no debe abrir una pantalla después de él, porque nadie escucha y su resultado
// podría llegar a una batalla posterior.
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
// Cada salida pasa por aquí. Una batalla que terminó trae su JSON; un gesto de volver no
// trae ningún dato, y un objeto vacío es la respuesta honesta en ese caso — la party lee
// un winnerUid ausente como "no pasó nada".
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) {
// Nada desde lo que lanzar: resuelve ya, en lugar de dejar al que llama esperando un
// resultado que nunca se le entregará.
promise.resolve("{}")
return
}
synchronized(this) {
if (pendingPromise != null || invalidated) {
// Una batalla ya en marcha, o el módulo ya desmontado: la misma respuesta.
promise.resolve("{}")
return
}
pendingPromise = promise
}
// nativeId queda sin leer: hoy existe un solo flujo nativo, y una segunda fila del registro
// necesitaría antes un switch aquí. openNative llega en una cola de fondo; salta al hilo de
// UI para lanzar la Activity en lugar de asumir que esa cola es segura para lanzarla.
activity.runOnUiThread {
synchronized(this) {
// invalidate puede ejecutarse entre el encolado de arriba y este bloque: para entonces la
// promesa ya está resuelta, y lanzar de todos modos abriría una pantalla que nadie
// escucha.
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) {
// Un lanzamiento que lanza excepción no deja ninguna Activity que entregue un
// resultado. Resuelve ya, o el await quedará esperando a una pantalla que nunca se
// abrió.
pendingPromise?.resolve("{}")
pendingPromise = null
}
}
}
}
override fun invalidate() {
// Una recarga en desarrollo o un desmontaje del host con una batalla abierta dejaría el
// await del que llama esperando para siempre: el listener está a punto de irse, así que
// resuelve la promesa pendiente como lo haría una salida sin resultado, antes de que ya no
// pueda resolverse, y rechaza cualquier lanzamiento aún encolado.
synchronized(this) {
invalidated = true
pendingPromise?.resolve("{}")
pendingPromise = null
}
reactApplicationContext.removeActivityEventListener(activityEventListener)
super.invalidate()
}
companion object {
private const val QUICK_BATTLE_REQUEST = 0xB47
}
}
iOS se registró con una macro; Android escribe el registro, en una clase de paquete para la que la plantilla deja un hueco. La clase base com.host.specs aparece en la primera build de Gradle, cuando se ejecuta el codegen de Android; si el editor se queja antes de esa build, se queja antes de tiempo, no por un error real. Crea 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
// --- El registro. iOS lo recibe gratis de RCT_EXPORT_MODULE y el autolinking; Android lo quiere
// por escrito, en dos mitades. getModule construye la instancia cuando el registry la pide por
// nombre, y getReactModuleInfoProvider declara cuál es ese nombre y cómo se comporta — el último
// flag es el que importa aquí, porque es el que marca el módulo como TurboModule en lugar de
// módulo del bridge legacy.
//
// El paquete se añade a la lista de MainApplication, en el hueco que la plantilla de React Native
// deja comentado exactamente para esto. ---
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
)
)
}
}
En MainApplication.kt, el hueco comentado de la plantilla por fin se usa:
PackageList(this).packages.apply {
// Los módulos nativos propios del host. El autolinking encuentra paquetes en node_modules; un
// módulo que está en la propia app se añade aquí, en el hueco que la plantilla le deja.
add(HostNativePackage())
},
La pantalla descargada es el primer Jetpack Compose del repo, así que la build declara el compilador y las librerías. En apps/host/android/build.gradle:
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin")
// Kotlin 2.x distribuye el compilador de Compose como plugin aparte; la pantalla nativa de
// Quick Battle es lo único de la app que lo necesita.
classpath("org.jetbrains.kotlin:compose-compiler-gradle-plugin:$kotlinVersion")
En apps/host/android/app/build.gradle, el plugin, la build feature y las dependencias:
apply plugin: "org.jetbrains.kotlin.android"
apply plugin: "org.jetbrains.kotlin.plugin.compose"
// Jetpack Compose, para la pantalla nativa de Quick Battle (la contraparte del SwiftUI de iOS).
buildFeatures {
compose true
}
// --- Jetpack Compose, para QuickBattleActivity. El BOM fija la familia de Compose desde una
// sola versión, así que esos artefactos no llevan ninguna propia; activity-compose queda fuera
// del BOM y se fija a sí mismo. ---
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")
La Activity se suma a AndroidManifest.xml, sin exportar:
<!-- La pantalla nativa de Quick Battle. exported=false: nada fuera de esta app la lanza;
la lanza el TurboModule del shell, con startActivityForResult. -->
<activity
android:name=".QuickBattleActivity"
android:exported="false"
android:theme="@style/AppTheme" />
Compara la salida de la Activity descargada con la versión Swift: Done llama a deliverResult; el botón de volver del sistema no llama a nada, y no necesita nada:
// RESULT_OK con el extra JSON es lo que lee el ActivityEventListener de ShellNavigationModule.
// Un gesto de volver del sistema nunca llega a este método, y no le hace falta: la Activity
// termina sin resultado, y el listener lee un Intent nulo como un objeto vacío.
private fun deliverResult(resultJson: String) {
setResult(Activity.RESULT_OK, Intent().putExtra(EXTRA_RESULT_JSON, resultJson))
finish()
}
En iOS cada salida resuelve porque el código cubre cada una; en Android, porque la plataforma devuelve todas las salidas de una pantalla que llegó a abrirse, y el módulo cubre a mano el propio lanzamiento. El conjunto completo:
| Salida | iOS | Android |
|---|---|---|
| Done tras una batalla | resuelve {"winnerUid": …} | extra de RESULT_OK, resuelve {"winnerUid": …} |
| Close antes de batallar | resuelve {} | extra de RESULT_OK, resuelve {} |
| Swipe para cerrar | bloqueado por isModalInPresentation | no procede, Activity a pantalla completa |
| Volver del sistema | no existe ese gesto en una hoja | Intent nulo, resuelve {} |
Segundo openNative con uno abierto | la guarda de reentrada lo resuelve con {} | la guarda de promesa pendiente lo resuelve con {} |
| El propio lanzamiento falla | el check de nil de la segunda red resuelve {} | el catch alrededor del start resuelve {} |
| Shell desmontado en plena batalla | la red del onDisappear resuelve {} | invalidate() resuelve {} y rechaza un lanzamiento encolado |
La fila del segundo lanzamiento es defensa en profundidad: el flag de batalla en curso de la propia party, que llega ahora, ya lo bloquea desde la UI.
Fija las garantías
La mitad de la tabla recoge situaciones que no se pueden provocar a demanda en un simulador: un lanzamiento que lanza excepción, un desmontaje que compite con el salto de hilo, una presentación que UIKit rechaza. Las dos suites nativas descargadas antes las prueban.
La suite de Android son tests de JVM puros: ShellNavigationModuleTest.kt ejercita el módulo real con la plataforma sustituida por mocks y comprueba la promesa en ocho rutas distintas. El mock de Promise anota cada resolución; el runOnUiThread del mock de Activity se ejecuta en línea, o se captura y se ejecuta después de invalidate(), que es como se reproduce la carrera del desmontaje. Dos ajustes en apps/host/android/app/build.gradle la configuran. En el bloque android:
// Los tests unitarios JVM de ShellNavigationModule se ejecutan contra el android.jar de stubs
// del SDK; valores por defecto en lugar de throws de "not mocked" dejan construir un Intent real
// como no-op mientras los mocks de alrededor llevan las aserciones.
testOptions {
unitTests.returnDefaultValues = true
}
Y en dependencies:
// --- Tests unitarios JVM del módulo del puente: la garantía de resolución de la promesa ante un
// fallo de lanzamiento, un desmontaje y el listener del resultado. Mockito 5 mockea finales por
// defecto, que es lo que permite sustituir Activity y el contexto de React sin envoltorios. ---
testImplementation("junit:junit:4.13.2")
testImplementation("org.mockito:mockito-core:5.14.2")
testImplementation("org.mockito.kotlin:mockito-kotlin:5.4.0")
La suite de iOS necesita un poco de cirugía de proyecto, porque la app no es su anfitriona. En Xcode: File → New → Target → Unit Testing Bundle, con el nombre HostTests y “Target to be Tested” en None; después selecciona los QuickBattlePresenterTests.swift y QuickBattle.swift descargados y marca HostTests en Target Membership. Si el bundle nuevo no está en la acción Test del esquema Host (Product → Scheme → Edit Scheme → Test), añádelo. El bundle compila el presenter directamente en lugar de cargar la app, así que sus tres tests no necesitan arranque de React Native, ni dev server, ni jerarquía de ventanas: sin anfitrión desde el que presentar, una segunda presentación rechazada, y una presentación que UIKit rechaza. Esta última es la red del check de nil, que un test comprueba en lugar de dejarla escrita solo en un comentario. Los tests preparan cada caso reasignando hostProvider.
Conecta el botón
La parte de la party empieza por su slice; las dos piezas nuevas se quedan privadas, en ningún contrato, porque nada fuera de la party las despacha. apps/party/src/partySlice.ts completo:
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
import { addToParty, MAX_PARTY, rootReducer, type PartyMember } from '@pokedex/contracts';
// --- El estado de la party, propiedad plena de la app party. El contrato lleva el action creator
// y la forma de lectura; este archivo lleva lo que significan. Nada fuera de esta app lo importa —
// el host lo carga como módulo federado en el arranque, por el efecto secundario del final. ---
export const partySlice = createSlice({
name: 'party',
initialState: { members: [] as PartyMember[], lastBattleWinnerUid: null as string | null },
reducers: {
// Privado: nadie más despacha remove, así que no viaja en ningún contrato.
remove(state, action: PayloadAction<string>) {
state.members = state.members.filter(m => m.uid !== action.payload);
// Un miembro eliminado no puede seguir siendo el último ganador; el banner nombraría a un
// Pokémon que ya no está en la party.
if (state.lastBattleWinnerUid === action.payload) {
state.lastBattleWinnerUid = null;
}
},
// También privado, y a propósito. El ganador vuelve de una pantalla nativa que presentó el
// HOST, pero acaba en el estado propio de la party y nada fuera de esta app lo despacha —
// así que no aparece en ningún contrato. Los tipos de params y resultado del traspaso están
// en la frontera porque el host tiene que serializarlos, que es una razón distinta de
// cruzar.
setLastBattleWinner(state, action: PayloadAction<string>) {
state.lastBattleWinnerUid = action.payload;
},
},
extraReducers: builder => {
// La interacción que cruza. La app list despacha el addToParty del contrato, y este case lo
// reconoce por el string de tipo: addCase lee actionCreator.type y registra el reducer bajo
// `party/add`, así que el acuerdo sobre ese string es lo que hace funcionar el match, no la
// identidad del objeto creador. Compartir @pokedex/contracts como singleton es lo que evita
// que los dos lados reescriban por separado ese string, el tope y la forma de lectura.
builder.addCase(addToParty, (state, { payload }) => {
if (state.members.length >= MAX_PARTY) return; // el tope va con el propietario
state.members.push(payload);
});
},
});
export const { remove, setLastBattleWinner } = partySlice.actions;
// Importar este módulo es lo que añade el reducer al store compartido. rootReducer es el mismo
// objeto que conectó el configureStore del host — por eso funciona la inyección desde una app
// construida por separado.
rootReducer.inject(partySlice);
Dos tests propios de la party comprueban la forma exacta del slice, así que añaden el mismo campo. En __tests__/partyStateReady.test.ts y __tests__/partySelfRecovery.test.tsx, la aserción toEqual({ members: [] }) pasa a ser:
expect((store.getState() as PartySliceShape).party).toEqual({
members: [],
lastBattleWinnerUid: null,
});
PartyScreen.tsx crece en cuatro sitios. Primero los 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';
A nivel de módulo, la forma de lectura local del propietario y el mínimo de la batalla:
// La vista que el propietario tiene de su propio slice. El contrato lleva `members`, porque los
// módulos ajenos lo leen; el último ganador se lee aquí y en ningún otro sitio, así que queda
// fuera del contrato y se añade a la forma en local.
type PartyOwnShape = PartySliceShape & {
party?: { lastBattleWinnerUid?: string | null };
};
// Dos contendientes es el mínimo para una batalla. Escrito como constante porque el estado
// deshabilitado del botón y la nota bajo él son dos lecturas de la misma regla.
const MIN_CONTESTANTS = 2;
Dentro del componente, los selectores y el 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;
// La única llamada que hace un remote para llegar a nativo. `shellNavigate` es todo lo que esta
// app sabe del otro lado: sin import de TurboModule, sin tipos nativos, sin idea de que la
// pantalla que abre es SwiftUI en una plataforma y Compose en la otra. El host resuelve el
// destino y es dueño de todo lo que hay después.
//
// El await es la clave. No retorna hasta que el flujo nativo ha terminado, y lo que vuelve
// acaba en el estado propio de esta app a través de su propia acción — el viaje de ida y
// vuelta empieza y termina dentro de la party, así que nada de la batalla necesita cruzar el
// contrato.
const [battleInFlight, setBattleInFlight] = React.useState(false);
const onQuickBattle = React.useCallback(async () => {
if (battleInFlight) return;
setBattleInFlight(true);
try {
// El tema se lee en el momento de la llamada, del mismo observable de NativeWind al que se
// suscribe cada clase dark: de la federación. Se envía en lugar de observarse porque la
// pantalla nativa no tiene forma de suscribirse: un cambio de tema con la batalla abierta no
// le llegará.
const result = (await shellNavigate('QuickBattle', {
members,
colourScheme: colorScheme === 'dark' ? 'dark' : 'light',
})) as QuickBattleResult | undefined;
// Sin ganador es un resultado real, no un fallo: la pantalla puede cerrarse sin batallar, y
// el lado nativo resuelve con un objeto vacío cuando pasa.
if (result?.winnerUid) {
dispatch(setLastBattleWinner(result.winnerUid));
}
} finally {
setBattleInFlight(false);
}
}, [battleInFlight, colorScheme, dispatch, members]);
El render añade el botón, su nota y el banner, después de la cuadrícula de huecos:
{/* El único control del traspaso. Deshabilitado por debajo de dos miembros, porque una batalla
necesita dos contendientes; la nota de debajo dice qué regla es, en lugar de dejar que un
botón muerto se explique solo. */}
<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'
}`}
// El mínimo de 44pt se declara, no se hereda de la variante de tamaño, igual que lo declara el
// botón Add del detail: la suite de accesibilidad solo puede comprobar lo que el control
// declara de sí mismo.
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}
{/* Lo que volvió de nativo, renderizado por la app que posee el estado. */}
{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}
Un cambio es fácil de pasar por alto, y en esta build supuso una sesión entera de depuración. Los huecos llenos van dentro de un React.memo a nivel de módulo, y un cambio de tema no altera ninguna de sus props, así que el memo se salta el subárbol y la tarjeta se queda con los objetos de estilo del esquema anterior: un rectángulo blanco vacío. La key del hueco incluye el esquema, así que cambiar de tema pasa a remontar seis tarjetas pequeñas:
// La key incluye el esquema de color, y tiene que incluirlo. Un cambio de tema no altera ninguna
// prop de este hueco, así que el memo de arriba las encuentra idénticas y se salta el subárbol;
// la tarjeta se queda entonces con los objetos de estilo que el runtime de estilos resolvió para
// el esquema anterior y se pinta como un rectángulo blanco vacío. Poner el esquema en la key
// convierte el cambio de tema en un remontaje de seis tarjetas pequeñas, mientras el memo sigue
// haciendo su trabajo dentro de un tema. La cuadrícula de la Pokédex no memoiza sus tarjetas, y
// por eso nunca lo mostró.
<PartySlot
key={`${member.uid}:${colorScheme}`}
member={member}
onOpen={openDetail}
onRemoveMember={removeMember}
/>
Ejecútalo
El código nativo cambió en las dos plataformas, así que las dos necesitan builds reales, como las pantallas del post 4 y los pods de animación del post 11. Esta vez el código compilado es de la propia serie. Tres dev servers, cada uno en su terminal:
( cd apps/host && npm start )
( cd apps/list && npm run start:remote )
( cd apps/party && npm run start:remote )
Reinicia el dev server del host con
--reset-cachesi ya estaba en marcha. Un server arrancado antes de instalar contracts 3.3.0 sigue sirviendo la resolución antigua, y el arranque muere conTypeError: undefined is not a functionenApp.tsx: una build de contracts cacheada sinregisterShellNavigateHandler.
Después las builds, y las suites con ellas:
( 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
Y las suites nativas, cambiando el nombre del simulador por uno que tengas instalado:
( 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' )
Añade dos Pokémon, abre la pestaña Party, pulsa Quick Battle. La hoja nativa aparece con las mismas tarjetas de la cuadrícula, salvo el badge; Battle rodea al ganador con su color de tipo; Done cierra; el banner nombra al ganador que llegó en la promesa. Cambia antes el tema y la pantalla nativa lo respeta, porque usa el esquema que la llamada envió:
El badge es la única pista. Así se mantiene el design system a los dos lados de una frontera que ningún bundle cruza:
Ahora rómpelo
Con el viaje de ida y vuelta funcionando ya, el riesgo del puente merece verse provocado una vez. El presenter dirige cada salida a su wrapper de resolución única, así que la forma más limpia de romperlo es tocar la única línea de la que dependen todas las rutas. En el QuickBattle.swift descargado, busca el wrapper y comenta su última llamada:
var settled = false
let settle: (String) -> Void = { resultJson in
guard !settled else { return }
settled = true
isPresenting = false
// completion(resultJson) <- rómpelo: la resolución nunca ocurre
}
Recompila, abre Quick Battle, batalla, pulsa Done. La hoja se cierra con normalidad, y de vuelta en la pestaña Party el botón dice «Battling…» para toda la vida del proceso: el await no retorna nunca, así que el finally que limpia battleInFlight no se ejecuta nunca. Un segundo toque no hace nada, bloqueado por la guarda de batalla en curso. Sin red box, sin línea de log, sin promesa rechazada. Ninguna red de seguridad ayudó: la primera llamó a su settle("{}"), pero la guarda settled ya estaba activada; y la segunda no tenía nada que hacer, porque la presentación fue bien. Las redes cubren las rutas que llegan al wrapper, y nada cubre la última línea del propio wrapper. Merece pensarse un momento: la defensa en profundidad termina en algún sitio, y la línea donde termina es la que una revisión tiene que leer con más cuidado.
La app no falla, y ese es el problema.
La serie tiene una pequeña colección de fallos silenciosos, y este es el más silencioso. La incorporación perdida del post 8 al menos despachaba a un store y se veía en las devtools; este no deja rastro, porque no pasó nada. Android no puede reproducir el mismo fallo: vacía deliverResult, y el gesto de volver del sistema sigue llegando a onActivityResult con un Intent nulo y resolviendo {}. Esa es la asimetría de la tabla de salidas, vista en directo. Restaura la línea y recompila.
Lo que has construido, y lo que viene
Un remote federado pidió una pantalla nativa y la consiguió, sin aprender nada de nativo. El contrato expone, sobre una tabla de rutas de una sola entrada, una función que devuelve una promesa; el host la implementa con un TurboModule que lleva JSON en los dos sentidos y presenta SwiftUI en una plataforma y una Activity de Compose en la otra, las dos con los tokens y el tema del design system. El uid del ganador vuelve en la promesa y acaba en el estado de la party a través de una acción privada, sin cruzar ninguna acción del contrato, como predicen las reglas de propiedad del post 7.
Los límites, dichos claramente: el puente es de un solo sentido, así que una pantalla nativa aún no puede pedirle al shell navegar a ningún sitio, y los deep links no pasan por la tabla; los dos quedan fuera a propósito y son las siguientes piezas del puente. El tema viaja en los params, así que un cambio en plena batalla llega a la batalla siguiente, no a la actual.
Lo siguiente: un bundle de producción y el primer artefacto de release federado.
Fuentes
- Re.Pack: Module Federation — la lista de limitaciones, incluida «Host application must have native modules used in other containers»
- React Native: Turbo Native Modules introduction — el flujo spec-first que este post sigue en las dos plataformas
- React Native New Architecture working group: Turbo Modules guide — el ejemplo desarrollado de módulo que devuelve una promesa
- React Native New Architecture working group: appendix — los mapeos de tipos,
Promise<T>incluido - React Native: legacy Android native modules — el patrón de promesa diferida alrededor de
onActivityResultque el módulo Kotlin lleva a la nueva arquitectura - React Navigation: native stack — la herramienta correcta para pantallas de React Native con respaldo nativo, y la alternativa con la que este puente no compite
- Jetpack Compose BOM — el bill of materials que fija las librerías de Compose a un solo conjunto de versiones
- react-native-module-federation — el repo de acompañamiento, la build en el tag
post-13-native-handoff