Un host i un remote de React Native compartint una sola còpia d'una biblioteca a través d'un únic share scope

El contracte de singletons compartits a React Native Module Federation

El post anterior acabava apuntant aquí: el contracte de singletons compartits, i l’error que fa petar l’app en arrencar. Aquest post cobreix tots dos. Què vol dir de debò shared, les tres opcions que el controlen, i la fallada amb què topa un remote quan trenca el contracte en una biblioteca amb costat natiu. Aquesta fallada és sorollosa, immediata i s’identifica sola, cosa que la converteix en una de les més fàcils d’arreglar.

Reprenem just on ho va deixar el post 2. Si vas seguir el tutorial llavors, queda’t amb el teu propi codi. Si no, parteix de l’estat final del post 2:

git clone https://github.com/warrendeleon/react-native-module-federation
cd react-native-module-federation
git checkout post-02-first-remote

Què volia dir «compartit» al post 2

El post 2 va declarar react i react-native com a singletons compartits i va tirar endavant. Aquí tens la meitat del host, a apps/host/rspack.config.mjs:

shared: {
  react: { singleton: true, eager: true, requiredVersion: pkg.dependencies.react },
  'react-native': {
    singleton: true,
    eager: true,
    requiredVersion: pkg.dependencies['react-native'],
  },
},

Tres opcions fan la feina, i cadascuna respon a una pregunta diferent.

singleton: true respon a «quantes còpies poden existir en runtime?» Una. Quan el host i el remote demanen tots dos react, Module Federation els entrega la mateixa instància en comptes de deixar que cadascun carregui la seva. Aquesta és l’opció clau. React desa l’estat dels seus hooks en variables a nivell de mòdul, així que dues còpies de React en una app volen dir dos conjunts d’estat separats, i qualsevol hook que es comprovi contra el conjunt equivocat llança un error.

eager: true respon a «aquesta còpia està llesta abans que corri la primera línia de l’app?» Al host, sí. Una entry normal de Module Federation és asíncrona: prepara el share scope i després arrenca el teu codi. React Native no et dona aquest marge. La seva entry és síncrona, així que el host marca les seves còpies compartides com a eager per carregar-les al share scope per endavant, abans que AppRegistry renderitzi res. El remote no necessita eager, perquè quan es carrega, el host ja ha omplert l’scope.

requiredVersion respon a «quines versions compten com la mateixa?» Fixa el rang acceptable. Declarar-lo explícitament és un bon costum, però ometre’l no equival a desactivar la comprovació. L’únic cas en què la comprovació sí que desapareix és l’error amb què acaba aquest post.

Fins aquí això és el post 2 amb el raonament afegit. El contracte comença a importar tan bon punt un remote depèn d’una tercera biblioteca, no només de React.

Una dependència de debò: el safe area

El SafeAreaView que porta React Native està deprecat. El reemplaçament mantingut és react-native-safe-area-context, i ve amb la plantilla actual de React Native, així que les dues apps del repo ja el tenen. És una bona prova del contracte perquè funciona a través d’un context de React: un SafeAreaProvider muntat a prop de l’arrel mesura el safe area del dispositiu, i qualsevol component per sota el llegeix amb useSafeAreaInsets.

En una app federada, el provider i el consumidor viuen en bundles diferents. El shell és cosa del host, així que és el host qui munta el provider. Reescriu apps/host/App.tsx:

import React, { Suspense } from 'react';
import { ActivityIndicator, StyleSheet } from 'react-native';
import { SafeAreaProvider } from 'react-native-safe-area-context';

const PokedexScreen = React.lazy(() => import('listApp/PokedexScreen'));

export default function App() {
  return (
    <SafeAreaProvider>
      <Suspense fallback={<ActivityIndicator style={styles.loader} size="large" />}>
        <PokedexScreen />
      </Suspense>
    </SafeAreaProvider>
  );
}

const styles = StyleSheet.create({
  loader: { flex: 1 },
});

El host ja no omple la pantalla ell mateix. Proveeix el context del safe area i entrega tot el llenç al remote. Ara el remote llegeix l’inset i manté el seu propi títol lluny del notch. Actualitza apps/list/src/PokedexScreen.tsx:

import React from 'react';
import { FlatList, StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';

const POKEMON = [
  { id: 1, name: 'Bulbasaur' },
  { id: 4, name: 'Charmander' },
  { id: 7, name: 'Squirtle' },
  { id: 25, name: 'Pikachu' },
  { id: 133, name: 'Eevee' },
];

export default function PokedexScreen() {
  const insets = useSafeAreaInsets();
  return (
    <View style={[styles.screen, { paddingTop: insets.top + 24 }]}>
      <Text style={styles.title}>Pokédex</Text>
      <Text style={styles.subtitle}>Served by the list remote</Text>
      <FlatList
        data={POKEMON}
        keyExtractor={p => String(p.id)}
        renderItem={({ item }) => (
          <View style={styles.row}>
            <Text style={styles.number}>#{String(item.id).padStart(3, '0')}</Text>
            <Text style={styles.name}>{item.name}</Text>
          </View>
        )}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { flex: 1, padding: 24, backgroundColor: '#fff' },
  title: { fontSize: 28, fontWeight: '700' },
  subtitle: { fontSize: 14, color: '#6b7280', marginBottom: 16 },
  row: {
    flexDirection: 'row',
    paddingVertical: 12,
    borderBottomWidth: StyleSheet.hairlineWidth,
    borderBottomColor: '#e5e7eb',
  },
  number: { width: 56, color: '#9ca3af', fontVariant: ['tabular-nums'] },
  name: { fontSize: 16, fontWeight: '500' },
});

Ara hi ha un handshake de context que travessa la frontera entre bundles: el provider és al host, i la crida a useSafeAreaInsets és al remote. Perquè això connecti, les dues apps necessiten el mateix SafeAreaProvider, de la mateixa còpia de la biblioteca. Un context de React s’identifica per l’objecte que el crea. Dues còpies de la biblioteca creen dos objectes de context diferents, i un consumidor que llegeix la còpia B mai no veurà un provider muntat des de la còpia A.

Per a això serveix el contracte. Afegeix la biblioteca a shared a les dues configs, com a singleton. El host (apps/host/rspack.config.mjs), eager com les seves altres còpies compartides:

shared: {
  react: { singleton: true, eager: true, requiredVersion: pkg.dependencies.react },
  'react-native': {
    singleton: true,
    eager: true,
    requiredVersion: pkg.dependencies['react-native'],
  },
  'react-native-safe-area-context': {
    singleton: true,
    eager: true,
    requiredVersion: pkg.dependencies['react-native-safe-area-context'],
  },
},

I el remote (apps/list/rspack.config.mjs), singleton però no eager:

shared: {
  react: { singleton: true, requiredVersion: pkg.dependencies.react },
  'react-native': {
    singleton: true,
    requiredVersion: pkg.dependencies['react-native'],
  },
  'react-native-safe-area-context': {
    singleton: true,
    requiredVersion: pkg.dependencies['react-native-safe-area-context'],
  },
},

Arrenca els dos dev servers i corre el host (la rutina de tres terminals del post 2). El Pokédex es renderitza amb el títol situat sota la Dynamic Island, amb el padding de l’inset que el remote va llegir del provider del host. Una biblioteca, un provider, un objecte de context, compartits entre dues apps que es van construir i publicar per separat.

Ara trenca-ho

Esborra una entrada. Treu react-native-safe-area-context del bloc shared del remote i deixa’l al del host. Aquesta és la versió realista de l’error: l’autor del host el va compartir, l’autor del remote se’n va oblidar. Reinicia el dev server del remote i recarrega el host.

L’app no renderitza una pantalla una mica malament. Peta en arrencar:

Uncaught Error: Tried to register two views with the same name RNCSafeAreaProvider
L'app mostra un red box en arrencar amb l'error: Tried to register two views with the same name RNCSafeAreaProvider

I aquesta és la raó que sigui sorollós i no silenciós. react-native-safe-area-context no és JavaScript pur. Porta una vista nativa, RNCSafeAreaProvider, que es dona d’alta al registre de vistes de React Native en arrencar. La còpia del host la registra un cop. Quan el remote deixa de compartir-la, empaqueta la seva pròpia còpia, i aquesta còpia intenta registrar el mateix nom natiu una segona vegada. React Native manté un únic registre per app i rebutja el duplicat. El crash salta abans que un sol Pokémon arribi a la pantalla.

Aquest és el patró de les biblioteques el JavaScript de les quals registra alguna cosa a la capa nativa, com safe-area context registra la seva vista de provider: una biblioteca de navegació, un gesture handler, qualsevol cosa que cridi requireNativeComponent o guardi identitat nativa a l’àmbit del mòdul. Comparteix-la des d’un únic lloc i funciona. Deixa que dos bundles portin cadascun la seva i totes dues còpies registren la mateixa vista nativa, i xoquen aviat i de manera evident. No totes les biblioteques amb part nativa fallen així: un wrapper prim que només crida un mòdul natiu pot sobreviure a la duplicació, perquè allà no hi col·lideix cap identitat. Les que es trenquen registren vistes o mantenen estat, i l’error fins i tot anomena la vista, així que la solució assenyala el share que falta.

Torna a posar aquesta entrada al bloc shared del remote, i l’app compila i corre de nou.

El mateix React falla igual de sorollosament, per un motiu diferent. Treu react del shared d’un remote i el remote empaqueta el seu propi React. El primer hook que corre el remote es comprova contra la còpia equivocada, i t’emportes el conegut red box d’Invalid hook call. Mateixa lliçó: una biblioteca que guarda identitat a l’àmbit del mòdul no sobreviu a carregar-se dues vegades. Això és una propietat d’aquestes biblioteques, no una regla que el runtime imposi a tot arreu.

La fallada que sí que es queda callada

Un cas es guanya l’etiqueta de «callat», i és requiredVersion, encara que no com t’esperaries. Treu el camp i, la major part de les vegades, no canvia res: Module Federation recorre a la versió que consta per a aquesta dependència al package.json del paquet mateix que la consumeix, així que encara es comprova un rang. A l’error silenciós li falta un ingredient més: que el bundler no tingui cap versió a la qual recórrer. Això passa quan el paquet compartit no surt al package.json del consumidor, o quan l’estructura del paquet impedeix que el bundler en llegeixi una versió (una trampa on el post del paquet de contracte cau de ple). Sense rang per cap de les dues vies, Module Federation no té res a comprovar: carrega la còpia que guanyi i tira endavant. Aquest és el que cal vigilar, perquè compila i es publica sense problemes, i només apareix quan dos equips acaben en versions diferents d’una dependència. Fixa requiredVersion explícitament, com fan les configs de dalt, i la comprovació deixa de dependre del que el bundler hagi pogut deduir.

Així que la regla pràctica és la tranquil·litzadora. Gairebé totes les maneres de trencar el contracte compartit s’anuncien en arrencar. La callada és estreta, i un sol camp la tanca.

Què has construït, i què ve

El host té un únic SafeAreaProvider. El remote llegeix els seus insets travessant la frontera entre bundles, perquè les dues apps resolen a una sola còpia compartida de la biblioteca. Vas veure el contracte aguantar, després el vas veure petar quan un remote va oblidar la seva meitat, i ara saps que el crash és el resultat amable.

Acaba exactament a l’estat final d’aquest post. El recorregut imprimeix els fitxers que sostenen el build; els manifests, les configs i els canvis menors viuen al tag. Per acabar amb un arbre idèntic byte a byte al del tag, aboca-hi a sobre la còpia de referència. Un fitxer que has escrit bé se sobreescriu amb ell mateix, i l’abocada omple el que la prosa no ha imprès:

npx degit@3.8.0 --force warrendeleon/react-native-module-federation#post-03-shared-singleton /tmp/pokedex-ref-03
cp -R /tmp/pokedex-ref-03/. .

El següent a la sèrie: el host deixa de ser una sola pantalla i es converteix en un shell de debò, amb la tab bar al seu càrrec mentre cada pestanya és un remote carregat en runtime.

Fonts

Warren de Leon
Warren de Leon

Software Engineering Manager. Recentment he liderat l'equip de Mobile Platform a Hargreaves Lansdown. Escric sobre lideratge tècnic, React Native i com construir bons equips.

Veure perfil