Isang federated remote na umaabot sa native screen sa pamamagitan ng hatch ng host at tumatanggap ng resulta

shell.navigateTo: mga native screen mula sa federated remote sa React Native

Natapos ang post 12 sa isang alok ng host sa mga remote: ipapahiram nito ang native side, sa pamamagitan ng bridge na magbubukas ng native screen mula sa Party tab at maghihintay ng resulta. Itinatayo ng post na ito ang bridge na iyon. Nagbubukas ang isang button sa isang federated tab ng ganap na native na screen, SwiftUI sa isang platform at Jetpack Compose sa kabila, at bumabalik ang nanalo sa battle bilang value ng na-resolve na promise.

Kasama na ng serye ang constraint na ito mula pa sa post 1: JavaScript lang ang ginagalaw ng runtime delivery, wala nang iba. Hinarap ito ng post 11 sa design system, at nakasulat sa listahan ng limitations ng Re.Pack ang responsibilidad ng host: “Host application must have native modules used in other containers”. Kung gusto ng isang remote ng native screen, hindi nito madadala iyon. Ipinapahiram ng host ang sarili nitong native side.

Ganito gumagana ang pahiram: eksaktong isang function lang ang nakikita ng remote, ang shellNavigate(destination, params?) mula sa contract, na nagbabalik ng promise. Ang shell.navigateTo sa title ay ang function na ito, sa pangalang ini-export ng contract. Nasa likod nito ang routing table ng host at ang unang TurboModule ng blog (ang typed native-module system ng React Native), kasama ang unang Swift at Kotlin code ng serye na lampas sa template. Ang round trip:

May isang praktikal na pagkakaiba bago ang unang command. Hanggang ngayon, convenience lang ang clone: tina-type ang lahat, at kasing-ayos nito ang tree na binuo mo mismo mula pa sa post 2. Ito ang unang post kung saan kailangan talaga ang companion repo: galing sa tag nito ang mga native screen, at sa oras na magrehistro ang isang TurboModule, kailangang tugma ang native project mo sa nasa tag. Magsimula sa tapos na estado ng post 12, ang start tag:

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

Ang tapos na estado ay ang end tag, ang post-13-native-handoff; mamaya, sa gitna ng build, magda-download ka ng ilang file mula rito, at ikaw ang magta-type ng lahat ng iba pa.

Isang function sa contract

May nadadagdag na isang file sa contract, at nandoon ang lahat ng makikita ng remote tungkol sa native side. Gawin ang packages/contracts/src/shellNavigation.ts:

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

// --- Ang routing surface ng shell, at ang kabuuan ng alam ng remote tungkol sa native. Ang
// ini-import ng remote ay `shellNavigate` at wala nang iba: walang TurboModule, walang native
// types, walang ideya kung ang destination na tinutukoy nito ay isa pang micro-app o isang ganap
// na native na screen. Sa host ang table na nagpapasya, kaya ang pag-migrate ng isang native
// flow papuntang React Native balang araw ay isang row edit dito at walang babaguhin sa kahit
// anong remote. ---

export type RouteEntry =
  /** Handoff sa native: tinatawag ng host ang openNative, may native screen na lalabas, at
   *  magre-resolve ang promise gamit ang kahit anong ibinalik ng screen na iyon. */
  { type: 'native'; nativeId: string };

// Isang entry lang, dahil isang bagay lang ang nagdi-dispatch. Nakasulat ang union sa itaas
// bilang union na may isang member lang para makasali ang isang micro-app variant nang hindi
// nagbabago ang hugis para sa mga bumabasa ng `entry.type`; ang registry row na walang laman sa
// likod ay patay na constant, at nakabayad na ang seryeng ito para sa isa.
export const ROUTE_REGISTRY: Record<string, RouteEntry> = {
  QuickBattle: { type: 'native', nativeId: 'quickBattle' },
};

// --- Ang tumatawid sa boundary, sa dalawang direksyon. Nasa contract ang mga type na ito dahil
// ang HOST ang kailangang mag-serialize sa kanila, hindi dahil may ibang app na nagdi-dispatch:
// sa party ang resulta ng battle at ang reducer ng party ang humahawak dito. ---

/** RN -> native. Iniaabot ng party ang mga member nito; ipinapakita at pinaglalaban sila ng
 *  native screen.
 *
 *  Kasama ng mga member ang color scheme dahil wala itong ibang paraan. Bumabasa ang bawat
 *  federated surface sa iisang styling runtime na mino-mount ng host, at ang native screen ang
 *  tanging consumer na hindi makaka-subscribe doon: walang bundle na maipapamahagi at walang
 *  provider na makakabitan. Kaya sa boundary na ito, hindi na ambient ang theme kundi nagiging
 *  argument, na ipinapadala sa mismong sandali ng tawag. */
export interface QuickBattleParams extends Record<string, unknown> {
  members: PartyMember[];
  colourScheme: 'light' | 'dark';
}

/** Native -> RN. Ang uid, hindi ang id: dalawang kopya ng parehong Pokémon ay dalawang kalahok,
 *  at dahil mismo dito, nilalagyan ng party ng uid ang bawat member mula pa noong 3.1.0. Wala
 *  ito kapag nagsara ang screen nang walang battle. */
export interface QuickBattleResult {
  winnerUid?: string;
}

/** Nagre-resolve ang native gamit ang isang object, o wala kapag natapos ang flow nang walang
 *  resulta. */
export type ShellNavigateResult = Record<string, unknown> | undefined;

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

// --- Ang slot na pinupunan ng host at binabasa ng bawat remote. Isa itong globalThis key sa
// halip na React context dahil naka-bundle ang package na ito sa loob ng host AT sa loob ng
// bawat remote: ang context na gagawin dito ay magiging ibang object sa bawat bundle, kaya hindi
// mahahanap kailanman ng useContext ng remote ang provider ng host. Ang module identity na
// ginawang singleton ng posts 3 at 6 ang eksaktong kulang sa seam na ito, at ang globalThis ang
// tanging slot na talagang pinagsasaluhan ng tatlong bundle. ---
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') {
    // Walang handler: mag-warn at mag-resolve. Ang remote na nag-a-await nito ay makakatanggap
    // ng undefined at magre-render ng tapat na resulta, ang parehong tolerance na pinagbatayan
    // ng read shape ng party slice.
    console.warn(`[shellNavigate] no handler registered (called with ${destination})`);
    return Promise.resolve(undefined);
  }
  return fn(destination, params);
}

Nakadokumento sa mga comment ang tatlong desisyon: isang entry lang sa registry, dahil isang bagay lang ang nagdi-dispatch; ang theme bilang argument, dahil hindi makaka-subscribe ang native screen sa styling runtime; at isang globalThis slot sa halip na React context, na kapag na-bundle sa bawat app ay magiging ibang object sa bawat isa. Ang uid na idinadagdag ng prepare mula sa post 8 ay may unang gumagamit na ngayon sa kabilang panig ng boundary: ito ang value na lumalabas ng app at bumabalik.

Dapat munang banggitin ang native stack ng React Navigation: para sa mga native-backed na React Native screen sa loob ng navigator, ito ang tamang tool. Ang hindi nito kaya ay isang screen na pag-aari ng host at nasa labas ng React tree, na kumukuha ng input mula sa isang remote at nagbabalik ng value dito. Ang round trip na iyon ang dahilan kung bakit may bridge.

I-export ito, itaas ang version, at i-publish sa parehong Verdaccio flow na itinatag ng post 5. Sa packages/contracts/src/index.ts:

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

Sa packages/contracts/package.json, "version": "3.3.0": isang additive na minor version sa ibabaw ng 3.2.2 ng post 12. May kasama itong dalawang pagbabago sa tooling: tumatawag ng console.warn ang bagong file, kaya idinadagdag ng tsconfig.json ng contracts ang "DOM" sa lib array nito gaya ng ginagawa na ng ui at a11y-testing, at nililinis na ng build script ang dist para walang lumang declaration file na maiiwan hanggang sa publish:

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

Mag-publish, tapos i-install ito sa tatlong app:

( 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

Ang spec at ang codegen

Nagsisimula ang TurboModule bilang TypeScript spec, na binabasa ng code generation ng React Native sa build para isulat ang mga base class na ine-extend ng mga native implementation. Gawin ang apps/host/specs/NativeShellNavigationModule.ts:

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

// --- Ang TurboModule spec: ang nag-iisang lugar kung saan nagkakasundo sa isang signature ang
// JavaScript ng host at ang native code nito. Binabasa ng codegen ang file na ito sa build at
// isinusulat ang mga C++/ObjC++ at Kotlin base class na ine-extend ng mga native
// implementation, kaya nasa labas ng src/ ang file at kailangang magsimula sa `Native` ang
// pangalan nito.
//
// Isang method, at asynchronous mismo sa hugis: iniaabot ng openNative sa isang native flow ang
// input nito at nagre-resolve gamit ang kahit anong ibinalik ng flow na iyon. Ang promise ang
// buong disenyo — hindi bumabalik ang await ng party hangga't hindi tapos ang native screen, at
// kailangang i-resolve iyon ng bawat exit path ng screen nang eksaktong isang beses.
//
// JSON strings sa dalawang direksyon ang boundary, sa halip na codegen object types. Kaya ng
// codegen magdala ng structured objects, at dapat itong ipaubaya ng mas malaking bridge; ang
// string ang nagpapanatiling kita ang serialization sa iisang lugar sa bawat panig, mas madaling
// sundan habang isang method pa lang ang lapad ng bridge. ---

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

// Nagtha-throw ang getEnforcing kapag walang native module na sumasagot sa pangalan: binary na
// binuo nang walang native half, o codegen mismatch. Nag-i-import ang mismong mga example ng
// React Native ng spec nang ganito sa module scope, at ayos iyon habang nasa binary ang module.
// Nire-require ng handler ng host ang file na ito nang lazy, bilang desisyon kung saan
// papayagang lumabas ang throw na iyon. Kapag in-import sa boot at wala ang native half,
// mamamatay ang shell habang nag-e-evaluate ang bundle, bago pa magkaroon ng kahit anong error
// boundary; kapag ni-require sa tawag, lalabas ang parehong fault sa pagpindot ng button, sa
// tumatakbong app na kayang mag-log nito at magpatuloy. Ang runtime delivery ang dahilan kung
// bakit totoo ang mismatch na ito: puwedeng dumating ang JavaScript sa binary na binuo nang
// hindi kasama ang native side.
export default TurboModuleRegistry.getEnforcing<Spec>('ShellNavigationModule');

JSON strings sa dalawang direksyon ang boundary; ipinapaliwanag ng dulo ng panimulang comment ng spec kung bakit. Sabihin sa codegen kung nasaan ang spec, sa apps/host/package.json:

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

Tumatakbo ang codegen kasabay ng pod install, kaya patakbuhin ito ngayon:

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

Ang handler ng host ang bumabasa ng routing table. Gawin ang apps/host/src/shell/shellNavigation.ts:

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

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

// --- Ang kalahati ng host sa shell.navigateTo. Tinatawag ng remote ang shellNavigate ng
// contract; ito ang tumatakbo. Hinahanap nito ang destination sa routing table at iniaabot ang
// mga native destination sa TurboModule, JSON papasok at JSON palabas. Hindi kailanman nalalaman
// ng remote kung aling branch ang tinahak. ---

// Tumatawag ang spec module ng TurboModuleRegistry.getEnforcing sa import, at nagtha-throw iyon
// kapag walang native module na sumasagot sa pangalan. Ang pag-require nito rito, sa loob ng
// tawag, ang nagpapasya kung saan puwedeng lumabas ang throw: sa tap, sa tumatakbong shell na
// nag-lo-log nito at nagpapatuloy, sa halip na habang nag-e-evaluate ang bundle sa boot, kung
// saan mamamatay ang app nang walang error boundary kapag wala ang native half.
function nativeModule(): ShellNavigationSpec {
  return require('../../specs/NativeShellNavigationModule').default as ShellNavigationSpec;
}

export const shellNavigateHandler: ShellNavigateFn = async (destination, params) => {
  const entry = ROUTE_REGISTRY[destination];
  if (!entry) {
    // Bug ng tumatawag ang hindi kilalang destination, hindi crash: mag-warn at mag-resolve,
    // para ang remote na binuo gamit ang mas bagong contract kaysa sa host ay mag-degrade sa walang
    // mangyayari.
    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 {
    // Bug sa native side ang native result na hindi mapa-parse, at hindi dahilan para pasabugin
    // ang await ng remote: mag-warn at mag-resolve, ang parehong tolerance na ginagamit sa lahat
    // ng punto ng seam na ito.
    console.warn(`[shellNavigate] unparseable native result for ${destination}`);
    return undefined;
  }
};

Pinipili ng lazy na require() kung saan lalabas ang fault kapag wala ang native half: sa tap, hindi sa boot. Nagtha-throw ang getEnforcing kapag walang module na sumasagot sa pangalan. Nag-i-import ang mismong mga example ng React Native ng spec sa module scope, na gumagana habang nasa binary ang module; ginagawang totoo ng runtime delivery ang mismatch. Sa boot, ang fault ay white screen na walang error boundary; sa tawag, isang failure sa iisang button na kaya namang i-log ng app.

Dalawang linya lang ang registration sa apps/host/App.tsx:

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

// Isang beses pinupunan ng host ang navigation slot ng contract, sa module scope, bago
// makapag-render at makatawag ng shellNavigate ang kahit anong remote. Walang native code na
// nagagalaw sa pagrehistro ng handler — hindi naaabot ang TurboModule hangga't walang aktwal na
// pag-navigate sa isang destination — kaya ligtas ito sa import, sa paraang hindi magiging
// ligtas ang pag-require ng spec dito.

registerShellNavigateHandler(shellNavigateHandler);

I-type ang bridge, i-fetch ang mga screen

Mula rito, hati ang trabaho. Ang bridge ang nagtuturo ng federation, kaya ita-type mo itong buo: ang module na nagpapanatiling bukas ng promise sa kabila ng boundary, ang registration nito, at ang paghawak sa mga thread. SwiftUI at Compose UI code naman ang mga screen, kaya galing sila sa end tag mismo ng post na ito. Mga isang libong linya ang hindi mo na ita-type, pero walang isa mang hindi ipinaliwanag.

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

Sinusunod ng dalawang screen ang design system. Walang bundle na tumatawid sa boundary na ito, kaya kinokopya ng bawat file nang mano-mano ang colours.ts at ang labingwalong type color, muling binuo sa parehong itsura ng card na nire-render ng Pokédex grid: ang tinted na sprite disc, ang number na may hash sa loob ng bilugang label, ang mga type badge, at ang kulay-accent na guhit sa ibaba. Isinasalin ng bawat screen ang colourScheme na dala ng params sa parehong pares ng kulay na ginagamit ng mga dark: rule; nagde-declare ng Role.Button ang mga Compose button, dahil kung hindi, plain text ang anunsyo ng naka-style na Text sa TalkBack; at may purple na NATIVE badge ang bawat screen. Walang federated screen na may ganoon, kaya sapat na ang isang screenshot para malaman kung saang panig ng boundary ang isang screen.

May kasamang apat na suite ang mga screen. Sinusubok ng dalawang JavaScript suite ang JS na bahagi ng bridge: sa host, ang contract slot at ang handler, kasama ang mga hindi mapa-parse na resulta; sa party, ang pag-disable ng button at ang pagdating ng panalo sa pamamagitan ng pribadong action. Sinusubok naman ng dalawang native suite ang mga garantiya ng promise na itatayo ng susunod na dalawang section; ise-set up ang mga ito pagkatapos ng exit table.

iOS: nagre-resolve ang bawat exit

Objective-C++ ang iOS module, at ang extension mismo ang punto: nagbabalik ang getTurboModule ng generated na JSI module (JavaScript Interface, ang C++ layer sa ilalim ng new architecture) bilang std::shared_ptr, na hindi kayang i-express ng Swift. Nananatiling purong SwiftUI ang screen; adapter lang ang .mm. Sa Xcode, gawin ang ShellNavigationModule.h at ShellNavigationModule.mm sa Host group, na naglalagay sa kanila sa target, at idagdag ang na-fetch na QuickBattle.swift gamit ang File → Add Files to “Host”.

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

// --- Ang native half ng host sa shell.navigateTo sa iOS. Binasa ng codegen ang specs/
// NativeShellNavigationModule.ts at isinulat ang NativeShellNavigationModuleSpecBase at ang
// NativeShellNavigationModuleSpec protocol sa HostSpecs; nag-su-subclass ang class na ito sa
// una at sumusunod sa pangalawa, kaya dito napupunta ang `openNative` ng JavaScript.
//
// May header dahil ObjC++ (.mm) ang implementation at hindi Swift: kailangan ng C++ para
// ibalik ang generated na C++ JSI module mula sa getTurboModule, at hindi iyon kaya ng Swift.
// Purong SwiftUI ang screen na pine-present nito — tingnan ang QuickBattle.swift. ---
@interface ShellNavigationModule : NativeShellNavigationModuleSpecBase <NativeShellNavigationModuleSpec>
@end
#import "ShellNavigationModule.h"
#import <HostSpecs/HostSpecs.h>
// Dine-declare ng Host-Swift.h ang bawat @objc na Swift class sa app na ito, at ganito
// nakakaabot ang ObjC++ file na ito sa QuickBattlePresenter. Ini-import pagkatapos ng header ng
// app delegate para ma-resolve sa translation unit na ito ang mga forward reference ng
// generated na header.
#import <React-RCTAppDelegate/RCTDefaultReactNativeFactoryDelegate.h>
#import "Host-Swift.h"

// --- Ang TurboModule implementation. Dalawang bagay lang ang nandito: ang registration na
// naglalagay ng module sa registry sa ilalim ng pangalang hiningi ng spec, at ang openNative.
//
// Sa openNative nagsisimula ang promise. Iniaabot nito ang resolve block sa presenter at agad
// bumabalik; tatawagin ang block mamaya, mula sa native screen, kapag natapos ang screen na
// iyon. Hanggang doon, tunay na nakahinto ang `await` ng party. Iyon ang buong handoff, at iyon
// din ang buong panganib: kung may daan sa native screen na hindi tatawag sa block, maiiwang
// nakabinbin ang promise habang buhay ang process. ---
@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);
                                 }];
}

// Ang codegen na C++ module. Ang method na ito ang dahilan kung bakit ObjC++ ang file at hindi
// Swift: nagbabalik ito ng std::shared_ptr, na walang paraan ang Swift para i-express.
- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
    (const facebook::react::ObjCTurboModule::InitParams &)params
{
  return std::make_shared<facebook::react::NativeShellNavigationModuleSpecJSI>(params);
}

@end

Ang nangyayari sa resolve block na iyon ang core ng post. Nire-resolve ng bawat exit ng native screen ang promise, nang eksaktong isang beses, at dumadaan ang lahat sa isang wrapper na unang resolve lang ang tinatanggap. Dumarating ang tawag sa labas ng main thread, kaya tumatalon muna sa main bago galawin ang UIKit. Galing ang host controller sa hostProvider, isang static variable na naghahanap sa key window bilang default; ang unit bundle lang ang nagpapalit nito. Agad na nire-resolve ng re-entrancy guard ang pangalawang presentation; naka-block ang pagsara ng sheet gamit ang swipe; at may dalawang safety net para sa mga exit na hindi dumadaan sa kahit anong button. Sinasalo ng safety net one ang sheet na nawala sa screen nang hindi dumaan sa finish(). Sinasalo ng safety net two ang presentation na tahimik na tinanggihan ng UIKit, at wala itong inaasahan: hindi dokumentado kung tumatakbo ang completion ng tinanggihang present(). Makalipas ang isang ikot sa main queue, wala pa ring presentingViewController ang tinanggihang controller; kapag wala nito, walang magsasara kailanman ng sheet, kaya nire-resolve ito ng wrapper:

// Dito nagtatapos ang lahat ng path sa ibaba, at hindi ito magre-resolve nang higit sa isang
// beses. Nag-uunahan ang sariling mga exit ng battle at ang dalawang safety net sa mas ibaba,
// at panalo ang unang dumating; nagiging no-op ang iba sa halip na dobleng resolve.
var settled = false
let settle: (String) -> Void = { resultJson in
  guard !settled else { return }
  settled = true
  isPresenting = false
  completion(resultJson)
}

// Dumarating ang openNative sa sariling queue ng TurboModule, hindi sa main thread. Kailangang
// nasa main ang lahat ng tawag sa UIKit sa ibaba, kaya tumalon bago gumalaw ng kahit ano.
DispatchQueue.main.async {
  guard let host = Self.hostProvider(), !isPresenting else {
    // Walang mapagpe-presentan, o may isang flow nang nakabukas: mag-resolve sa halip na
    // maipit.
    completion("{}")
    return
  }
  isPresenting = true

  // Nire-resolve ang resulta BAGO magsimula ang dismissal, hindi sa completion nito. Tumatakbo
  // ang viewDidDisappear habang papalabas pa lang ang animation ng sheet, kaya ang resolve na
  // naka-schedule pagkatapos ng transition ay matatalo sa karera ng safety net one at darating
  // ang panalo bilang "{}".
  let view = QuickBattleView(contestants: contestants, isDark: isDark) { resultJson in
    settle(resultJson)
    host.dismiss(animated: true)
  }

  let controller = QuickBattleHostingController(rootView: view)
  controller.modalPresentationStyle = .pageSheet
  // Lumabas lang sa sariling mga control ng screen. Kapag naisara ang sheet gamit ang
  // interactive na swipe, hindi ito dadaan sa resolve sa itaas at maiiwang nakabinbin ang
  // promise.
  controller.isModalInPresentation = true
  // Safety net one: nawala ang sheet sa screen sa rutang hindi dumaan sa mga button — sapat na
  // ang pagkakatanggal ng isang ancestor. Mas mabuti ang resolve na walang resulta kaysa
  // promise na nakabinbin.
  controller.onDisappear = { settle("{}") }
  // Safety net two: walang ginagawa ang present(), tahimik, kapag nasa gitna ng transition ang
  // host dahil sa mga dahilang hindi nakikita ng file na ito. Hindi dokumentado kung
  // pinapatakbo ng UIKit ang completion ng tinanggihang present, kaya walang umaasa rito:
  // makalipas ang isang ikot sa main queue, may presentingViewController ang na-present na
  // controller at wala pa rin ang tinanggihan, at ang nil ay nangangahulugang walang magsasara
  // kailanman ng sheet na ito, kaya mag-resolve na ngayon.
  host.present(controller, animated: true)
  DispatchQueue.main.async {
    if controller.presentingViewController == nil {
      settle("{}")
    }
  }
}

May mahalagang pagkakasunod-sunod dito: nire-resolve ang resulta bago magsimula ang dismissal, dahil tumatakbo ang viewDidDisappear sa gitna ng animation. Sa view, dumadaan ang dalawang exit, ang Close at ang Done, sa iisang finish(), at resulta ang paglabas nang walang battle, hindi 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)
}

Walang safety net ang promise na hindi kailanman na-resolve. Walang timeout sa bridge, walang tulong mula sa garbage collector, walang error na makikita: basta na lang hindi bumabalik ang await, at mananatiling naka-retain ang resolve block at lahat ng hawak nito habang buhay ang process. Isang log line lang ang gastos ng kabaligtarang fault, dahil binabalewala ng bridge ang pangalawang resolve. Obligado ang pag-resolve sa bawat exit; hindi naman nakakasira ang pag-resolve nang dalawang beses.

Android: resulta ang bawat exit

Galing sa istruktura ang malaking bahagi ng garantiya sa Android: Activity na inilunsad para sa resulta ang screen, ibinabalik ng platform ang bawat exit ng screen na nakapagbukas, at ang listener ng module ang nag-iisang lugar kung saan nire-resolve ang promise kapag may dumating na resulta. Ang hindi sakop ng istruktura ay ang mismong launch (start na nagtha-throw, o teardown na nakikipag-unahan sa talon papuntang UI thread), kaya direktang hina-handle ng module ang dalawang kasong iyon. Gawin ang 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

// --- Ang native half ng host sa shell.navigateTo sa Android, at ang salamin ng
// ShellNavigationModule ng iOS. Binasa ng codegen ang parehong spec file at isinulat ang
// NativeShellNavigationModuleSpec sa com.host.specs; ine-extend ito ng class na ito, kaya dito
// napupunta ang `openNative` ng JavaScript.
//
// Ang istruktura ng Android ang gumagawa ng halos buong disiplina ng promise. Activity na
// inilunsad para sa resulta ang screen, kaya naghahatid ang platform ng resulta para sa bawat
// exit ng screen na nakapagbukas — ang Done button, ang system back gesture — at ang
// onActivityResult ang nag-iisang lugar kung saan nire-resolve ang promise kapag may dumating
// na resulta. Ang hindi sakop ng istruktura ay ang mismong launch: start na nagtha-throw, o
// teardown na nakikipag-unahan sa talon papuntang UI thread. Direktang hina-handle ang dalawang
// kasong iyon sa code sa ibaba. ---
class ShellNavigationModule(reactContext: ReactApplicationContext) :
  NativeShellNavigationModuleSpec(reactContext) {

  // Hawak mula sa paglunsad ng Activity hanggang sa pagbalik nito. Isa-isa lang, ang katapat ng
  // isPresenting flag ng presenter sa iOS. Ginagalaw ang dalawang field mula sa native-modules
  // thread (openNative, invalidate) at sa UI thread (ang launch block, ang listener ng
  // resulta), kaya nasa loob ng synchronized(this) ang bawat mutation.
  private var pendingPromise: Promise? = null

  // Isang beses binabaligtad sa invalidate at hindi na ibinabalik: ang launch na naipila bago
  // ang teardown ay hindi dapat magbukas ng screen pagkatapos nito, dahil walang nakikinig at
  // baka mapunta ang resulta nito sa mas huling battle.
  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
        // Dumadaan dito ang bawat exit. May dalang JSON ang battle na natapos; walang dalang
        // data ang back gesture, at empty object ang tapat na sagot doon — binabasa ng party ang
        // nawawalang winnerUid bilang "walang nangyari".
        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) {
      // Walang mapaglulunsaran: mag-resolve na ngayon, sa halip na hayaang maghintay ang
      // tumatawag sa resultang hindi kailanman darating.
      promise.resolve("{}")
      return
    }
    synchronized(this) {
      if (pendingPromise != null || invalidated) {
        // May battle nang tumatakbo, o tanggal na ang module: parehong sagot.
        promise.resolve("{}")
        return
      }
      pendingPromise = promise
    }
    // Hindi binabasa ang nativeId: isang native flow lang ang umiiral ngayon, at
    // mangangailangan muna ng switch dito ang pangalawang registry row. Dumarating ang
    // openNative sa background queue; tumalon sa UI thread para ilunsad ang Activity sa halip
    // na ipagpalagay na ligtas maglunsad mula sa queue na iyon.
    activity.runOnUiThread {
      synchronized(this) {
        // Puwedeng tumakbo ang invalidate sa pagitan ng pagpila sa itaas at ng block na ito:
        // resolved na ang promise pagsapit doon, at kapag naglunsad pa rin, magbubukas ng
        // screen na walang nakikinig.
        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) {
          // Walang iniiwang Activity na maghahatid ng resulta ang launch na nag-throw.
          // Mag-resolve na ngayon, o maghihintay ang await sa screen na hindi naman nabuksan.
          pendingPromise?.resolve("{}")
          pendingPromise = null
        }
      }
    }
  }

  override fun invalidate() {
    // Kapag nag-dev reload o na-teardown ang host habang may bukas na battle, maghihintay na
    // lang nang maghihintay ang await ng tumatawag: paalis na ang listener, kaya i-resolve ang
    // nakabinbing promise gaya ng gagawin ng exit na walang resulta, bago pa ito tuluyang hindi
    // na ma-resolve, at tanggihan ang kahit anong launch na nakapila pa.
    synchronized(this) {
      invalidated = true
      pendingPromise?.resolve("{}")
      pendingPromise = null
    }
    reactApplicationContext.removeActivityEventListener(activityEventListener)
    super.invalidate()
  }

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

Nagparehistro ang iOS gamit ang isang macro; isinusulat naman ng Android ang registration, sa isang package class na may nakalaang puwang sa template. Lumalabas ang com.host.specs base class sa unang Gradle build, kapag tumakbo ang codegen ng Android; kung magreklamo ang editor bago ang build na iyon, hindi pa lang tumatakbo ang codegen; hindi ito totoong error. Gawin ang 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

// --- Ang registration. Libre itong nakukuha ng iOS mula sa RCT_EXPORT_MODULE at autolinking;
// gusto ito ng Android nang nakasulat, sa dalawang bahagi. Binubuo ng getModule ang instance
// kapag hiningi ito ng registry sa pangalan, at dine-declare ng getReactModuleInfoProvider kung
// ano ang pangalang iyon at kung paano ito kumikilos — ang huling flag ang mahalaga rito, dahil
// iyon ang nagmamarka sa module bilang TurboModule sa halip na legacy bridge module.
//
// Idinadagdag ang package sa listahan ng MainApplication, sa puwang na iniiwang naka-comment ng
// React Native template para mismo rito. ---
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
        )
    )
  }
}

Sa MainApplication.kt, sa wakas ay ginagamit na ang naka-comment na puwang ng template:

PackageList(this).packages.apply {
  // Ang sariling mga native module ng host. Nahahanap ng autolinking ang mga package sa
  // node_modules; idinadagdag dito ang module na nasa mismong app, sa puwang na iniiwan ng
  // template para dito.
  add(HostNativePackage())
},

Ang na-fetch na screen ang unang Jetpack Compose ng repo, kaya dine-declare ng build ang compiler at ang mga library. Sa apps/host/android/build.gradle:

classpath("org.jetbrains.kotlin:kotlin-gradle-plugin")
// Sa Kotlin 2.x, hiwalay na plugin ang Compose compiler; ang native na Quick Battle screen
// lang ang nangangailangan nito sa buong app.
classpath("org.jetbrains.kotlin:compose-compiler-gradle-plugin:$kotlinVersion")

Sa apps/host/android/app/build.gradle, ang plugin, ang build feature, at ang mga dependency:

apply plugin: "org.jetbrains.kotlin.android"
apply plugin: "org.jetbrains.kotlin.plugin.compose"
// Jetpack Compose, para sa native na Quick Battle screen (ang katapat ng SwiftUI sa iOS).
buildFeatures {
    compose true
}
// --- Jetpack Compose, para sa QuickBattleActivity. Pini-pin ng BOM ang pamilya ng Compose mula
// sa iisang version, kaya walang sariling dala ang mga artefact na iyon; nasa labas ng BOM ang
// activity-compose at sarili nitong pini-pin. ---
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")

Idagdag ang Activity sa AndroidManifest.xml, hindi naka-export:

<!-- Ang native na Quick Battle screen. exported=false: walang nasa labas ng app na ito ang
     naglulunsad nito; ang TurboModule ng shell ang naglulunsad, gamit ang
     startActivityForResult. -->
<activity
  android:name=".QuickBattleActivity"
  android:exported="false"
  android:theme="@style/AppTheme" />

Ikumpara ang exit ng na-fetch na Activity sa Swift version: tumatawag ang Done sa deliverResult; walang tinatawagan ang system back button, at wala itong kailangan:

// Ang RESULT_OK na may JSON extra ang binabasa ng ActivityEventListener ng
// ShellNavigationModule. Hindi kailanman umaabot ang system back sa method na ito, at hindi
// kailangan: nagtatapos ang Activity nang walang resulta, at binabasa ng listener ang null na
// Intent bilang empty object.
private fun deliverResult(resultJson: String) {
  setResult(Activity.RESULT_OK, Intent().putExtra(EXTRA_RESULT_JSON, resultJson))
  finish()
}

Sa iOS, nagre-resolve ang bawat exit dahil may code para sa bawat isa; sa Android, dahil ibinabalik ng platform ang bawat exit ng screen na nakapagbukas, at may mano-manong code ang module para sa mismong launch. Ang buong hanay:

ExitiOSAndroid
Done pagkatapos ng battlenagre-resolve ng {"winnerUid": …}RESULT_OK extra, nagre-resolve ng {"winnerUid": …}
Close bago mag-battlenagre-resolve ng {}RESULT_OK extra, nagre-resolve ng {}
Swipe para isarahinaharang ng isModalInPresentationwala nito, full-screen na Activity
System backwalang ganoong gesture sa sheetnull na Intent, nagre-resolve ng {}
Pangalawang openNative habang may bukasnire-resolve ito ng re-entrancy guard ng {}nire-resolve ito ng pending-promise guard ng {}
Pumalya ang mismong launchnire-resolve ng nil check ng safety net two ang {}nire-resolve ng catch sa paligid ng start ang {}
Natanggal ang shell sa gitna ng battlenagre-resolve ang safety net one ng {}nagre-resolve ang invalidate() ng {} at tinatanggihan ang nakapilang launch

Kasama ang row ng pangalawang launch bilang defense in depth: hinaharang na ito sa UI ng in-flight flag ng party, na gagawin mamaya sa post na ito.

I-pin ang mga guarantee

Sakop ng kalahati ng table ang mga sitwasyong hindi mo kayang i-reproduce sa isang simulator run: launch na nagtha-throw, teardown na nakikipag-unahan sa talon ng thread, presentation na tinatanggihan ng UIKit. Sinusubok ang mga iyon ng dalawang native suite na na-fetch kanina.

Purong JVM tests ang Android suite: pinapatakbo ng ShellNavigationModuleTest.kt ang totoong module na may mga mock na pumapalit sa platform, at sinusubok nito ang promise sa walong magkakaibang path. Itinatala ng mock na Promise ang bawat resolve. Tumatakbo nang inline ang runOnUiThread ng mock na Activity, o kinukuha ito at pinapatakbo pagkatapos ng invalidate(); ganoon nire-reproduce ang karera ng teardown. Dalawang edit sa apps/host/android/app/build.gradle ang nagse-set up nito. Sa android block:

// Tumatakbo ang mga JVM unit test ng ShellNavigationModule gamit ang stub na android.jar ng SDK;
// mga default value sa halip na "not mocked" na throws ang nagpapahintulot na mabuo ang totoong
// Intent bilang no-op habang ang mga mock sa paligid ang may dala ng mga assertion.
testOptions {
    unitTests.returnDefaultValues = true
}

At sa dependencies:

// --- Mga JVM unit test ng bridge module: ang garantiya ng pag-resolve ng promise kapag pumalya
// ang launch, nag-teardown, at sa listener ng resulta. Naka-default ang Mockito 5 sa pag-mock
// ng finals, kaya napapalitan ang Activity at ang React context nang walang mga wrapper. ---
testImplementation("junit:junit:4.13.2")
testImplementation("org.mockito:mockito-core:5.14.2")
testImplementation("org.mockito.kotlin:mockito-kotlin:5.4.0")

Kailangan ng iOS suite ng kaunting ayos sa project, dahil hindi ang app ang host nito. Sa Xcode: File → New → Target → Unit Testing Bundle, pangalanang HostTests, at itakda sa None ang “Target to be Tested”; pagkatapos, piliin ang na-fetch na QuickBattlePresenterTests.swift at QuickBattle.swift at lagyan ng check ang HostTests sa Target Membership. Kung wala ang bagong bundle sa Test action ng Host scheme (Product → Scheme → Edit Scheme → Test), idagdag ito roon. Kino-compile ng bundle ang presenter nang direkta sa halip na i-load ang app, kaya hindi nangangailangan ang tatlong test nito ng React Native boot, dev server, o window hierarchy: walang host na puwedeng pagpresentahan, tinanggihang pangalawang presentation, at presentation na tinanggihan ng UIKit. Ang huling kaso ang nil-check safety net; may test na sumusubok dito sa halip na basta itong nakasulat sa isang comment. Sine-set up ng mga test ang bawat kaso sa pamamagitan ng pagpapalit sa hostProvider.

I-wire ang button

Nagsisimula sa slice ang bahagi ng party; nananatiling pribado ang dalawang bagong piraso, wala sa kahit anong contract, dahil walang nagdi-dispatch sa kanila sa labas ng party. Ang buong apps/party/src/partySlice.ts:

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

// --- Ang state ng party, buong pag-aari ng party app. Dala ng contract ang action creator at
// ang read shape; dala ng file na ito ang ibig nilang sabihin. Walang nag-i-import nito sa labas
// ng app na ito — nilo-load ito ng host bilang federated module sa boot, para sa side effect sa
// dulo. ---
export const partySlice = createSlice({
  name: 'party',
  initialState: { members: [] as PartyMember[], lastBattleWinnerUid: null as string | null },
  reducers: {
    // Pribado: walang ibang nagdi-dispatch ng remove, kaya hindi ito sumasama sa kahit anong
    // contract.
    remove(state, action: PayloadAction<string>) {
      state.members = state.members.filter(m => m.uid !== action.payload);
      // Hindi puwedeng manatiling huling panalo ang natanggal na member; magbabanggit ang
      // banner ng Pokémon na wala na sa party.
      if (state.lastBattleWinnerUid === action.payload) {
        state.lastBattleWinnerUid = null;
      }
    },
    // Pribado rin, at sinadya. Bumabalik ang panalo mula sa native screen na pine-present ng
    // HOST, pero sa sariling state ng party ito napupunta at walang nagdi-dispatch nito sa
    // labas ng app na ito — kaya wala ito sa kahit anong contract. Nasa seam ang mga type ng
    // params at resulta ng handoff dahil kailangan silang i-serialize ng host, na ibang dahilan
    // kaysa pagtawid.
    setLastBattleWinner(state, action: PayloadAction<string>) {
      state.lastBattleWinnerUid = action.payload;
    },
  },
  extraReducers: builder => {
    // Ang interaksiyong tumatawid. Nagdi-dispatch ang list app ng addToParty ng contract, at
    // nakikilala ito ng case na ito sa type string: binabasa ng addCase ang actionCreator.type
    // at inirerehistro ang reducer sa ilalim ng `party/add`, kaya ang pagkakasundo sa string na
    // iyon ang nagpapagana ng match, hindi ang identity ng creator object. Ang pagbabahagi ng
    // @pokedex/contracts bilang singleton ang pumipigil sa dalawang panig na isulat muli nang
    // hiwalay ang string na iyon, ang limit, at ang read shape.
    builder.addCase(addToParty, (state, { payload }) => {
      if (state.members.length >= MAX_PARTY) return; // nasa may-ari ang limit
      state.members.push(payload);
    });
  },
});

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

// Ang pag-import ng module na ito ang nagdadagdag ng reducer sa shared store. Ang rootReducer
// ang parehong object na ikinabit ng configureStore ng host — kaya gumagana ang injection mula
// sa app na hiwalay na binuo.
rootReducer.inject(partySlice);

Sinusuri ng dalawang sariling test ng party ang eksaktong hugis ng slice, kaya idinadagdag din sa kanila ang parehong field. Sa __tests__/partyStateReady.test.ts at __tests__/partySelfRecovery.test.tsx, ang assertion na toEqual({ members: [] }) ay nagiging:

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

Lumalaki ang PartyScreen.tsx sa apat na lugar. Una, ang mga import:

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';

Sa module level, ang lokal na read shape ng may-ari at ang minimum ng battle:

// Ang sariling view ng may-ari sa slice nito. Dala ng contract ang `members`, dahil binabasa
// ito ng mga module mula sa labas; dito lang binabasa ang huling panalo at wala nang iba, kaya
// nasa labas ito ng contract at idinadagdag sa shape nang lokal.
type PartyOwnShape = PartySliceShape & {
  party?: { lastBattleWinnerUid?: string | null };
};

// Dalawang kalahok ang minimum para sa battle. Nakasulat bilang constant dahil ang disabled na
// estado ng button at ang paalala sa ilalim nito ay dalawang basa ng parehong panuntunan.
const MIN_CONTESTANTS = 2;

Sa loob ng component, ang mga selector at ang 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;
// Ang nag-iisang tawag na ginagawa ng remote para makaabot sa native. Ang `shellNavigate` ang
// kabuuan ng alam ng app na ito tungkol sa kabilang panig: walang TurboModule import, walang
// native types, walang ideya na ang screen na binubuksan nito ay SwiftUI sa isang platform at
// Compose sa kabila. Nire-resolve ng host ang destination at sa kanya ang lahat ng kasunod.
//
// Ang await ang punto. Hindi ito bumabalik hangga't hindi tapos ang native flow, at napupunta
// ang ibinabalik sa sariling state ng app na ito sa pamamagitan ng sarili nitong action —
// nagsisimula at nagtatapos ang round trip sa loob ng party, kaya walang kahit ano tungkol sa
// battle na kailangang tumawid sa contract.
const [battleInFlight, setBattleInFlight] = React.useState(false);
const onQuickBattle = React.useCallback(async () => {
  if (battleInFlight) return;
  setBattleInFlight(true);
  try {
    // Binabasa ang theme sa sandali ng tawag, mula sa parehong NativeWind observable na
    // sini-subscribe-an ng bawat dark: class sa federation. Ipinapadala ito sa halip na
    // obserbahan dahil walang paraan ang native screen para mag-subscribe: hindi aabot dito ang
    // toggle habang bukas ang battle.
    const result = (await shellNavigate('QuickBattle', {
      members,
      colourScheme: colorScheme === 'dark' ? 'dark' : 'light',
    })) as QuickBattleResult | undefined;
    // Totoong resulta ang walang panalo, hindi failure: puwedeng isara ang screen nang hindi
    // nag-ba-battle, at nagre-resolve ang native side ng empty object kapag ganoon.
    if (result?.winnerUid) {
      dispatch(setLastBattleWinner(result.winnerUid));
    }
  } finally {
    setBattleInFlight(false);
  }
}, [battleInFlight, colorScheme, dispatch, members]);

Nadadagdagan ang render ng button, ng paalala nito, at ng banner, pagkatapos ng slot grid:

{/* Ang nag-iisang control ng handoff. Disabled kapag kulang sa dalawang member, dahil
    nangangailangan ng dalawang kalahok ang battle; sinasabi ng paalala sa ilalim kung aling
    panuntunan ito, sa halip na hayaang magpaliwanag mag-isa ang patay na button. */}
<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'
  }`}
  // Dine-declare ang 44pt na minimum, hindi minamana mula sa size variant, gaya ng pagde-declare
  // ng Add button ng detail: ang masusuri lang ng accessibility suite ay ang sinasabi ng control
  // tungkol sa sarili nito.
  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}

{/* Ang bumalik mula sa native, na nire-render ng app na may-ari ng state. */}
{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}

May isang pagbabagong madaling malampasan, at isang buong debugging session ang ginugol dito sa build na ito. Nasa isang module-level na React.memo ang mga slot na may laman, at walang binabago ang theme change sa kahit anong prop nila, kaya nilalaktawan ng memo ang subtree at naiiwan sa card ang mga style object ng lumang scheme: isang walang lamang puting parihaba. Kasama sa key ng slot ang scheme, kaya nagiging remount ng anim na maliliit na card ang isang toggle:

// Kasama sa key ang color scheme, at kailangan talaga. Walang binabago ang theme change sa
// kahit anong prop ng slot na ito, kaya nakikita silang magkapareho ng memo sa itaas at
// nilalaktawan ang subtree; naiiwan sa card ang mga style object na na-resolve ng styling
// runtime para sa lumang scheme at nagre-render ito bilang walang lamang puting parihaba. Ang
// paglalagay ng scheme sa key ang gumagawa sa toggle na remount ng anim na maliliit na card,
// habang patuloy ang memo sa trabaho nito sa loob ng isang theme. Hindi nagme-memoize ang
// Pokédex grid ng mga card nito, kaya hindi ito kailanman lumitaw doon.
<PartySlot
  key={`${member.uid}:${colorScheme}`}
  member={member}
  onOpen={openDetail}
  onRemoveMember={removeMember}
/>

Patakbuhin mo

Nagbago ang native code sa dalawang platform, kaya kailangan ng parehong platform ng totoong build, gaya ng mga screen ng post 4 at ng mga animation pod ng post 11. Sa pagkakataong ito, sa serye mismo galing ang na-compile na code. Tatlong dev server, bawat isa sa sariling terminal:

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

I-restart ang dev server ng host gamit ang --reset-cache kung tumatakbo na ito. Ang server na inandar bago ma-install ang contracts 3.3.0 ay patuloy na nagse-serve ng lumang resolution, at namamatay ang boot sa TypeError: undefined is not a function sa App.tsx: isang naka-cache na contracts build na walang registerShellNavigateHandler.

Pagkatapos, ang mga build, at kasama nila ang mga suite:

( 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

At ang mga native suite, papalitan ang pangalan ng simulator ng isa na naka-install sa iyo:

( 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' )

Magdagdag ng dalawang Pokémon, buksan ang Party tab, pindutin ang Quick Battle. Lalabas ang native sheet na may parehong mga card ng grid, maliban sa badge; bibilugan ng Battle ang panalo sa type color nito; magsasara ang Done; at sasabihin ng banner ang pangalan ng nanalo na dala ng promise. I-flip muna ang theme at susundin ito ng native screen, dahil ginagamit nito ang scheme na ipinadala ng tawag:

Ang round trip sa iOS: pinindot ang Quick Battle sa federated na Party tab, lumitaw ang native na SwiftUI sheet na may NATIVE iOS badge at ang party na naka-render bilang mga design-system card, binilugan ng Battle ang panalo, nagsara ang Done, at ang banner ng Party tab ay nagsasabing Last battle winner

Ang badge lang ang palatandaan. Ganito nananatiling pareho ang design system sa dalawang panig ng boundary na walang bundle na tumatawid:

Ang native na Quick Battle sheet sa dark mode: dalawang member ng party na iginuhit sa itsura ng card ng design system, ang panalo na binilugan at may glow sa type color nito, sa ilalim ng purple na NATIVE iOS badge

Ngayon, sirain natin

Ngayong gumagana na ang round trip, sulit na makita nang isang beses ang panganib ng bridge. Pinapadaan ng presenter ang bawat exit sa settle-once wrapper nito, kaya ang pinakamalinis na paraan para sirain ito ay ang isang linyang inaasahan ng lahat ng path. Sa na-fetch na QuickBattle.swift, hanapin ang wrapper at i-comment ang huling tawag nito:

var settled = false
let settle: (String) -> Void = { resultJson in
  guard !settled else { return }
  settled = true
  isPresenting = false
  // completion(resultJson)   <- sirain natin: hindi kailanman mangyayari ang resolve
}

Mag-rebuild, buksan ang Quick Battle, mag-battle, pindutin ang Done. Normal na magsasara ang sheet, at pagbalik sa Party tab, “Battling…” ang nakasulat sa button habang buhay ang process: hindi kailanman bumabalik ang await, kaya hindi kailanman tumatakbo ang finally na naglilinis ng battleInFlight. Walang ginagawa ang pangalawang tap, hinaharang ng in-flight guard. Walang red box, walang log line, walang na-reject na promise. Walang nakatulong sa dalawang safety net: tumawag ang safety net one sa settle("{}") nito, pero nakataas na ang settled guard; at walang ginawa ang safety net two, dahil nagtagumpay ang presentation. Binabantayan ng mga net ang mga daan papasok sa wrapper, pero walang nagbabantay sa huling linya mismo ng wrapper. Sulit itong pag-isipan sandali: may hangganan ang defense in depth, at ang linya kung saan ito nagtatapos ang kailangang basahin nang pinakamaingat sa isang review.

Walang nag-crash, at iyon mismo ang problema.

May maliit na koleksyon ng mga tahimik na failure ang serye, at ito ang pinakatahimik. Nag-dispatch man lang sa store ang nawalang pag-add ng post 8 at kita ito sa devtools; walang iniiwang bakas ang isang ito, dahil walang nangyari. Hindi magagaya ng Android ang parehong fault: alisan ng laman ang deliverResult, at bumabagsak pa rin sa onActivityResult ang system back gesture na may null na Intent at nagre-resolve ng {}. Nakita natin mismo ang asymmetry ng exit table. Ibalik ang linya at mag-rebuild.

Ang binuo mo, at ang susunod

Humingi ng native screen ang isang federated remote at nakakuha nito, nang hindi natututo ng kahit ano tungkol sa native. Naglalantad ang contract ng isang function na nagbabalik ng promise, sa ibabaw ng routing table na may isang entry; ipinapatupad ito ng host gamit ang TurboModule na nagdadala ng JSON sa dalawang direksyon at nagpe-present ng SwiftUI sa isang platform at ng Compose Activity sa kabila, na parehong gumagamit ng mga token at theme ng design system. Bumabalik ang uid ng panalo sa promise at napupunta sa state ng party sa pamamagitan ng pribadong action, walang tinatawid na kahit anong contract action, gaya ng hula ng mga panuntunan sa pagmamay-ari ng post 7.

Ang mga limitasyon: one-way ang bridge, kaya hindi pa makakahiling ang native screen sa shell na mag-navigate kahit saan, at hindi dumadaan sa table ang mga deep link; parehong sinadyang iwanan sa labas at ito ang mga susunod na idaragdag sa bridge. Nasa params ang theme, kaya aabot ang toggle sa gitna ng battle sa susunod na battle, hindi sa kasalukuyan.

Susunod: isang production bundle at ang unang federated release artefact.

Mga Sanggunian

Warren de Leon
Warren de Leon

Software Engineering Manager. Pinakahuling pinamunuan ang Mobile Platform team sa Hargreaves Lansdown. Sumusulat tungkol sa engineering leadership, React Native, at pagbuo ng magagandang team.

Tingnan ang profile