El post 4 va acabar amb una promesa: les dues pestanyes voldrien obrir la mateixa pantalla de detall de Pokémon, i apps construïdes per separat haurien de posar-se d’acord en què passar-li.
Aquest post construeix això, i calen tres canvis que es necessiten entre si. Cada remote deixa d’exposar una pantalla solta i exposa un stack de navegació sencer, així que empènyer un detall és cosa de la pestanya que l’empeny. La mateixa pantalla de detall es publica com un paquet versionat que les dues pestanyes instal·len, no com una altra unitat de desplegament, per una raó que mereix explicar-se. I l’acord sobre el que creua cap a aquesta pantalla es converteix també en un paquet: un contracte amb número de versió, que és on viu la lliçó de debò d’aquest post. L’acte final trenca l’acord a propòsit, tres esglaons amunt en una escala de versions, i acaba en un forat que cap compilador del repo no pot veure.
Continua des del teu propi codi del post 4 si el vas anar construint. Si no:
git clone https://github.com/warrendeleon/react-native-module-federation
cd react-native-module-federation
git checkout post-04-host-shell
Cada pestanya guanya un stack
Al post 4 cada remote lliurava al host una pantalla solta i el host les ordenava en pestanyes. Una pantalla de detall canvia el que vol dir “dins d’una pestanya”: tocar una fila ha d’empènyer una pantalla nova mentre la barra de pestanyes es queda on és, i qui és amo d’aquesta empenta és amo de la navegació dins de la pestanya. Això és cosa del remote, no del shell. Així que cada remote exposa ara 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>
);
}
Aquest import PokemonDetailScreen from '@pokedex/detail' és el segon canvi del post, i hi tornarem; primer, el cablejat del voltant. La superfície pública del remote va canviar: exposa ./ListStack on abans exposava ./PokedexScreen, així que la seva config de rspack reanomena l’entrada a exposes, i els dos imports lazy del host passen a ser listApp/ListStack i partyApp/PartyStack. L’app party ho replica tot amb el seu propi PartyStack.
El mapa de compartits evoluciona també, en les dues direccions de la regla del post 4. Els remotes ara importen @react-navigation/native, @react-navigation/native-stack i react-native-screens, així que els tres s’uneixen als mapes de singletons compartits de cada costat. El mapa registra el que importa més d’una part, i tres paquets més acaben de creuar aquesta línia. Dues de les entrades noves declaren version a mà:
'@react-navigation/native': {
singleton: true,
version: navPkg.version,
requiredVersion: pkg.dependencies['@react-navigation/native'],
},
Rspack normalment llegeix la versió del paquet que està compartint, però no pot amb un paquet resolt a través d’un mapa exports, i React Navigation en té un. Sense el version explícit el bundler omet el provide en silenci: res no aterra al share scope, i l’app mor en arrencar amb RUNTIME-006 (eager) o empaqueta un duplicat sense dir res (sense eager). Els paquets de navegació el necessiten; react-native-screens no. Mirar si hi ha un camp exports al package.json d’un paquet et diu quin tractament li toca.
Posa’l en marxa i el canvi de producte es veu: toca una fila, el detall s’empeny dins de la pestanya, i la barra de pestanyes es queda en pantalla.
On viu la pantalla compartida?
Els dos stacks munten la mateixa pantalla de detall. En el producte d’aquesta sèrie és una pantalla; en una app real és el tipus de cosa que dos equips comparteixen de veritat. On va?
La resposta federada s’insinua sola: un tercer remote, un detailApp al seu propi port, declarat als mapes remotes dels dos consumidors. Funciona mecànicament (Module Federation resol remotes niats sense queixar-se) i manté la pantalla actualitzable sense tocar cap consumidor. També és el tall equivocat. Una frontera de federació és una unitat de desplegament, amb el seu servidor de desenvolupament, el seu pipeline de desplegament i els seus propis modes de fallada. Els equips tallen aquestes fronteres per dominis: un equip, un domini, un remote, diverses pantalles a dins. Una sola pantalla mai no es guanya aquest cost, i un codi que reparteix una frontera per pantalla va camí de desplegar una app per pantalla.
Una pantalla que comparteixen dos dominis és un component, i els components compartits porten dècades amb el seu mecanisme d’entrega: un paquet. Així que la pantalla de detall es publica com a @pokedex/detail, pujada a un registre i instal·lada per les dues apps de pestanya, i cadascuna la munta al seu propi stack on li sembla. Les pestanyes segueixen sent les unitats de desplegament. La pantalla és una dependència. Aquesta regla es defensa a fons d’aquí a dos posts: una frontera és el domini d’un equip, no una pantalla.
packages/detail/src/PokemonDetailScreen.tsx, les parts que importen:
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];
// renderitza el número de la dex, el nom, l'sprite i els xips de tipus
}
Hi ha dues coses deliberades aquí. La pantalla porta la seva pròpia còpia de les dades de Pokémon: l’app list en té una altra, i les dues ja s’han desviat, perquè la llista necessita noms i aquesta pantalla necessita a més els tipus. Dues còpies dels mateixos fets fan mala olor, i l’olor està plantada a propòsit: les dades en directe esborren totes dues al proper post. I les props es tipen estructuralment ({ route: { params: DetailParams } }) en lloc d’importar els tipus de React Navigation. La pantalla la munten dos stacks que no ha vist mai, així que declara la forma que necessita i queda lliure d’una dependència de navegació. El seu import de DetailParams és el paquet de contractes, que aquest post ha d’explicar ara.
L’acord del qual cap app no pot ser ama
Mira el que ha de quadrar. L’app list empeny PokemonDetail amb { id }. L’app party empenyerà la mateixa ruta quan tingui membres per tocar. La pantalla llegeix route.params.id. Tres codis toquen una forma, i cap no la pot definir per als altres: una definició a l’app list és invisible per al paquet de la pantalla, i copiar el tipus a mà a cada codi és deriva amb compte enrere.
El mateix problema existeix un nivell més amunt, on el host consumeix els remotes. Les declaracions ambient del host descriuen mòduls sense arxiu on apuntar:
declare module 'listApp/ListStack' {
import type { ListStackModule } from '@pokedex/contracts';
const ListStack: ListStackModule;
export default ListStack;
}
Una declaració ambient és una promesa escrita a mà. TypeScript se la creu perquè no hi ha res contra què comprovar-la: el mòdul que descriu es resol en runtime, des d’un servidor, molt després que el compilador acabi. Apuntar la declaració a un tipus d’un paquet instal·lat no fa la promesa comprovable; fa la promesa compartida, perquè el host i el remote almenys descriguin la superfície des d’una definició en lloc de dues suposicions. El que segueix sense poder atrapar, dit amb honestedat: si el remote reanomena el seu expose, la declaració queda obsoleta i el compilador segueix en verd. La comprovació d’aquesta frontera no existeix en build. Guarda aquesta frase; el final d’aquest post està construït sobre ella.
Així que els acords viuen a @pokedex/contracts, un paquet que té tipus i res més:
// packages/contracts/src/params.ts
export interface DetailParams {
id: number;
}
export type DetailParamList = {
PokemonDetail: DetailParams;
};
Cada stack incrusta el fragment a la seva pròpia llista de params (type ListParamList = DetailParamList & { PokedexList: undefined }), el paquet de la pantalla llegeix DetailParams, i el host tipa les seves declaracions de mòdul des del mateix lloc. Tot el del paquet s’esborra en build. Res seu no arriba a cap bundle; existeix perquè cada tsc del repo comprovi contra una sola definició.
Publica els dos a un registre
Un paquet necessita un registre, i les rutes file: no valen: una ruta és una ubicació en una màquina, i el sentit dels dos paquets és que apps construïdes per separat (en una organització real, repos amb amos diferents) instal·lin el mateix artefacte per nom i versió. Verdaccio és un registre npm en un sol procés, suficient perquè tot el flux sigui real:
npx verdaccio # :4873, es queda amunt
npm adduser --registry http://localhost:4873 # qualsevol usuari, contrasenya i correu
Un .npmrc d’una línia a l’arrel del repo apunta l’scope cap a ell (@pokedex:registry=http://localhost:4873/) i els dos paquets es publiquen:
( 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 paquet de detall declara el que necessita com a peers en lloc d’empaquetar-ho:
"peerDependencies": {
"@pokedex/contracts": ">=1",
"react": "*",
"react-native": "*",
"react-native-safe-area-context": ">=5"
}
Un rang peer és una afirmació de compatibilitat, i >=1 és laxa: diu que aquesta pantalla funciona amb qualsevol contracte des d’1.0.0 en endavant. Els rangs laxos donen als consumidors llibertat per actualitzar al seu ritme. El que costen és precisió, i el final d’aquest post ensenya el preu.
Les tres apps instal·len els dos paquets. Una instal·lació real, per versió, des del registre:
npm install @pokedex/contracts@^1.0.0 @pokedex/detail@^1.0.0
Cada app desempaqueta la seva pròpia còpia a node_modules, amb la versió que va demanar. Quatre còpies del contracte pel codi sona una altra vegada al problema de la deriva, fins que notes la diferència: aquestes còpies porten número de versió, i els números de versió són del que va la resta del post.
Ara trenca-ho: l’escala de versions
El muntatge funciona. Les dues pestanyes munten la pantalla instal·lada, les empentes comproven tipus contra el contracte instal·lat, i el simulador es porta bé. La pregunta interessant és què passa quan els paquets es mouen i les apps no, perquè en una organització real no ho faran, no alhora. Tres esglaons.
1.1.0, una addició
L’app party acabarà passant un id d’instància de buit quan un Pokémon tocat vingui d’un buit de l’equip. Camp opcional, canvi additiu:
export interface DetailParams {
id: number;
uid?: string;
}
Puja a 1.1.0, compila, publica. Després, a l’app list, la part que gairebé totes les explicacions se salten:
npm install
up to date, audited 1188 packages in 926ms
No passa res. El lockfile fixa 1.0.0, i npm install respecta el lockfile. El caret del package.json diu el que aquesta app acceptaria; el lockfile decideix el que rep. Prendre el minor és un acte deliberat:
npm update @pokedex/contracts
changed 1 package, and audited 1188 packages in 868ms
Ara npm ls @pokedex/contracts ensenya la nova resolució, inclosa la pantalla, que l’accepta a través del seu peer lax:
+-- @pokedex/contracts@1.1.0
`-- @pokedex/detail@1.0.0
`-- @pokedex/contracts@1.1.0 deduped
tsc passa sense canviar una línia de codi enlloc: un consumidor construït contra 1.0.0 mai no passa uid, i segueix satisfent el tipus. Això és el que fa segur desplegar un canvi additiu de forma desigual.
2.0.0, un camp que hi ha de ser
El següent canvi és dels que trenquen. La pantalla de detall vol ensenyar des d’on es va obrir, així que el param passa a ser obligatori, i la pantalla aprèn a renderitzar-lo:
export interface DetailParams {
id: number;
uid?: string;
source: 'pokedex' | 'party';
}
Un camp obligatori invalida la forma antiga, així que el contracte puja a 2.0.0. El paquet de la pantalla el segueix fins allà: @pokedex/detail@2.0.0, construït contra el contracte nou, renderitzant una petita línia d’origen des de route.params.source. De tornada a l’app list, amb els dos al registre:
npm install
up to date, audited 1188 packages in 886ms
Es queda a 1.1.0. El caret rebutja el major sense que li ho demanin, que és l’única peça de tot aquest muntatge que funciona per defecte.
El punt cec
Tres equips publiquen a tres velocitats. L’equip de party adopta els dos 2.0.0 i compila net: encara no empeny res, així que el nou requisit no li costa res. L’equip de list té una release fora i es queda al contracte 1.1.0. Però el paquet de la pantalla és una dependència més, i les dependències s’actualitzen: algú de l’equip de list pren la pantalla nova sense prendre el contracte nou.
npm install @pokedex/detail@^2.0.0
changed 1 package, and audited 1188 packages in 1s
Sense avisos. El rang peer de la pantalla diu >=1, l’app list té 1.1.0, i 1.1.0 >= 1. npm queda satisfet:
+-- @pokedex/contracts@1.1.0
`-- @pokedex/detail@2.0.0
`-- @pokedex/contracts@1.1.0 deduped
Aquesta és la part on val la pena frenar. L’app list empaqueta ara una pantalla el codi de runtime de la qual llegeix route.params.source. Pregunta què va comprovar el seu compilador, i la resposta és: les declaracions de tipus publicades de la pantalla diuen que les props prenen DetailParams, importat de @pokedex/contracts i resolt a la còpia que el consumidor va instal·lar. L’app list el va resoldre a 1.1.0, on source no existeix. Així que l’empenta de { id } de l’app list comprova tipus perfectament contra la mateixa pantalla que està a punt de llegir un camp que mai no li van enviar. Cada tsc del codi està en verd, i cadascun està comprovant una frase diferent de la que el runtime pronunciarà.
En runtime, la pestanya list empeny en Bulbasaur:
“Opened from the ”, i després res. Sense crash, sense red box, sense un avís a cap terminal. Una frase amb un forat, enviada a un usuari.
El contracte no va fallar. Va fer el que un contracte de temps de compilació pot fer, que és subjectar cada app a la versió que aquesta app va instal·lar. El peer lax tampoc no va fallar; va fer exactament el que diu >=1. El forat és entre els dos: un tipus és una promesa de temps de compilació, i ja no existeix quan un valor creua de veritat. Comprovar el valor que arriba és una altra feina, d’altres eines, en un altre moment.
El que has construït, i el que ve
La navegació va baixar als remotes. La pantalla compartida es publica com a paquet versionat, perquè una frontera de federació és el domini d’un equip i una pantalla és un component. Els acords (params i formes de mòdul) viuen en un paquet de contractes que cada costat instal·la, i semver governa com es desvien els tres codis: els minors es despleguen de forma desigual i sense dany, els majors esperen consentiment, i l’única fallada que es va colar ho va fer en silenci, al buit entre un rang peer lax i un tipus esborrat.
El codi acabat d’aquest post és el tag post-05-contracts:
git checkout post-05-contracts
El proper de la sèrie: dades reals. Les llistes de Pokémon hardcodejades desapareixen, totes dues còpies, i cada pantalla llegeix d’un store alimentat per PokéAPI. Que és on el forat d’aquesta frase rep una resposta de veritat, perquè un tipus comprovat en build no serveix de res davant d’un valor que apareix en runtime.
Fonts
- Module Federation 2.0 — el runtime de mòduls compartits, i la referència de RUNTIME-006
- Verdaccio — el registre npm local on es publiquen els dos paquets
- React Navigation — l’stack natiu que cada remote posseeix ara
- Rangs semver d’npm — el que un caret accepta i no accepta, i el que afirma un rang peer
- react-native-module-federation — el repo d’acompanyament, al tag
post-05-contracts