El post 3 cerraba con una promesa: el host deja de ser una sola pantalla y se convierte en un shell de verdad, con la tab bar a su cargo y cada pestaña como un remote cargado en runtime. Eso es este post.
Hasta ahora el host ha cargado una pantalla de un remote, a pantalla completa, sin ningún otro sitio al que ir. Una app de verdad tiene un marco alrededor de sus features: una tab bar, navegación, las partes que siempre están en pantalla. Este post le da ese trabajo al host, y le da la segunda pestaña a un segundo remote.
El reparto de responsabilidades
Una frase resume el post entero: el marco es del host, y las features que van dentro son de los remotes.
El host instala la librería de navegación, monta la tab bar y decide qué pestañas existen. Los remotes siguen siendo pantallas y nada más. Ninguno hace import de un navigator, ninguno sabe que está dentro de una pestaña, y no saben que existe un segundo remote. Añadir una feature debería ser aburrido: construyes otro remote, añades otra pestaña, la despliegas.
Así dos equipos pueden llevar una pestaña cada uno, construirla por separado y desplegarla a su ritmo, porque ninguno tiene que compilar contra el otro.
El punto de partida es el estado final del post 3. Si lo construiste conmigo, ese es el código que ya tienes. Si no:
git clone https://github.com/warrendeleon/react-native-module-federation
cd react-native-module-federation
git checkout post-03-shared-singleton
Un segundo remote para llenar una segunda pestaña
Una pestaña no es una tab bar. Así que añadimos un segundo remote, party, igual que el post 2 construyó el remote list: una app de React Native nueva sobre Re.Pack, sin AppRegistry.registerComponent, exponiendo una pantalla. La ruta exacta, calcada de los pasos de list en el post 2:
npx @react-native-community/cli@20.1.0 init Party --directory apps/party --version 0.85.3
( cd apps/party && npm install -D @callstack/repack@5.2.5 @rspack/core@2.0.5 @module-federation/enhanced@2.5.0 @swc/helpers@0.5.23 @react-native-community/cli@20.2.0 @react-native-community/cli-platform-android@20.2.0 @react-native-community/cli-platform-ios@20.2.0 )
cp apps/list/react-native.config.js apps/party/
cp apps/list/rspack.config.mjs apps/party/
Después dale el mismo entry point casi vacío que tiene list: copia apps/list/src/index.js a apps/party/src/index.js. El index.js de la raíz conserva su registro para ejecuciones en solitario; el entry federado es el que no tiene nada que hacer al arrancar.
Cambian cuatro campos en el apps/party/rspack.config.mjs que acabas de copiar, y los cuatro importan. Sus valores finales:
// output, near the top:
uniqueName: 'PartyApp',
// inside the ModuleFederationPluginV2 options:
name: 'partyApp',
filename: 'partyApp.container.js.bundle',
exposes: {
'./PartyScreen': './src/PartyScreen.tsx',
},
uniqueNamees el fácil de olvidar.uniqueNameda nombre a las variables globales de carga de chunks de webpack, así que dos remotes que publiquen el mismo valor chocan dentro del runtime del host.
La pantalla que expone, apps/party/src/PartyScreen.tsx, tiene seis huecos vacíos:
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
const SLOTS = [1, 2, 3, 4, 5, 6];
export default function PartyScreen() {
const insets = useSafeAreaInsets();
return (
<View style={[styles.screen, { paddingTop: insets.top + 24 }]}>
<Text style={styles.title}>Party</Text>
<Text style={styles.subtitle}>
Your party is empty. Add up to 6 Pokémon from the Pokédex.
</Text>
<View style={styles.grid}>
{SLOTS.map(slot => (
<View key={slot} style={styles.slot}>
<Text style={styles.slotNumber}>{slot}</Text>
</View>
))}
</View>
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, padding: 24, backgroundColor: '#fff' },
title: { fontSize: 28, fontWeight: '700' },
subtitle: { fontSize: 14, color: '#6b7280', marginBottom: 24 },
// alignItems: 'flex-start' stops the default stretch from overriding each slot's aspectRatio.
grid: { flexDirection: 'row', flexWrap: 'wrap', gap: 12, alignItems: 'flex-start' },
slot: {
width: '47%',
aspectRatio: 1,
borderRadius: 12,
borderWidth: 1,
borderStyle: 'dashed',
borderColor: '#e5e7eb',
backgroundColor: '#f9fafb',
alignItems: 'center',
justifyContent: 'center',
},
slotNumber: { fontSize: 20, fontWeight: '600', color: '#9ca3af' },
});
Todavía no hay nada en esos huecos, y es a propósito. La app no tiene store, así que el party no tiene dónde guardar sus miembros; eso llega con los posts de estado. Hoy el equipo propietario de esta feature entrega lo más pequeño que puede poner delante de un usuario: una pantalla con una forma y sin datos detrás. Una pantalla sin props y sin estado sigue siendo una feature que el shell puede cargar en runtime.
Lee el inset del safe area con useSafeAreaInsets, y ahí el singleton compartido del post 3 hace su trabajo: el inset viene del provider del host, medido una vez, y este remote lo lee sin llevar su propia copia de la librería.
Dale su propio puerto de dev server para que no choque con list en el 8082. En apps/party/package.json:
"scripts": {
"start:remote": "react-native start --config rspack.config.mjs --port 8083"
}
Ahora hay dos remotes, en el 8082 y el 8083, cada uno una pantalla esperando un host.
El host recibe la navegación
En este build la tab bar es del host, no de los remotes. Instala los paquetes de navegación solo en el host:
( cd apps/host && npm install @react-navigation/native@7.3.1 @react-navigation/bottom-tabs@7.18.0 react-native-screens@4.25.2 )
( cd apps/host/ios && bundle exec pod install )
Ese pod install no es opcional, y tampoco una build nativa nueva después.
react-native-screenslleva código nativo. Sáltatelo y la build se para con'RNSViewInteractionAware.h' file not found, que parece una librería rota y en realidad es un pod que falta. Es la primera dependencia nativa que asume el host desde el post 2, y un adelanto de una restricción a la que la serie vuelve más adelante: el JavaScript puede llegar por la red, el código nativo no.
La parte de federación es el contrato del post 3 leído al revés. Los singletons compartidos siguen siendo exactamente los mismos: react, react-native y react-native-safe-area-context. React Navigation y react-native-screens no se comparten, porque ningún remote los importa.
Compartirlo todo por defecto es una costumbre defendible: una decisión menos por dependencia, y ninguna posibilidad de olvidarte de algo que necesitabas. Pero compartir sirve para que dos copias de una librería no se carguen a la vez en un mismo runtime, y la navegación nunca sale del host, así que no hay una segunda copia con la que chocar.
Que ambos lados toquen una librería es lo que hace que valga la pena plantearse compartirla, no lo que lo vuelve obligatorio. Lo que decide es si la librería lleva identidad: estado a nivel de módulo, un contexto de React, un módulo nativo que se registra una vez por proceso. Esa prueba sale del post 3, y el post 7 la convierte en una regla sobre quién responde de cada frontera. Esas tienen que ser una sola copia. Una librería de solo valores que ambos lados importan, sin ninguna instancia sobre la que discrepar, se puede duplicar sin riesgo y a menudo sale más barato.
El shell
Reescribe apps/host/App.tsx. El host pasa a montar un SafeAreaProvider, un NavigationContainer y un navegador de pestañas inferior, y el contenido de cada pestaña es un remote que se carga de forma lazy:
import React, { Suspense } from 'react';
import { ActivityIndicator, StyleSheet } from 'react-native';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { NavigationContainer } from '@react-navigation/native';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
const PokedexScreen = React.lazy(() => import('listApp/PokedexScreen'));
const PartyScreen = React.lazy(() => import('partyApp/PartyScreen'));
// A remote downloads the first time its tab is opened, so each tab renders behind a Suspense
// spinner. Wrapping once here keeps the lazy boundary out of the remotes.
function withSuspense(Remote: React.ComponentType) {
return function Tab() {
return (
<Suspense fallback={<ActivityIndicator style={styles.loader} size="large" />}>
<Remote />
</Suspense>
);
};
}
const PokedexTab = withSuspense(PokedexScreen);
const PartyTab = withSuspense(PartyScreen);
const Tab = createBottomTabNavigator();
export default function App() {
return (
<SafeAreaProvider>
<NavigationContainer>
<Tab.Navigator screenOptions={{ headerShown: false }}>
<Tab.Screen name="Pokédex" component={PokedexTab} />
<Tab.Screen name="Party" component={PartyTab} />
</Tab.Navigator>
</NavigationContainer>
</SafeAreaProvider>
);
}
const styles = StyleSheet.create({
loader: { flex: 1 },
});
Cada pestaña es un remote detrás de React.lazy y Suspense, así que la app solo descarga la pestaña con la que arranca y deja la otra hasta que la pulsas.
El host necesita saber dónde vive el segundo remote. Añádelo a remotes en apps/host/rspack.config.mjs:
remotes: {
listApp: `listApp@http://localhost:8082/${platform}/mf-manifest.json`,
partyApp: `partyApp@http://localhost:8083/${platform}/mf-manifest.json`,
},
Y dile a TypeScript qué forma tiene el nuevo import federado, en apps/host/mf-modules.d.ts:
declare module 'partyApp/PartyScreen' {
import type React from 'react';
const PartyScreen: React.ComponentType;
export default PartyScreen;
}
Un archivo más, o los tests se rompen antes de arrancar. React Navigation y react-native-screens publican módulos ES, y el preset de jest de React Native solo pasa por Babel los paquetes del propio React Native: su transformIgnorePatterns lista react-native, @react-native y @react-native-community, y nada más. En apps/host/jest.config.js:
module.exports = {
preset: '@react-native/jest-preset',
transformIgnorePatterns: [
'node_modules/(?!((jest-)?react-native|@react-native(-community)?|@react-navigation|react-native-screens)/)',
],
};
Ejecútalo
Ahora cuatro terminales, uno por remote, uno para el host, más la build:
cd apps/list && npm run start:remote # :8082
cd apps/party && npm run start:remote # :8083
cd apps/host && npm start # :8081
cd apps/host && npm run ios
El host arranca en la pestaña Pokédex y renderiza el remote list. Pulsa Party y mira el terminal del 8083: su primera petición llega en ese momento, no al arrancar. El host descarga el contenedor, lo ejecuta, y aparece la cuadrícula vacía.
Dos features, construidas y servidas por dos apps distintas, dentro de una tab bar que no es de ninguna de las dos.
Ahora rómpelo
El host y el remote se ponen de acuerdo en un nombre escrito en dos archivos distintos, y nada comprueba que sigan coincidiendo. Rompe el acuerdo a propósito. En apps/party/rspack.config.mjs, renombra el contenedor:
new Repack.plugins.ModuleFederationPluginV2({
name: 'partyRemote', // el host sigue pidiendo partyApp
filename: 'partyApp.container.js.bundle',
Reinicia el dev server de party. Un cambio en la config de rspack no se recarga en caliente, así que un refresco normal sigue sirviendo el manifest viejo y esconde el fallo. Después relanza la app y pulsa Party:
[ Federation Runtime ]: Unhandled error. ([ScriptManager] Failed while resolving script locator:)
while loading "./PartyScreen" from webpack/container/reference/partyApp
args: [ { scriptId: 'partyRemote', caller: undefined } ]
originalError: [Error: No resolver was able to resolve script partyRemote]
Léelo de atrás hacia delante y la cadena queda clara. El host pidió partyApp, porque esa es la clave en su mapa de remotes. Descargó el manifest del 8083, y el manifest declaraba un contenedor llamado partyRemote. El runtime buscó entonces un script con ese nombre, y el host no tiene ningún partyRemote registrado, así que su resolver no tiene con qué emparejarlo. No faltaba nada y nada estaba caído. El renombrado se aplicó en uno de los dos archivos que tienen que coincidir, no en los dos.
Fíjate en dónde aparece el fallo. Sale una pantalla roja, no el spinner: un error sin capturar, no una pestaña que espera para siempre. Suspense gestiona una promesa que todavía no se ha resuelto; no captura una que se rechaza. Nada en esta app está pendiente de eso, lo cual está bien en un portátil con dos dev servers y mucho menos bien cuando el remote viene de una CDN (una red de distribución de contenido) en producción. Capturarlo bien necesita un error boundary alrededor de la pestaña lazy. La serie no le dedica un post propio, así que trátalo como trabajo que este shell todavía debe: la historia de fallback y recuperación llega con la entrega por CDN, donde un remote que falta deja de ser un problema de portátil.
Vuelve a poner el nombre, reinicia el server, pulsa Party y la cuadrícula vuelve.
Lo que has construido, y qué viene ahora
El host ya es un shell. Lleva la navegación y la tab bar; cada pestaña es un remote construido y desplegado por su cuenta y cargado en runtime. Los remotes siguen siendo simples: renderizan una pantalla y no saben nada de cómo están colocados. Una tercera feature es un tercer remote y una tercera pestaña, sin tocar las que ya están desplegadas.
Termina exactamente en el estado final de este post. El recorrido imprime los archivos que sostienen el build; los manifests, las configs 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. Un archivo que escribiste bien se sobrescribe consigo mismo, y el volcado rellena lo que la prosa no imprimió:
npx degit@3.8.0 --force warrendeleon/react-native-module-federation#post-04-host-shell /tmp/pokedex-ref-04
cp -R /tmp/pokedex-ref-04/. .
El código terminado de este post es el tag post-04-host-shell, si clonaste en vez de construir a la vez:
git checkout post-04-host-shell
Lo siguiente en la serie: las dos pestañas van a querer abrir la misma pantalla de detalle de un Pokémon. Una pantalla que comparten dos equipos es un componente, no otra unidad de despliegue, y lo primero en lo que dos apps construidas por separado tienen que ponerse de acuerdo es qué se le pasa. Ese acuerdo no puede depender de ninguno de los dos lados, así que se convierte en un paquete: un contrato, publicado e instalado por versión.
Fuentes
- React Navigation — el navegador de pestañas inferior sobre el que se construye el shell del host
- Module Federation 2.0 — los remotes
name@urlque cargan cada pestaña en runtime - react-native-screens — la dependencia nativa sobre la que se apoya React Navigation
- react-native-module-federation — el repo de acompañamiento, en el tag
post-04-host-shell