El post 4 terminó con una promesa: las dos pestañas querrían abrir la misma pantalla de detalle de Pokémon, y apps construidas por separado tendrían que ponerse de acuerdo en qué pasarle.
Este post construye eso, y hacen falta tres cambios que se necesitan entre sí. Cada remote deja de exponer una pantalla suelta y expone un stack de navegación entero, así que empujar un detalle es asunto de la pestaña que lo empuja. La propia pantalla de detalle se publica como un paquete versionado que las dos pestañas instalan, no como otra unidad de despliegue, por una razón que merece explicarse. Y el acuerdo sobre lo que cruza hacia esa pantalla se convierte también en un paquete: un contrato con número de versión, que es donde vive la lección de verdad de este post. Al final rompemos el acuerdo a propósito, tres peldaños arriba en una escalera de versiones, y acabamos en un agujero que ningún compilador del repo puede ver.
Continúa desde tu propio código del post 4 si lo has ido construyendo. Si no:
git clone https://github.com/warrendeleon/react-native-module-federation
cd react-native-module-federation
git checkout post-04-host-shell
Cada pestaña pasa a tener su stack
En el post 4 cada remote entregaba al host una pantalla suelta y el host las ordenaba en pestañas. Una pantalla de detalle cambia lo que significa «dentro de una pestaña»: tocar una fila debe empujar una pantalla nueva mientras la barra de pestañas se queda donde está, y quien hace ese push decide la navegación dentro de la pestaña. Eso es asunto del remote, no del shell. Así que cada remote expone ahora un stack.
apps/list/src/ListStack.tsx:
import React from 'react';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import PokemonDetailScreen from '@pokedex/detail';
import type { ListParamList } from './routes';
import PokedexScreen from './PokedexScreen';
const Stack = createNativeStackNavigator<ListParamList>();
export default function ListStack() {
return (
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen
name="PokedexList"
component={PokedexScreen}
options={{ title: 'Pokédex' }}
/>
<Stack.Screen
name="PokemonDetail"
component={PokemonDetailScreen}
options={{ headerShown: true, title: '' }}
/>
</Stack.Navigator>
);
}
Ese import PokemonDetailScreen from '@pokedex/detail' es el segundo cambio del post, y volveremos a él; primero, lo que lo rodea. La superficie pública del remote cambió: expone ./ListStack donde antes exponía ./PokedexScreen, así que su config de rspack renombra la entrada en exposes, y los dos imports lazy del host pasan a ser listApp/ListStack y partyApp/PartyStack. La app party lo replica todo con su propio PartyStack.
El mapa de shared también evoluciona, en las dos direcciones de la regla del post 4. Los remotes ahora importan @react-navigation/native, @react-navigation/native-stack y react-native-screens, y los tres llevan identidad: contexto de navegación, registros a nivel de módulo, una vista nativa registrada una vez por proceso. Así que los tres se unen a los mapas de singletons compartidos de cada lado. Que los importe más de una parte es lo que los pone sobre la mesa; llevar identidad es lo que decide la entrada. Dos de las entradas nuevas declaran version a mano:
'@react-navigation/native': {
singleton: true,
version: navPkg.version,
requiredVersion: pkg.dependencies['@react-navigation/native'],
},
Un paquete resuelto a través de un mapa
exportsno se provee si no declarasversion. En este montaje rspack lee la versión del paquete que está compartiendo, y no la gestiona para un paquete detrás de un mapa así, que es lo que tiene React Navigation. Tómalo como un comportamiento verificado aquí, con estas versiones, y no como una regla general sobre los mapasexports. Sin elversionexplícito el bundler omite el provide en silencio: nada aterriza en el share scope, y la app muere al arrancar con RUNTIME-006 (eager) o empaqueta un duplicado sin decir nada (sin eager). Los paquetes de navegación lo necesitan;react-native-screensno. Mirar si hay un campoexportsen el package.json de un paquete te dice qué tratamiento le toca.
Ponlo en marcha y se nota el cambio de producto: toca una fila, el detalle se empuja dentro de la pestaña, y la barra de pestañas se queda en pantalla.
¿Dónde vive la pantalla compartida?
Los dos stacks montan la misma pantalla de detalle. En el producto de esta serie es una pantalla; en una app real es el tipo de cosa que dos equipos comparten de verdad. ¿Dónde va?
La respuesta federada se insinúa sola: un tercer remote, un detailApp en su propio puerto, declarado en los mapas remotes de los dos consumidores. Funciona mecánicamente (Module Federation resuelve remotes anidados sin quejarse) y mantiene la pantalla actualizable sin tocar a ningún consumidor. También es el corte equivocado. Una frontera de federación es una unidad de despliegue, con su servidor de desarrollo, su pipeline de despliegue y sus propios modos de fallo. Los equipos cortan esas fronteras por dominios: un equipo, un dominio, un remote, varias pantallas dentro. El número de pantallas es la medida equivocada. Lo que se gana una frontera de federación es un equipo que responde de eso y necesita publicarlo a su propio ritmo, y una sola pantalla rara vez trae ninguna de las dos cosas. Aquí la pantalla de detalle pertenece al mismo dominio que la lista, así que viaja como paquete y no como unidad de despliegue.
Una pantalla que comparten dos dominios es un componente, y los componentes compartidos llevan décadas con su mecanismo de entrega: un paquete. Así que la pantalla de detalle se publica como @pokedex/detail, subida a un registro e instalada por las dos apps de pestaña, y cada una la monta en su propio stack donde le parece. Las pestañas siguen siendo las unidades de despliegue. La pantalla es una dependencia. Esa regla se defiende a fondo dentro de dos posts.
packages/detail/src/PokemonDetailScreen.tsx, las partes que importan:
import type { DetailParams } from '@pokedex/contracts';
const POKEMON: Record<number, { name: string; types: string[] }> = {
1: { name: 'Bulbasaur', types: ['Grass', 'Poison'] },
4: { name: 'Charmander', types: ['Fire'] },
7: { name: 'Squirtle', types: ['Water'] },
25: { name: 'Pikachu', types: ['Electric'] },
133: { name: 'Eevee', types: ['Normal'] },
};
export interface PokemonDetailScreenProps {
route: { params: DetailParams };
}
export default function PokemonDetailScreen({ route }: PokemonDetailScreenProps) {
const { id } = route.params;
const pokemon = POKEMON[id];
// renderiza el número de la dex, el nombre, el sprite y los chips de tipo
}
Hay dos cosas deliberadas aquí. La pantalla lleva su propia copia de los datos de Pokémon: la app list tiene otra, y las dos ya se han desviado, porque la lista necesita nombres y esta pantalla necesita además los tipos. Dos copias de los mismos hechos huelen mal, y el olor está plantado a propósito: los datos en vivo borran las dos en el próximo post. Y las props se tipan estructuralmente ({ route: { params: DetailParams } }) en vez de importar los tipos de React Navigation. La pantalla la montan dos stacks que no ha visto nunca, así que declara la forma que necesita y queda libre de una dependencia de navegación. Su import de DetailParams es el paquete de contratos, que este post tiene que explicar ahora.
El acuerdo que no puede ser de ninguna app
Mira lo que tiene que cuadrar. La app list empuja PokemonDetail con { id }. La app party empujará la misma ruta cuando tenga miembros que tocar. La pantalla lee route.params.id. Tres bases de código tocan una misma forma, y ninguna puede definirla para las demás: una definición en la app list es invisible para el paquete de la pantalla, y copiar el tipo a mano en cada una es deriva con cuenta atrás.
El mismo problema existe un nivel más arriba, donde el host consume los remotes. Las declaraciones ambient del host describen módulos sin archivo al que apuntar:
declare module 'listApp/ListStack' {
import type { ListStackModule } from '@pokedex/contracts';
const ListStack: ListStackModule;
export default ListStack;
}
Una declaración ambient es una promesa escrita a mano. TypeScript se la cree porque no hay nada contra lo que comprobarla: el módulo que describe se resuelve en runtime, desde un servidor, mucho después de que el compilador termine. Apuntar la declaración a un tipo de un paquete instalado no hace la promesa comprobable; hace la promesa compartida, para que el host y el remote al menos describan la superficie desde una definición en vez de dos suposiciones. Lo que sigue sin poder atrapar, dicho con honestidad: si el remote renombra su expose, la declaración se queda obsoleta y el compilador sigue en verde. La comprobación de esa frontera no existe en build. Ahora rómpelo: la escalera de versiones está construido sobre esa comprobación que falta.
Así que los acuerdos viven en @pokedex/contracts, un paquete que tiene tipos y nada más:
// packages/contracts/src/params.ts
export interface DetailParams {
id: number;
}
export type DetailParamList = {
PokemonDetail: DetailParams;
};
Cada stack incrusta el fragmento en su propia lista de params (type ListParamList = DetailParamList & { PokedexList: undefined }), el paquete de la pantalla lee DetailParams, y el host tipa sus declaraciones de módulo desde el mismo sitio. Todo lo del paquete se borra en build. Nada suyo llega a un bundle; existe para que cada tsc del repo compruebe contra una sola definición.
Publica los dos a un registro
Un paquete necesita un registro, y las rutas file: no valen: una ruta es una ubicación en una máquina, y el sentido de los dos paquetes es que apps construidas por separado (en una organización real, repos con dueños distintos) instalen el mismo artefacto por nombre y versión. Verdaccio es un registro npm en un solo proceso, suficiente para que todo el flujo sea real:
npx verdaccio # :4873, se queda arriba
npm adduser --registry http://localhost:4873 # cualquier usuario, contraseña y correo
Un .npmrc de una línea en la raíz del repo apunta el scope hacia él (@pokedex:registry=http://localhost:4873/) y los dos paquetes se publican:
( cd packages/contracts && npm install && npm run build && npm publish )
( cd packages/detail && npm install && npm run build && npm publish )
+ @pokedex/contracts@1.0.0
+ @pokedex/detail@1.0.0
El paquete de detalle declara lo que necesita como peers en vez de empaquetarlo:
"peerDependencies": {
"@pokedex/contracts": ">=1",
"react": "*",
"react-native": "*",
"react-native-safe-area-context": ">=5"
}
Un rango peer es una afirmación de compatibilidad, y >=1 es una laxa: dice que esta pantalla funciona con cualquier contrato desde 1.0.0 en adelante. Los rangos laxos dan a los consumidores libertad para actualizar a su ritmo. Lo que cuestan es precisión, y Ahora rómpelo: la escalera de versiones enseña el precio.
Los dos remotes de pestaña instalan los dos paquetes; el host instala solo el contrato, porque en el shell no hay nada que renderice una pantalla de detalle. Una instalación real, por versión, desde el registro, con cada app por su nombre:
( cd apps/host && npm install @pokedex/contracts@1.0.0 )
( cd apps/list && npm install @pokedex/contracts@1.0.0 @pokedex/detail@1.0.0 )
( cd apps/party && npm install @pokedex/contracts@1.0.0 @pokedex/detail@1.0.0 )
Cada consumidor desempaqueta su propia copia en node_modules, con la versión que pidió. Si cuentas el checkout: el host, la app list y la app party tienen una cada uno, y el paquete detail tiene la suya para su propio build, así que hay cuatro copias del contrato en disco. Suena otra vez al problema de la deriva, hasta que notas la diferencia: estas copias llevan número de versión, y los números de versión son de lo que va el resto del post.
Ahora rómpelo: la escalera de versiones
El montaje funciona. Las dos pestañas montan la pantalla instalada, los push comprueban tipos contra el contrato instalado, y el simulador se porta bien. La pregunta interesante es qué pasa cuando los paquetes se mueven y las apps no, porque en una organización real no lo harán, no a la vez. Tres peldaños.
1.1.0, una adición
La app party acabará pasando un id de instancia de hueco cuando un Pokémon tocado venga de un hueco del equipo. Campo opcional, cambio aditivo:
export interface DetailParams {
id: number;
uid?: string;
}
Sube a 1.1.0, compila, publica. Después, en la app list, la parte que casi todas las explicaciones se saltan:
npm install
up to date, audited 1188 packages in 926ms
No pasa nada. El lockfile fija 1.0.0, y npm install respeta el lockfile. El caret del package.json dice lo que esta app aceptaría; el lockfile decide lo que recibe. Tomar el minor es un acto deliberado:
npm update @pokedex/contracts
changed 1 package, and audited 1188 packages in 868ms
Ahora npm ls @pokedex/contracts enseña la nueva resolución, incluida la pantalla, que la acepta a través de su peer laxo:
+-- @pokedex/contracts@1.1.0
`-- @pokedex/detail@1.0.0
`-- @pokedex/contracts@1.1.0 deduped
tsc pasa sin cambiar una línea de código en ningún sitio: un consumidor construido contra 1.0.0 nunca pasa uid, y sigue satisfaciendo el tipo. Eso es lo que hace seguro desplegar un cambio aditivo de forma desigual.
2.0.0, un campo que tiene que estar
El siguiente cambio es de los que rompen. La pantalla de detalle quiere enseñar desde dónde se abrió, así que el param pasa a ser obligatorio, y la pantalla aprende a renderizarlo:
export interface DetailParams {
id: number;
uid?: string;
source: 'pokedex' | 'party';
}
Un campo obligatorio invalida la forma antigua, así que el contrato sube a 2.0.0. El paquete de la pantalla lo sigue hasta ahí: @pokedex/detail@2.0.0, construido contra el contrato nuevo, renderizando una pequeña línea de origen desde route.params.source. En la app list, ya con los dos publicados en el registro:
npm install
up to date, audited 1188 packages in 886ms
Se queda en 1.1.0. El caret rechaza el major sin que se lo pidan, que es la única pieza de todo este montaje que funciona por defecto.
El punto ciego
Tres equipos publican a tres velocidades. El equipo de party adopta los dos 2.0.0 y compila limpio: todavía no empuja nada, así que el nuevo requisito no le cuesta nada. El equipo de list tiene una release fuera y se queda en el contrato 1.1.0. Pero el paquete de la pantalla es una dependencia más, y las dependencias se actualizan: alguien del equipo de list toma la pantalla nueva sin tomar el contrato nuevo.
npm install @pokedex/detail@2.0.0
changed 1 package, and audited 1188 packages in 1s
Sin avisos. El rango peer de la pantalla dice >=1, la app list tiene 1.1.0, y 1.1.0 >= 1. npm queda satisfecho:
+-- @pokedex/contracts@1.1.0
`-- @pokedex/detail@2.0.0
`-- @pokedex/contracts@1.1.0 deduped
Esta es la parte en la que merece la pena frenar. La app list empaqueta ahora una pantalla cuyo código de runtime lee route.params.source. Pregunta qué comprobó su compilador, y la respuesta es: las declaraciones de tipos publicadas de la pantalla dicen que las props toman DetailParams, importado de @pokedex/contracts y resuelto a la copia que el consumidor instaló. La app list lo resolvió a 1.1.0, donde source no existe. Así que el push de { id } de la app list comprueba tipos perfectamente contra la misma pantalla que está a punto de leer un campo que nunca le enviaron. Cada tsc del código está en verde, y cada uno está comprobando una frase distinta de la que el runtime va a pronunciar.
En runtime, la pestaña list empuja a Bulbasaur:
“Opened from the ”, y después nada. Sin crash, sin red box, sin un aviso en ninguna terminal. Una frase con un agujero, enviada a un usuario.
El contrato no falló. Hizo lo que un contrato de tiempo de compilación puede hacer, que es sujetar cada app a la versión que esa app instaló. El peer laxo tampoco falló; hizo exactamente lo que dice >=1. El agujero está entre los dos: un tipo es una promesa de tiempo de compilación, y ya no existe cuando un valor cruza de verdad. Comprobar el valor que llega es otro trabajo, de otras herramientas, en otro momento.
Lo que has construido, y lo que viene
La navegación bajó a los remotes. La pantalla compartida se publica como paquete versionado, porque una frontera de federación es el dominio de un equipo y una pantalla es un componente. Los acuerdos (params y formas de módulo) viven en un paquete de contratos que cada lado instala, y semver gobierna cómo se desvían los tres códigos: los minors se despliegan de forma desigual y sin daño, los majors esperan consentimiento, y el único fallo que se coló lo hizo en silencio, en el hueco entre un rango peer laxo y un tipo borrado.
Termina exactamente en el estado final de este post. El recorrido imprime los archivos que sostienen el build; los manifests, las configs, los tests y los cambios menores viven en el tag. Para acabar con un árbol idéntico byte a byte al del tag, vuelca encima la copia de referencia. Casi todos los archivos que escribiste bien se sobrescriben consigo mismos, y el volcado rellena lo que la prosa no imprimió:
npx degit@3.8.0 --force warrendeleon/react-native-module-federation#post-05-contracts /tmp/pokedex-ref-05
cp -R /tmp/pokedex-ref-05/. .
Eso rellena los manifests de los paquetes, sus exports y la configuración de TypeScript, y los cambios en host, list y party que la prosa resumió.
Una parte de tu árbol sí cambia. La escalera de versiones es una demostración, no un paso sobre el que se apoye la serie: 1.1.0 y 2.0.0 se quedan en tu registro, pero el repo lleva DetailParams adelante como { id: number }. Así que el volcado devuelve el código del contrato, la pantalla de detalle y los tres manifests a su forma de 1.0.0, y el post 6 arranca desde ahí.
El código terminado de este post es el tag post-05-contracts:
git checkout post-05-contracts
Lo próximo en la serie: datos reales. Las listas de Pokémon hardcodeadas desaparecen, las dos copias, y cada pantalla lee de un store alimentado por PokéAPI. Que es donde el agujero de esa frase recibe una respuesta de verdad, porque un tipo comprobado en build no sirve de nada frente a un valor que aparece en runtime.
Fuentes
- Module Federation 2.0 — el runtime de módulos compartidos, y la referencia de RUNTIME-006
- Verdaccio — el registro npm local al que se publican los dos paquetes
- React Navigation — el stack nativo que ahora monta cada remote
- Rangos semver de npm — lo que un caret acepta y no acepta, y lo que afirma un rango peer
- react-native-module-federation — el repo de acompañamiento, en el tag
post-05-contracts