Un remote federat arriba a una pantalla nativa a través de l'escotilla del host i en rep el resultat

shell.navigateTo: pantalles natives des d'un remote federat a React Native

El post 12 va acabar amb una oferta del host als remotes: els presta el seu costat natiu, a través d’un pont que obre una pantalla nativa des de la pestanya Party i n’espera el resultat. Aquest post construeix aquest pont. Un botó d’una pestanya federada obre una pantalla totalment nativa, SwiftUI en una plataforma i Jetpack Compose a l’altra, i el guanyador de la batalla torna com el valor amb què es resol una promesa.

La restricció de fons acompanya la sèrie des del post 1: el lliurament en runtime mou JavaScript i res més. El post 11 la va afrontar al design system, i la llista de limitacions de Re.Pack deixa escrita la part del tracte que toca al host: «Host application must have native modules used in other containers». Un remote que vol una pantalla nativa no la pot portar. El host presta la seva.

En què consisteix el préstec: un remote veu exactament una funció, shellNavigate(destination, params?) del contracte, que retorna una promesa. El shell.navigateTo del títol és aquesta funció, amb el nom que exporta el contracte. Al darrere hi ha la taula de rutes del host i el primer TurboModule del blog (el sistema de mòduls natius tipats de React Native), amb el seu primer codi Swift i Kotlin més enllà de la plantilla. El viatge d’anada i tornada:

Una diferència pràctica abans de la primera ordre. Fins ara el clon era una comoditat: tot es teclejava, i un arbre construït a mà des del post 2 servia igual de bé. Aquest és el primer post en què el repo d’acompanyament és imprescindible: les pantalles natives surten del seu tag, i en el moment en què un TurboModule es registra, el teu projecte natiu ha de coincidir amb el del tag. Comença des de l’estat final del post 12, el tag d’inici:

git clone https://github.com/warrendeleon/react-native-module-federation
cd react-native-module-federation
git checkout post-12-a11y-testing

L’estat final és el tag de tancament, post-13-native-handoff; més endavant, durant la build, en descarregaràs uns quants fitxers, i tota la resta la tecleges tu.

Una funció al contracte

El contracte suma un fitxer, que conté tot el que un remote arriba a veure del costat natiu. Crea packages/contracts/src/shellNavigation.ts:

import type { PartyMember } from './party';

// --- La superfície de rutes del shell, i tot el que un remote sap del que és natiu. Un remote
// importa `shellNavigate` i res més: sense TurboModule, sense tipus natius, sense cap idea de si
// el destí que anomena és una altra micro-app o una pantalla totalment nativa. El host és l'amo
// de la taula que ho decideix, així que migrar més endavant un flux natiu a React Native és
// editar una fila aquí i no tocar res a cap remote. ---

export type RouteEntry =
  /** Traspassa a natiu: el host crida openNative, es presenta una pantalla nativa i la promesa
   *  es resol amb el que aquella pantalla hagi retornat. */
  { type: 'native'; nativeId: string };

// Una sola entrada, perquè només una cosa despatxa. La unió de dalt està escrita com a unió d'un
// sol membre perquè una variant de micro-app s'hi pugui afegir sense que els lectors
// d'`entry.type` canviïn de forma; una fila del registre sense res al darrere és una constant
// morta, i aquesta sèrie ja n'ha pagat una.
export const ROUTE_REGISTRY: Record<string, RouteEntry> = {
  QuickBattle: { type: 'native', nativeId: 'quickBattle' },
};

// --- El que creua la frontera, en totes dues direccions. Aquests tipus són al contracte perquè
// el HOST els ha de serialitzar, no perquè cap altra app despatxi res: el resultat de la batalla
// és cosa de la party i el gestiona el reducer de la party. ---

/** RN -> natiu. La party lliura els seus membres; la pantalla nativa els mostra i els enfronta.
 *
 *  L'esquema de color hi viatja perquè no hi ha alternativa. Cada superfície federada llegeix
 *  l'únic runtime d'estils que munta el host, i una pantalla nativa és l'únic consumidor que no
 *  s'hi pot subscriure: no hi ha bundle per compartir ni provider d'on penjar-se. Així que en
 *  aquesta frontera el tema deixa de ser ambiental i passa a ser un argument, enviat en el
 *  moment de la crida. */
export interface QuickBattleParams extends Record<string, unknown> {
  members: PartyMember[];
  colourScheme: 'light' | 'dark';
}

/** Natiu -> RN. L'uid, no l'id: dues còpies del mateix Pokémon són dos contendents, i per
 *  aquesta raó exactament la party posa un uid a cada membre des de la 3.1.0. Absent quan la
 *  pantalla s'ha tancat sense batalla. */
export interface QuickBattleResult {
  winnerUid?: string;
}

/** Natiu resol amb un objecte, o amb res quan el flux ha acabat sense resultat. */
export type ShellNavigateResult = Record<string, unknown> | undefined;

export type ShellNavigateFn = (
  destination: string,
  params?: Record<string, unknown>,
) => Promise<ShellNavigateResult>;

// --- L'slot que el host omple i que tots els remotes llegeixen. És una clau de globalThis i no
// un context de React perquè aquest paquet s'empaqueta dins del host I dins de cada remote: un
// context creat aquí seria un objecte diferent a cada bundle, així que el useContext d'un remote
// no trobaria mai el provider del host. La identitat de mòdul que els posts 3 i 6 van convertir
// en singleton és exactament el que falta en aquesta frontera, i globalThis és l'únic slot que
// els tres bundles comparteixen de debò. ---
const GLOBAL_KEY = '__POKEDEX_SHELL_NAVIGATE__';

export function registerShellNavigateHandler(fn: ShellNavigateFn): void {
  (globalThis as Record<string, unknown>)[GLOBAL_KEY] = fn;
}

export function shellNavigate(
  destination: string,
  params?: Record<string, unknown>,
): Promise<ShellNavigateResult> {
  const fn = (globalThis as Record<string, unknown>)[GLOBAL_KEY] as ShellNavigateFn | undefined;
  if (typeof fn !== 'function') {
    // Sense handler: avisa i resol. Un remote que ho espera amb await rep undefined i renderitza
    // una cosa honesta, la mateixa tolerància sobre la qual està construïda la forma de lectura
    // de l'slice de la party.
    console.warn(`[shellNavigate] no handler registered (called with ${destination})`);
    return Promise.resolve(undefined);
  }
  return fn(destination, params);
}

Els comentaris documenten les tres decisions: una sola entrada al registre, perquè només una cosa despatxa; el tema com a argument, perquè una pantalla nativa no es pot subscriure al runtime d’estils; i un slot de globalThis en lloc d’un context de React, que en empaquetar-se a cada app seria un objecte diferent a cadascuna. L’uid que el prepare del post 8 afegeix a cada membre hi troba el seu primer consumidor a l’altra banda de la frontera: és el valor que surt de l’app i torna.

Convé anomenar abans de res el native stack de React Navigation: per a pantalles de React Native amb suport natiu dins d’un navigator és l’eina correcta. El que no modela és una pantalla que pertany al host i és fora de l’arbre de React, que pren l’entrada d’un remote i li retorna un valor. Aquest viatge d’anada i tornada és la raó de ser del pont.

Exporta’l, puja la versió i publica pel mateix flux de Verdaccio que va establir el post 5. A packages/contracts/src/index.ts:

export * from './party';
export * from './shellNavigation';

A packages/contracts/package.json, "version": "3.3.0": una versió minor additiva sobre la 3.2.2 del post 12. Amb ella arriben dos ajustos d’eines: el fitxer nou crida console.warn, així que el tsconfig.json de contracts afegeix "DOM" al seu array lib com ja fan ui i a11y-testing, i l’script build incorpora una neteja de dist perquè cap fitxer de declaracions obsolet no sobrevisqui fins a un publish:

"build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc",

Publica, i després instal·la’l a les tres apps:

( cd packages/contracts && npm install && npm run build && npm publish )
for app in host list party; do ( cd apps/$app && npm install @pokedex/contracts@3.3.0 ); done

La spec i el codegen

Un TurboModule comença com una spec de TypeScript, que la generació de codi de React Native llegeix en build i fa servir per escriure les classes base que les implementacions natives estenen. Crea apps/host/specs/NativeShellNavigationModule.ts:

import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';

// --- La spec del TurboModule: l'únic lloc on el JavaScript del host i el seu codi natiu acorden
// una signatura. Codegen llegeix aquest fitxer en build i escriu les classes base de C++/ObjC++
// i Kotlin que les implementacions natives estenen, i per això el fitxer és fora de src/ i el
// seu nom ha de començar per `Native`.
//
// Un mètode, i asíncron per la seva pròpia forma: openNative lliura a un flux natiu la seva
// entrada i es resol amb el que aquell flux hagi retornat. La promesa és tot el disseny — l'await
// de la party no retorna fins que la pantalla nativa ha acabat, i cada camí de sortida d'aquella
// pantalla l'ha de resoldre exactament una vegada.
//
// La frontera són strings JSON en totes dues direccions, en lloc de tipus d'objecte de codegen.
// Codegen pot portar objectes estructurats, i un pont més gran l'hi hauria de deixar; un string
// manté la serialització visible en un sol punt de cada banda, més fàcil de seguir mentre el
// pont fa un mètode d'ample. ---

export interface Spec extends TurboModule {
  openNative(nativeId: string, paramsJson: string): Promise<string>;
}

// getEnforcing llança quan cap mòdul natiu no respon a aquell nom: un binari construït sense la
// meitat nativa, o un desajust de codegen. Els mateixos exemples de React Native importen una
// spec així a nivell de mòdul, i amb el mòdul dins del binari això és correcte. El handler del
// host requereix aquest fitxer de manera mandrosa, com a decisió sobre on es permet aparèixer a
// aquell throw. Importat a l'arrencada, si falta la meitat nativa l'app mor durant l'avaluació
// del bundle, abans que existeixi cap error boundary; requerit a la crida, la mateixa fallada
// apareix en prémer el botó, en una app en marxa que la pot registrar i continuar. El lliurament
// en runtime és el que fa real aquest desfasament: el JavaScript pot arribar a un binari que es
// va construir sense el costat natiu.
export default TurboModuleRegistry.getEnforcing<Spec>('ShellNavigationModule');

La frontera són strings JSON en totes dues direccions; el comentari inicial de la spec acaba explicant el perquè. Digues a codegen on és la spec, a apps/host/package.json:

"codegenConfig": {
  "name": "HostSpecs",
  "type": "modules",
  "jsSrcsDir": "specs",
  "android": {
    "javaPackageName": "com.host.specs"
  }
}

Codegen s’executa durant pod install, així que executa’l ara:

( cd apps/host/ios && bundle exec pod install )

El handler del host és el lector de la taula de rutes. Crea apps/host/src/shell/shellNavigation.ts:

import { ROUTE_REGISTRY, type ShellNavigateFn, type ShellNavigateResult } from '@pokedex/contracts';

import type { Spec as ShellNavigationSpec } from '../../specs/NativeShellNavigationModule';

// --- La meitat del host de shell.navigateTo. Un remote crida el shellNavigate del contracte;
// això és el que s'executa. Busca el destí a la taula de rutes i lliura els destins natius al
// TurboModule, JSON d'entrada i JSON de sortida. El remote no sap mai quina branca ha pres. ---

// El mòdul de la spec crida TurboModuleRegistry.getEnforcing en importar-se, i això llança quan
// cap mòdul natiu no respon a aquell nom. Requerir-lo aquí, dins de la crida, decideix on pot
// aparèixer aquell throw: en el toc, en un shell en marxa que el registra i continua, en lloc de
// durant l'avaluació de l'arrencada, on la falta de la meitat nativa mataria l'app sense cap
// error boundary.
function nativeModule(): ShellNavigationSpec {
  return require('../../specs/NativeShellNavigationModule').default as ShellNavigationSpec;
}

export const shellNavigateHandler: ShellNavigateFn = async (destination, params) => {
  const entry = ROUTE_REGISTRY[destination];
  if (!entry) {
    // Un destí desconegut és un bug de qui crida, no un crash: avisa i resol, perquè un remote
    // construït contra un contracte més nou que el host es degradi a no passar res.
    console.warn(`[shellNavigate] unknown destination: ${destination}`);
    return undefined;
  }

  const resultJson = await nativeModule().openNative(entry.nativeId, JSON.stringify(params ?? {}));
  if (!resultJson) {
    return undefined;
  }
  try {
    return JSON.parse(resultJson) as ShellNavigateResult;
  } catch {
    // Un resultat natiu que no parseja és un bug del costat natiu, i no una raó per fer esclatar
    // l'await del remote: avisa i resol, la mateixa tolerància que s'aplica a tots els altres
    // punts d'aquesta frontera.
    console.warn(`[shellNavigate] unparseable native result for ${destination}`);
    return undefined;
  }
};

El require() mandrós tria on falla l'app si falta la meitat nativa: en el toc, no a l'arrencada. getEnforcing llança quan cap mòdul no respon a aquell nom. Els mateixos exemples de React Native importen la spec a nivell de mòdul, i això funciona mentre el mòdul és al binari; el lliurament en runtime fa real el desfasament. A l'arrencada, la fallada és una pantalla en blanc sense error boundary; a la crida, una fallada en un sol botó que l'app sí que pot registrar al log.

El registre són dues línies a apps/host/App.tsx:

import { partyStateReady, registerShellNavigateHandler } from '@pokedex/contracts';
// ...
import { store } from './src/store';
import { shellNavigateHandler } from './src/shell/shellNavigation';

// El host omple l'slot de navegació del contracte una vegada, a nivell de mòdul, abans que cap
// remote no pugui renderitzar i cridar shellNavigate. Registrar el handler no toca codi natiu —
// el TurboModule no s'arriba a tocar fins que de debò es navega a un destí — així que això és
// segur en importar, d'una manera que requerir la spec aquí no ho seria.

registerShellNavigateHandler(shellNavigateHandler);

Escriu el pont, descarrega les pantalles

Des d’aquí la feina es divideix segons el que ensenya cada meitat. El pont és federació, i el tecleges sencer: el mòdul que manté una promesa oberta a través de la frontera, el seu registre i la gestió de fils. Les pantalles són codi d’UI en SwiftUI i Compose, així que arriben des del tag de tancament d’aquest mateix post. Al voltant de mil línies queden fora del teu teclat, cap sense explicar.

npx degit@3.8.0 --force warrendeleon/react-native-module-federation#post-13-native-handoff /tmp/pokedex-ref-13
cp /tmp/pokedex-ref-13/apps/host/ios/Host/QuickBattle.swift apps/host/ios/Host/QuickBattle.swift
cp /tmp/pokedex-ref-13/apps/host/android/app/src/main/java/com/host/QuickBattleActivity.kt apps/host/android/app/src/main/java/com/host/QuickBattleActivity.kt
cp /tmp/pokedex-ref-13/apps/host/__tests__/shellNavigation.test.ts apps/host/__tests__/shellNavigation.test.ts
cp /tmp/pokedex-ref-13/apps/party/__tests__/quickBattle.test.tsx apps/party/__tests__/quickBattle.test.tsx
mkdir -p apps/host/android/app/src/test/java/com/host apps/host/ios/HostTests
cp /tmp/pokedex-ref-13/apps/host/android/app/src/test/java/com/host/ShellNavigationModuleTest.kt apps/host/android/app/src/test/java/com/host/ShellNavigationModuleTest.kt
cp /tmp/pokedex-ref-13/apps/host/ios/HostTests/QuickBattlePresenterTests.swift apps/host/ios/HostTests/QuickBattlePresenterTests.swift

Les dues pantalles segueixen el design system. Cap bundle no creua aquesta frontera, així que cada fitxer replica a mà colours.ts i els divuit colors de tipus, reconstruïts amb el mateix aspecte de targeta que renderitza la graella de la Pokédex: el disc tenyit de l’sprite, el número amb coixinet sobre la seva etiqueta arrodonida, els badges de tipus i la franja de color d’accent a la base. Cada pantalla tradueix el colourScheme que arriba als params als mateixos parells de colors que fan servir les regles dark:; els botons de Compose declaren Role.Button, perquè un Text amb estils s’anuncia a TalkBack com a text pla; i cada pantalla mostra un badge morat de NATIVE. Cap pantalla federada no el té, així que amb una captura n’hi ha prou per saber a quin costat de la frontera és cadascuna.

Amb les pantalles arriben quatre suites. Les dues de JavaScript proven la part JavaScript del pont: la del host, l’slot del contracte i el handler, resultats no parsejables inclosos; la de la party, el bloqueig del botó i que el guanyador arriba a través de l’acció privada. Les dues natives proven les garanties de la promesa que construeixen les dues seccions següents, i es configuren després de la taula de sortides.

iOS: cada sortida resol

El mòdul d’iOS és Objective-C++, i l’extensió és la clau: getTurboModule retorna el mòdul JSI generat (JavaScript Interface, la capa C++ sota la nova arquitectura) com un std::shared_ptr, que Swift no pot expressar. La pantalla continua sent SwiftUI pur; el .mm només fa d’adaptador. A Xcode, crea ShellNavigationModule.h i ShellNavigationModule.mm al grup Host, cosa que els posa al target, i afegeix el QuickBattle.swift descarregat amb File → Add Files to “Host”.

#import <Foundation/Foundation.h>
#import <React/RCTBridgeModule.h>
#import <HostSpecs/HostSpecs.h>

// --- La meitat nativa del host de shell.navigateTo a iOS. Codegen va llegir specs/
// NativeShellNavigationModule.ts i va escriure NativeShellNavigationModuleSpecBase i el protocol
// NativeShellNavigationModuleSpec a HostSpecs; aquesta classe hereta del primer i s'ajusta al
// segon, i per això l'`openNative` de JavaScript acaba executant-se aquí.
//
// La capçalera existeix perquè la implementació és ObjC++ (.mm) i no Swift: retornar el mòdul
// JSI de C++ generat des de getTurboModule necessita C++, i Swift no pot. La pantalla que
// presenta és SwiftUI pur — vegeu QuickBattle.swift. ---
@interface ShellNavigationModule : NativeShellNavigationModuleSpecBase <NativeShellNavigationModuleSpec>
@end
#import "ShellNavigationModule.h"
#import <HostSpecs/HostSpecs.h>
// Host-Swift.h declara totes les classes Swift @objc d'aquesta app, i així és com aquest fitxer
// ObjC++ arriba a QuickBattlePresenter. S'importa després de la capçalera de l'app delegate
// perquè les referències avançades de la capçalera generada resolguin en aquesta unitat de
// traducció.
#import <React-RCTAppDelegate/RCTDefaultReactNativeFactoryDelegate.h>
#import "Host-Swift.h"

// --- La implementació del TurboModule. Aquí hi ha dues coses i res més: el registre que posa el
// mòdul al registry sota el nom que va demanar la spec, i openNative.
//
// openNative és on comença la promesa. Lliura el bloc resolve al presenter i retorna de seguida;
// el bloc es crida més tard, des de la pantalla nativa, quan aquella pantalla acabi. Fins
// llavors l'`await` de la party està genuïnament suspès. Això és tot el traspàs, i també tot el
// risc: si algun recorregut per la pantalla nativa no crida mai el bloc, la promesa queda
// pendent per a tota la vida del procés. ---
@implementation ShellNavigationModule

RCT_EXPORT_MODULE()

- (void)openNative:(NSString *)nativeId
        paramsJson:(NSString *)paramsJson
           resolve:(RCTPromiseResolveBlock)resolve
            reject:(RCTPromiseRejectBlock)reject
{
  [QuickBattlePresenter presentWithNativeId:nativeId
                                 paramsJson:paramsJson
                                 completion:^(NSString *_Nonnull resultJson) {
                                   resolve(resultJson);
                                 }];
}

// El mòdul C++ de codegen. Aquest mètode és la raó que el fitxer sigui ObjC++ i no Swift:
// retorna un std::shared_ptr, que Swift no té manera d'expressar.
- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
    (const facebook::react::ObjCTurboModule::InitParams &)params
{
  return std::make_shared<facebook::react::NativeShellNavigationModuleSpecJSI>(params);
}

@end

El que passa amb aquell bloc resolve és el nucli del post. Cada sortida de la pantalla nativa resol la promesa, exactament una vegada, i totes passen per un wrapper que només accepta la primera resolució. La crida arriba fora del fil principal, així que abans de tocar UIKit se salta a main. El controller que presenta ve de hostProvider, una variable estàtica que per defecte busca a la key window; només el bundle de tests la reassigna. Una guarda de reentrada resol a l’instant una segona presentació; la sheet té blocat el tancament amb gest; i dues xarxes de seguretat cobreixen les sortides que no passen per cap botó. La primera cobreix el cas d’una sheet que desapareix de la pantalla sense passar per finish(). La segona cobreix una presentació que UIKit rebutja en silenci, i no dona res per fet: no està documentat si el completion d’un present() rebutjat arriba a executar-se. Un torn més tard a la cua principal, un controller rebutjat continua sense presentingViewController; si no en té, ningú no tancarà mai aquella sheet, així que el wrapper resol:

// Totes les rutes de sota acaben aquí, i això resol com a molt una vegada. Les sortides
// pròpies de la batalla competeixen amb les dues xarxes de seguretat de més avall, i guanya la
// primera que arribi; la resta es converteixen en no-ops en lloc de resolucions dobles.
var settled = false
let settle: (String) -> Void = { resultJson in
  guard !settled else { return }
  settled = true
  isPresenting = false
  completion(resultJson)
}

// openNative arriba a la cua pròpia del TurboModule, no al fil principal. Tota crida a UIKit
// d'aquí avall ha d'anar a main, així que salta abans de tocar res.
DispatchQueue.main.async {
  guard let host = Self.hostProvider(), !isPresenting else {
    // Res des d'on presentar, o un flux ja en marxa: resol en lloc de quedar-te encallat.
    completion("{}")
    return
  }
  isPresenting = true

  // El resultat es resol ABANS que comenci el tancament, no en el seu completion.
  // viewDidDisappear es dispara mentre la sheet encara s'anima cap enfora, així que una
  // resolució programada després de la transició perdria la cursa contra la primera xarxa i el
  // guanyador arribaria com a "{}".
  let view = QuickBattleView(contestants: contestants, isDark: isDark) { resultJson in
    settle(resultJson)
    host.dismiss(animated: true)
  }

  let controller = QuickBattleHostingController(rootView: view)
  controller.modalPresentationStyle = .pageSheet
  // Sortir només pels controls propis de la pantalla. Un tancament interactiu amb swipe
  // trauria la sheet sense passar per la resolució de dalt, deixant la promesa pendent.
  controller.isModalInPresentation = true
  // Primera xarxa de seguretat: la sheet desapareix de la pantalla per qualsevol ruta que no
  // passi pels botons — n'hi ha prou que un ancestre es desmunti. Millor una resolució sense
  // resultat que una promesa pendent.
  controller.onDisappear = { settle("{}") }
  // Segona xarxa de seguretat: present() no fa res, en silenci, quan el host és al mig d'una
  // transició per raons que aquest fitxer no pot veure. No està documentat si UIKit executa el
  // completion d'un present rebutjat, així que res d'aquí no en depèn: un torn més tard a la
  // cua principal, un controller presentat té presentingViewController i un de rebutjat continua
  // sense, i nil vol dir que ningú no tancarà mai aquesta sheet, així que resol ara.
  host.present(controller, animated: true)
  DispatchQueue.main.async {
    if controller.presentingViewController == nil {
      settle("{}")
    }
  }
}

Hi ha un ordre que importa: el resultat es resol abans que comenci el tancament, perquè viewDidDisappear es dispara al mig de l’animació. A la vista, les dues sortides, Close i Done, passen per un únic finish(), i una sortida sense batalla és un resultat i no un error:

private func finish() {
  guard let winnerId else {
    onDone("{}")
    return
  }
  let json = (try? JSONSerialization.data(withJSONObject: ["winnerUid": winnerId]))
    .flatMap { String(data: $0, encoding: .utf8) } ?? "{}"
  onDone(json)
}

Una promesa que no es resol mai no té xarxa de seguretat. Sense timeout al pont, sense rescat del recol·lector de brossa, sense error per observar: l'await senzillament no retorna mai, i el bloc resolve i tot el que captura queden retinguts per a tota la vida del procés. La fallada contrària costa com a molt una línia de log, perquè el pont ignora una segona resolució. Resoldre a cada sortida és obligatori; resoldre dues vegades es tolera.

Android: cada sortida és un resultat

Android obté la major part de la garantia de l’estructura: la pantalla és una Activity llançada per a un resultat, la plataforma retorna cada sortida d’una pantalla que va arribar a obrir-se, i el listener del mòdul és l’únic lloc on la promesa es resol quan arriba un resultat. El que l’estructura no cobreix és el llançament mateix (un start que acaba en excepció, o un desmuntatge que competeix amb el salt al fil d’UI), així que el mòdul cobreix els dos casos a mà. Crea apps/host/android/app/src/main/java/com/host/ShellNavigationModule.kt:

package com.host

import android.app.Activity
import android.content.Intent
import com.facebook.react.bridge.BaseActivityEventListener
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.host.specs.NativeShellNavigationModuleSpec

// --- La meitat nativa del host de shell.navigateTo a Android, i el mirall del
// ShellNavigationModule d'iOS. Codegen va llegir el mateix fitxer de spec i va escriure
// NativeShellNavigationModuleSpec a com.host.specs; aquesta classe l'estén, i així
// l'`openNative` de JavaScript acaba executant-se aquí.
//
// L'estructura d'Android resol per si sola gairebé tota la disciplina de la promesa. La pantalla
// és una Activity llançada per a un resultat, així que la plataforma lliura un resultat per cada
// sortida d'una pantalla que va arribar a obrir-se — el botó Done, el gest d'enrere del sistema —
// i onActivityResult és l'únic lloc on la promesa es resol quan arriba un resultat. El que
// l'estructura no cobreix és el llançament mateix: un start que acaba en excepció, o un
// desmuntatge que competeix amb el salt al fil d'UI. Aquests dos casos es cobreixen a mà a
// sota. ---
class ShellNavigationModule(reactContext: ReactApplicationContext) :
  NativeShellNavigationModuleSpec(reactContext) {

  // Retinguda des que es llança l'Activity fins que torna. D'una en una, la contrapart del flag
  // isPresenting del presenter d'iOS. Els dos camps es toquen des del fil de mòduls natius
  // (openNative, invalidate) i des del fil d'UI (el bloc de llançament, el listener del
  // resultat), així que cada mutació va dins de synchronized(this).
  private var pendingPromise: Promise? = null

  // S'activa una vegada a invalidate i mai no torna enrere: un llançament encuat abans del
  // desmuntatge no ha d'obrir una pantalla després, perquè ningú no escolta i el seu resultat
  // podria arribar a una batalla posterior.
  private var invalidated = false

  private val activityEventListener =
    object : BaseActivityEventListener() {
      override fun onActivityResult(
        activity: Activity,
        requestCode: Int,
        resultCode: Int,
        data: Intent?,
      ) {
        if (requestCode != QUICK_BATTLE_REQUEST) return
        // Cada sortida passa per aquí. Una batalla que ha acabat porta el seu JSON; un gest
        // d'enrere no porta cap dada, i un objecte buit n'és la resposta honesta — la party
        // llegeix un winnerUid absent com a "no ha passat res".
        val result = data?.getStringExtra(QuickBattleActivity.EXTRA_RESULT_JSON) ?: "{}"
        synchronized(this@ShellNavigationModule) {
          pendingPromise?.resolve(result)
          pendingPromise = null
        }
      }
    }

  init {
    reactApplicationContext.addActivityEventListener(activityEventListener)
  }

  override fun openNative(nativeId: String, paramsJson: String, promise: Promise) {
    val activity = reactApplicationContext.currentActivity
    if (activity == null) {
      // Res des d'on llançar: resol ja, en lloc de deixar qui crida esperant un resultat que no
      // se li lliurarà mai.
      promise.resolve("{}")
      return
    }
    synchronized(this) {
      if (pendingPromise != null || invalidated) {
        // Una batalla ja en marxa, o el mòdul ja desmuntat: la mateixa resposta.
        promise.resolve("{}")
        return
      }
      pendingPromise = promise
    }
    // nativeId queda sense llegir: avui existeix un sol flux natiu, i una segona fila del
    // registre necessitaria abans un switch aquí. openNative arriba en una cua de fons; salta al
    // fil d'UI per llançar l'Activity en lloc de donar per fet que aquella cua és segura per
    // llançar-la.
    activity.runOnUiThread {
      synchronized(this) {
        // invalidate pot executar-se entre l'encuament de dalt i aquest bloc: llavors la promesa
        // ja està resolta, i llançar igualment obriria una pantalla que ningú no escolta.
        if (invalidated || pendingPromise == null) return@runOnUiThread
        try {
          val intent =
            Intent(activity, QuickBattleActivity::class.java).apply {
              putExtra(QuickBattleActivity.EXTRA_PARAMS_JSON, paramsJson)
            }
          activity.startActivityForResult(intent, QUICK_BATTLE_REQUEST)
        } catch (e: Exception) {
          // Un llançament que acaba en excepció no deixa cap Activity que lliuri un resultat.
          // Resol ja, o l'await quedarà esperant una pantalla que mai no es va obrir.
          pendingPromise?.resolve("{}")
          pendingPromise = null
        }
      }
    }
  }

  override fun invalidate() {
    // Una recàrrega en desenvolupament o un desmuntatge del host amb una batalla oberta deixaria
    // l'await de qui crida esperant per sempre: el listener és a punt d'anar-se'n, així que
    // resol la promesa pendent com ho faria una sortida sense resultat, abans que ja no es pugui
    // resoldre, i rebutja qualsevol llançament encara encuat.
    synchronized(this) {
      invalidated = true
      pendingPromise?.resolve("{}")
      pendingPromise = null
    }
    reactApplicationContext.removeActivityEventListener(activityEventListener)
    super.invalidate()
  }

  companion object {
    private const val QUICK_BATTLE_REQUEST = 0xB47
  }
}

iOS es va registrar amb una macro; Android escriu el registre, en una classe de paquet per a la qual la plantilla deixa un buit. La classe base com.host.specs apareix a la primera build de Gradle, quan s’executa el codegen d’Android; si l’editor es queixa abans d’aquella build, es queixa abans d’hora, no per un error real. Crea apps/host/android/app/src/main/java/com/host/HostNativePackage.kt:

package com.host

import com.facebook.react.BaseReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.module.model.ReactModuleInfo
import com.facebook.react.module.model.ReactModuleInfoProvider
import com.host.specs.NativeShellNavigationModuleSpec

// --- El registre. iOS el rep gratis de RCT_EXPORT_MODULE i l'autolinking; Android el vol per
// escrit, en dues meitats. getModule construeix la instància quan el registry la demana pel nom,
// i getReactModuleInfoProvider declara quin és aquell nom i com es comporta — l'últim flag és el
// que importa aquí, perquè és el que marca el mòdul com a TurboModule en lloc de mòdul del
// bridge legacy.
//
// El paquet s'afegeix a la llista de MainApplication, al buit que la plantilla de React Native
// deixa comentat exactament per a això. ---
class HostNativePackage : BaseReactPackage() {

  override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? =
    when (name) {
      NativeShellNavigationModuleSpec.NAME -> ShellNavigationModule(reactContext)
      else -> null
    }

  override fun getReactModuleInfoProvider() = ReactModuleInfoProvider {
    mapOf(
      NativeShellNavigationModuleSpec.NAME to
        ReactModuleInfo(
          NativeShellNavigationModuleSpec.NAME,
          ShellNavigationModule::class.java.name,
          false, // canOverrideExistingModule
          false, // needsEagerInit
          false, // isCxxModule
          true, // isTurboModule
        )
    )
  }
}

A MainApplication.kt, el buit comentat de la plantilla per fi es fa servir:

PackageList(this).packages.apply {
  // Els mòduls natius propis del host. L'autolinking troba paquets a node_modules; un mòdul que
  // és a la mateixa app s'afegeix aquí, al buit que la plantilla li deixa.
  add(HostNativePackage())
},

La pantalla descarregada és el primer Jetpack Compose del repo, així que la build declara el compilador i les biblioteques. A apps/host/android/build.gradle:

classpath("org.jetbrains.kotlin:kotlin-gradle-plugin")
// Kotlin 2.x distribueix el compilador de Compose com a plugin a part; la pantalla nativa de
// Quick Battle és l'única cosa de l'app que el necessita.
classpath("org.jetbrains.kotlin:compose-compiler-gradle-plugin:$kotlinVersion")

A apps/host/android/app/build.gradle, el plugin, la build feature i les dependències:

apply plugin: "org.jetbrains.kotlin.android"
apply plugin: "org.jetbrains.kotlin.plugin.compose"
// Jetpack Compose, per a la pantalla nativa de Quick Battle (la contrapart del SwiftUI d'iOS).
buildFeatures {
    compose true
}
// --- Jetpack Compose, per a QuickBattleActivity. El BOM fixa la família de Compose des d'una
// sola versió, així que aquells artefactes no en porten cap de pròpia; activity-compose queda
// fora del BOM i es fixa a si mateix. ---
def composeBom = platform("androidx.compose:compose-bom:2024.09.03")
implementation(composeBom)
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.material3:material3")
implementation("androidx.activity:activity-compose:1.9.3")

L’Activity s’afegeix a AndroidManifest.xml, sense exportar:

<!-- La pantalla nativa de Quick Battle. exported=false: res de fora d'aquesta app no la llança;
     la llança el TurboModule del shell, amb startActivityForResult. -->
<activity
  android:name=".QuickBattleActivity"
  android:exported="false"
  android:theme="@style/AppTheme" />

Compara la sortida de l’Activity descarregada amb la versió Swift: Done crida deliverResult; el botó d’enrere del sistema no crida res, i no necessita res:

// RESULT_OK amb l'extra JSON és el que llegeix l'ActivityEventListener de ShellNavigationModule.
// Un gest d'enrere del sistema mai no arriba a aquest mètode, i no li cal: l'Activity acaba
// sense resultat, i el listener llegeix un Intent nul com un objecte buit.
private fun deliverResult(resultJson: String) {
  setResult(Activity.RESULT_OK, Intent().putExtra(EXTRA_RESULT_JSON, resultJson))
  finish()
}

A iOS cada sortida resol perquè el codi cobreix cadascuna; a Android, perquè la plataforma retorna totes les sortides d’una pantalla que va arribar a obrir-se, i el mòdul cobreix a mà el llançament mateix. El conjunt complet:

SortidaiOSAndroid
Done després d’una batallaresol {"winnerUid": …}extra de RESULT_OK, resol {"winnerUid": …}
Close abans de batallarresol {}extra de RESULT_OK, resol {}
Swipe per tancarblocat per isModalInPresentationno és aplicable, Activity a pantalla completa
Enrere del sistemano existeix aquest gest en una sheetIntent nul, resol {}
Segon openNative amb un d’obertla guarda de reentrada el resol amb {}la guarda de promesa pendent el resol amb {}
El llançament mateix fallala comprovació de nil de la segona xarxa resol {}el catch al voltant de l’start resol {}
Shell desmuntat en plena batallala xarxa de l’onDisappear resol {}invalidate() resol {} i rebutja un llançament encuat

La fila del segon llançament és defensa en profunditat: el flag de batalla en curs de la mateixa party, que arriba ara, ja el bloqueja des de la UI.

Fixa les garanties

La meitat de la taula recull situacions que no es poden provocar a voluntat en un simulador: un llançament que acaba en excepció, un desmuntatge que competeix amb el salt de fil, una presentació que UIKit rebutja. Les dues suites natives descarregades abans les proven.

La suite d’Android són tests de JVM purs: ShellNavigationModuleTest.kt exercita el mòdul real amb la plataforma substituïda per mocks i comprova la promesa en vuit rutes diferents. El mock de Promise anota cada resolució; el runOnUiThread del mock d’Activity s’executa en línia, o es captura i s’executa després d’invalidate(), que és com es reprodueix la cursa del desmuntatge. Dos ajustos a apps/host/android/app/build.gradle la configuren. Al bloc android:

// Els tests unitaris JVM de ShellNavigationModule s'executen contra l'android.jar de stubs de
// l'SDK; valors per defecte en lloc de throws de "not mocked" deixen construir un Intent real
// com a no-op mentre els mocks del voltant porten les assercions.
testOptions {
    unitTests.returnDefaultValues = true
}

I a dependencies:

// --- Tests unitaris JVM del mòdul del pont: la garantia de resolució de la promesa davant
// d'una fallada de llançament, un desmuntatge i el listener del resultat. Mockito 5 mockeja
// finals per defecte, que és el que permet substituir Activity i el context de React sense
// embolcalls. ---
testImplementation("junit:junit:4.13.2")
testImplementation("org.mockito:mockito-core:5.14.2")
testImplementation("org.mockito.kotlin:mockito-kotlin:5.4.0")

La suite d’iOS necessita una mica de cirurgia de projecte, perquè l’app no n’és l’amfitriona. A Xcode: File → New → Target → Unit Testing Bundle, amb el nom HostTests i “Target to be Tested” a None; després selecciona els QuickBattlePresenterTests.swift i QuickBattle.swift descarregats i marca HostTests a Target Membership. Si el bundle nou no és a l’acció Test de l’esquema Host (Product → Scheme → Edit Scheme → Test), afegeix-l’hi. El bundle compila el presenter directament en lloc de carregar l’app, així que els seus tres tests no necessiten arrencada de React Native, ni dev server, ni jerarquia de finestres: cap amfitrió des d’on presentar, una segona presentació rebutjada, i una presentació que UIKit rebutja. Aquesta última és la xarxa de la comprovació de nil, que un test comprova en lloc de deixar-la escrita només en un comentari. Els tests preparen cada cas reassignant hostProvider.

Connecta el botó

La part de la party comença pel seu slice; les dues peces noves queden privades, en cap contracte, perquè res de fora de la party no les despatxa. apps/party/src/partySlice.ts sencer:

import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
import { addToParty, MAX_PARTY, rootReducer, type PartyMember } from '@pokedex/contracts';

// --- L'estat de la party, propietat plena de l'app party. El contracte porta l'action creator i
// la forma de lectura; aquest fitxer porta el que signifiquen. Res de fora d'aquesta app no
// l'importa — el host el carrega com a mòdul federat a l'arrencada, per l'efecte secundari del
// final. ---
export const partySlice = createSlice({
  name: 'party',
  initialState: { members: [] as PartyMember[], lastBattleWinnerUid: null as string | null },
  reducers: {
    // Privat: ningú més no despatxa remove, així que no viatja en cap contracte.
    remove(state, action: PayloadAction<string>) {
      state.members = state.members.filter(m => m.uid !== action.payload);
      // Un membre eliminat no pot continuar sent l'últim guanyador; el banner anomenaria un
      // Pokémon que ja no és a la party.
      if (state.lastBattleWinnerUid === action.payload) {
        state.lastBattleWinnerUid = null;
      }
    },
    // També privat, i expressament. El guanyador torna d'una pantalla nativa que va presentar el
    // HOST, però acaba a l'estat propi de la party i res de fora d'aquesta app no el despatxa —
    // així que no apareix en cap contracte. Els tipus de params i resultat del traspàs són a la
    // frontera perquè el host els ha de serialitzar, que és una raó diferent de creuar.
    setLastBattleWinner(state, action: PayloadAction<string>) {
      state.lastBattleWinnerUid = action.payload;
    },
  },
  extraReducers: builder => {
    // La interacció que creua. L'app list despatxa l'addToParty del contracte, i aquest case el
    // reconeix per l'string de tipus: addCase llegeix actionCreator.type i registra el reducer
    // sota `party/add`, així que l'acord sobre aquell string és el que fa funcionar el match, no
    // la identitat de l'objecte creador. Compartir @pokedex/contracts com a singleton és el que
    // evita que les dues bandes reescriguin per separat aquell string, el límit i la forma de
    // lectura.
    builder.addCase(addToParty, (state, { payload }) => {
      if (state.members.length >= MAX_PARTY) return; // el límit va amb el propietari
      state.members.push(payload);
    });
  },
});

export const { remove, setLastBattleWinner } = partySlice.actions;

// Importar aquest mòdul és el que afegeix el reducer a l'store compartit. rootReducer és el
// mateix objecte que va connectar el configureStore del host — per això funciona la injecció des
// d'una app construïda a part.
rootReducer.inject(partySlice);

Dos tests propis de la party comproven la forma exacta de l’slice, així que afegeixen el mateix camp. A __tests__/partyStateReady.test.ts i __tests__/partySelfRecovery.test.tsx, l’asserció toEqual({ members: [] }) passa a ser:

expect((store.getState() as PartySliceShape).party).toEqual({
  members: [],
  lastBattleWinnerUid: null,
});

PartyScreen.tsx creix en quatre llocs. Primer els imports:

import { useColorScheme } from 'nativewind';
import {
  MAX_PARTY,
  partyStateReady,
  shellNavigate,
  type PartyMember,
  type PartySliceShape,
  type QuickBattleResult,
} from '@pokedex/contracts';
import { Box, Button, ButtonText, EmptySlot, PokemonCard, ScreenContainer, Text, toast } from '@pokedex/ui';
import { remove, setLastBattleWinner } from './partySlice';

A nivell de mòdul, la forma de lectura local del propietari i el mínim de la batalla:

// La vista que el propietari té del seu propi slice. El contracte porta `members`, perquè els
// mòduls forans el llegeixen; l'últim guanyador es llegeix aquí i enlloc més, així que queda
// fora del contracte i s'afegeix a la forma en local.
type PartyOwnShape = PartySliceShape & {
  party?: { lastBattleWinnerUid?: string | null };
};

// Dos contendents és el mínim per a una batalla. Escrit com a constant perquè l'estat desactivat
// del botó i la nota de sota són dues lectures de la mateixa regla.
const MIN_CONTESTANTS = 2;

Dins del component, els selectors i el handler:

const { colorScheme } = useColorScheme();
const members = useSelector((s: PartyOwnShape) => s.party?.members ?? EMPTY_MEMBERS);
const lastBattleWinnerUid = useSelector(
  (s: PartyOwnShape) => s.party?.lastBattleWinnerUid ?? null,
);
const lastWinner = members.find(m => m.uid === lastBattleWinnerUid) ?? null;
// L'única crida que fa un remote per arribar a natiu. `shellNavigate` és tot el que aquesta app
// sap de l'altra banda: sense import de TurboModule, sense tipus natius, sense cap idea que la
// pantalla que obre és SwiftUI en una plataforma i Compose a l'altra. El host resol el destí i
// és l'amo de tot el que hi ha després.
//
// L'await és la clau. No retorna fins que el flux natiu ha acabat, i el que torna acaba a
// l'estat propi d'aquesta app a través de la seva pròpia acció — el viatge d'anada i tornada
// comença i acaba dins de la party, així que res de la batalla no necessita creuar el contracte.
const [battleInFlight, setBattleInFlight] = React.useState(false);
const onQuickBattle = React.useCallback(async () => {
  if (battleInFlight) return;
  setBattleInFlight(true);
  try {
    // El tema es llegeix en el moment de la crida, del mateix observable de NativeWind al qual
    // se subscriu cada classe dark: de la federació. S'envia en lloc d'observar-se perquè la
    // pantalla nativa no té manera de subscriure-s'hi: un canvi de tema amb la batalla oberta no
    // li arribarà.
    const result = (await shellNavigate('QuickBattle', {
      members,
      colourScheme: colorScheme === 'dark' ? 'dark' : 'light',
    })) as QuickBattleResult | undefined;
    // Sense guanyador és un resultat real, no una fallada: la pantalla es pot tancar sense
    // batallar, i el costat natiu resol amb un objecte buit quan passa.
    if (result?.winnerUid) {
      dispatch(setLastBattleWinner(result.winnerUid));
    }
  } finally {
    setBattleInFlight(false);
  }
}, [battleInFlight, colorScheme, dispatch, members]);

El render afegeix el botó, la seva nota i el banner, després de la graella de buits:

{/* L'únic control del traspàs. Desactivat per sota de dos membres, perquè una batalla necessita
    dos contendents; la nota de sota diu quina regla és, en lloc de deixar que un botó mort
    s'expliqui sol. */}
<Button
  onPress={onQuickBattle}
  disabled={members.length < MIN_CONTESTANTS || battleInFlight}
  size="lg"
  className={`mt-6 rounded-xl ${
    members.length < MIN_CONTESTANTS ? 'bg-lightGrey dark:bg-white/10' : 'bg-purple'
  }`}
  // El mínim de 44pt es declara, no s'hereta de la variant de mida, igual com el declara el botó
  // Add del detail: la suite d'accessibilitat només pot comprovar el que el control declara de
  // si mateix.
  style={{ alignSelf: 'stretch', minWidth: 44, minHeight: 44 }}
  accessibilityRole="button">
  <ButtonText
    className={members.length < MIN_CONTESTANTS ? 'text-midGrey' : 'text-white'}>
    {battleInFlight ? 'Battling…' : 'Quick Battle'}
  </ButtonText>
</Button>
{members.length < MIN_CONTESTANTS ? (
  <Text size="xs" className="mt-2 text-center text-darkGrey dark:text-lightGrey">
    Add at least 2 Pokémon to battle.
  </Text>
) : null}

{/* El que ha tornat de natiu, renderitzat per l'app que posseeix l'estat. */}
{lastWinner ? (
  <Text
    size="sm"
    className="mt-3 text-center font-semi text-darkGrey dark:text-lightGrey"
    accessibilityLiveRegion="polite">
    Last battle winner: {lastWinner.name}
  </Text>
) : null}

Un canvi és fàcil de passar per alt, i en aquesta build va suposar una sessió sencera de depuració. Els buits plens van dins d’un React.memo a nivell de mòdul, i un canvi de tema no altera cap de les seves props, així que el memo se salta el subarbre i la targeta es queda amb els objectes d’estil de l’esquema anterior: un rectangle blanc buit. La key del buit inclou l’esquema, així que canviar de tema passa a remuntar sis targetes petites:

// La key inclou l'esquema de color, i l'ha d'incloure. Un canvi de tema no altera cap prop
// d'aquest buit, així que el memo de dalt les troba idèntiques i se salta el subarbre; la
// targeta es queda llavors amb els objectes d'estil que el runtime d'estils va resoldre per a
// l'esquema anterior i es renderitza com un rectangle blanc buit. Posar l'esquema a la key
// converteix el canvi de tema en un remuntatge de sis targetes petites, mentre el memo continua
// fent la seva feina dins d'un tema. La graella de la Pokédex no memoïtza les seves targetes, i
// per això mai no ho va mostrar.
<PartySlot
  key={`${member.uid}:${colorScheme}`}
  member={member}
  onOpen={openDetail}
  onRemoveMember={removeMember}
/>

Executa-ho

El codi natiu ha canviat a les dues plataformes, així que totes dues necessiten builds reals, com les pantalles del post 4 i els pods d’animació del post 11. Aquesta vegada el codi compilat és de la mateixa sèrie. Tres dev servers, cadascun al seu terminal:

( cd apps/host && npm start )
( cd apps/list && npm run start:remote )
( cd apps/party && npm run start:remote )

Reinicia el dev server del host amb --reset-cache si ja era en marxa. Un server arrencat abans d'instal·lar contracts 3.3.0 continua servint la resolució antiga, i l'arrencada mor amb TypeError: undefined is not a function a App.tsx: una build de contracts en cache sense registerShellNavigateHandler.

Després les builds, i les suites amb elles:

( cd apps/host && npm run ios )
( cd apps/host && npm run android )
for d in apps/host apps/party; do ( cd $d && npx jest --silent ); done

I les suites natives, canviant el nom del simulador per un que tinguis instal·lat:

( cd apps/host/android && ./gradlew :app:testDebugUnitTest )
( cd apps/host/ios && xcodebuild test -workspace Host.xcworkspace -scheme Host -only-testing:HostTests -destination 'platform=iOS Simulator,name=iPhone 17 Pro' )

Afegeix dos Pokémon, obre la pestanya Party, prem Quick Battle. La sheet nativa apareix amb les mateixes targetes de la graella, llevat del badge; Battle encercla el guanyador amb el seu color de tipus; Done tanca; el banner anomena el guanyador que ha arribat en la promesa. Canvia abans el tema i la pantalla nativa el respecta, perquè fa servir l’esquema que la crida va enviar:

El viatge d'anada i tornada a iOS: Quick Battle premut a la pestanya Party federada, la sheet nativa de SwiftUI es presenta amb el seu badge NATIVE iOS i la party renderitzada com a targetes del design system, Battle encercla el guanyador, Done tanca, i el banner de la pestanya Party diu Last battle winner

El badge és l’única pista. Així es manté el design system als dos costats d’una frontera que cap bundle no creua:

La sheet nativa de Quick Battle en mode fosc: dos membres de la party dibuixats amb l'aspecte de targeta del design system, el guanyador encerclat i amb halo en el seu color de tipus, sota un badge morat de NATIVE iOS

Ara trenca-ho

Ara que el viatge d’anada i tornada ja funciona, el risc del pont mereix provocar-se una vegada. El presenter dirigeix cada sortida al seu wrapper de resolució única, així que la manera més neta de trencar-lo és tocar l’única línia de la qual depenen totes les rutes. Al QuickBattle.swift descarregat, busca el wrapper i comenta la seva última crida:

var settled = false
let settle: (String) -> Void = { resultJson in
  guard !settled else { return }
  settled = true
  isPresenting = false
  // completion(resultJson)   <- trenca-ho: la resolució no passa mai
}

Recompila, obre Quick Battle, batalla, prem Done. La sheet es tanca amb normalitat, i de tornada a la pestanya Party el botó diu «Battling…» per a tota la vida del procés: l’await no retorna mai, així que el finally que neteja battleInFlight no s’executa mai. Un segon toc no fa res, blocat per la guarda de batalla en curs. Sense red box, sense línia de log, sense promesa rebutjada. Cap xarxa de seguretat no va ajudar: la primera va cridar el seu settle("{}"), però la guarda settled ja estava activada; i la segona no tenia res a fer, perquè la presentació va anar bé. Les xarxes cobreixen les rutes que arriben al wrapper, i res no cobreix l’última línia del wrapper mateix. Mereix pensar-s’hi un moment: la defensa en profunditat acaba en algun lloc, i la línia on acaba és la que una revisió ha de llegir amb més cura.

L’app no falla, i aquest és el problema.

La sèrie té una petita col·lecció de fallades silencioses, i aquesta és la més silenciosa. La incorporació perduda del post 8 almenys despatxava a un store i es veia a les devtools; aquesta no deixa rastre, perquè no ha passat res. Android no pot reproduir la mateixa fallada: buida deliverResult, i el gest d’enrere del sistema continua arribant a onActivityResult amb un Intent nul i resolent {}. Aquesta és l’asimetria de la taula de sortides, vista en directe. Restaura la línia i recompila.

El que has construït, i el que ve

Un remote federat va demanar una pantalla nativa i la va aconseguir, sense aprendre res de natiu. El contracte exposa, sobre una taula de rutes d’una sola entrada, una funció que retorna una promesa; el host la implementa amb un TurboModule que porta JSON en tots dos sentits i presenta SwiftUI en una plataforma i una Activity de Compose a l’altra, totes dues amb els tokens i el tema del design system. L’uid del guanyador torna en la promesa i acaba a l’estat de la party a través d’una acció privada, sense creuar cap acció del contracte, com prediuen les regles de propietat del post 7.

Els límits, dits clarament: el pont és d’un sol sentit, així que una pantalla nativa encara no pot demanar al shell navegar enlloc, i els deep links no passen per la taula; tots dos queden fora a propòsit i són les properes peces del pont. El tema viatja als params, així que un canvi en plena batalla arriba a la batalla següent, no a l’actual.

El següent: un bundle de producció i el primer artefacte de release federat.

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