El post 3 cerraba con una promesa: el host deja de ser una sola pantalla y se convierte en un shell de verdad, dueño de la tab bar, con 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 host es dueño del marco, y los remotes son dueños de las features que van dentro.
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 ser dueños de dos pestañas, construirlas por separado y desplegarlas 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
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. Créala junto a las otras, instala sus dependencias exactamente como hiciste con list, y copia el rspack.config.mjs de list.
Cambian cuatro campos, y los cuatro importan: el name del plugin (partyApp), el filename del contenedor (partyApp.container.js.bundle), la pantalla expuesta (./PartyScreen) y output.uniqueName ('PartyApp'). El último es el fácil de olvidar. uniqueName da nombre a las variables globales de carga de chunks de webpack, así que dos remotes con 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 dueño 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
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 @react-navigation/bottom-tabs react-native-screens
cd apps/host/ios && bundle exec pod install
react-native-screens lleva código nativo, así que ese pod install no es opcional, y tampoco lo es una build nativa nueva después. 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 federation 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.
La regla vale en los dos sentidos: comparte una librería cuando haya código de los dos lados que la toque, y solo entonces.
El shell
Reescribe apps/host/App.tsx. El host pasa a ser dueño de un SafeAreaProvider, un NavigationContainer y un navegador de pestañas inferior, y el contenido de cada pestaña es un remote cargado en 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 fichero 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 ficheros 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 manifiesto 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 manifiesto del 8083, y el manifiesto 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 ficheros 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, y eso llega más adelante en la serie.
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. Es dueño de la navegación y de 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.
El código terminado de este post es el tag post-04-host-shell, para que puedas compararlo con el tuyo:
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. Ninguno de los dos lados puede ser dueño de ese acuerdo, 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 compañero, en el tag
post-04-host-shell