Isang federated remote na nagdadagdag ng sarili nitong slice sa isang shared store habang tumatawid dito ang isa pang remote

Client state sa kabila ng seam: mga remote na may dalang sariling slice sa React Native

Nagtapos ang post 6 sa isang tapat na limitasyon: isara ang app, buksan ulit, at walang natitira sa lahat ng ginawa mo. Walang party, walang mga paborito, walang alaala. Server state ang lahat ng tumawid sa seam hanggang ngayon: data na pag-aari ng PokéAPI, nakaimbak sa isang cache na pinaghahatian ng lahat ng module. Ang app mismo, wala pa ring pag-aari.

Bibigyan ito ng post na ito ng isang bagay na maaari nitong ariin: ang party ng anim. Ang client state ay walang server sa likod at walang cache na maire-refetch. Nasa isang Redux slice ito, at sa ilalim ng federation, ang slice ang nagtatanong ng tanong na paikot-ikot sa buong serye: sino ang may-ari nito, at paano ito hahawakan ng ibang feature nang hindi hinahawakan ang may-ari? Sumagot ang sanaysay tungkol sa pagmamay-ari gamit ang mga prinsipyo. Sasagot ang post na ito gamit ang code.

Ang hugis na bubuuin natin:

Isang ideya ang hawakan sa isip: sa lahat ang cache, pero ang isang client-state slice ay may eksaktong isang may-ari. Ang party app ang may-ari ng party. Lahat ng kailangan ng ibang module (isang action, isang hugis ng pagbasa, isang cap) ay tumatawid sa contract, at malapit sa dulo magdi-dispatch tayo papunta sa slice bago pa mag-load ang may-ari nito, sinasadya, para makita kung ano ang gagawin ng Redux sa isang action na walang nakikinig.

Magpatuloy mula sa sarili mong code ng post 6 kung sinabayan mo ang pagbuo. Kung hindi, magsimula sa huling estado nito:

git clone https://github.com/warrendeleon/react-native-module-federation
cd react-native-module-federation
git checkout post-06-shared-store

Sino ang may-ari ng client state?

Tatlong klase ng state na ang nasa app na ito, at magkakaiba ang may-ari ng bawat isa.

Ang server cache ay sa lahat. Binuo ito ng post 6: isang baseApi sa contract, isang store sa host, mga endpoint na ini-inject ng may-ari ng data. Walang app na may-ari ng isang cache entry. Sa PokéAPI ang data at kopya lang ang hawak ng cache, kaya puwede itong basahin ng kahit anong module.

Ang private state ay may isang may-ari at nananatili sa loob nito. Kung sinong miyembro ang hawak ng party, paano gumagana ang pagtanggal, ano ang ibig sabihin ng cap: lahat iyan ay usapin ng party app, sa isang slice na walang ibang nag-i-import.

Ang interaksiyong tumatawid ang makitid na guhit sa pagitan nila. Kailangan ng Pokédex na magdagdag ng miyembro sa isang party na hindi nito pag-aari, at gusto ng header nitong ipakita ang “My Party 3/6” para sa state na hindi nito nakikita. Ang mga pagtawid na iyon ay tina-type, pinapangalanan, binibigyan ng bersyon at inilalagay sa contract, dahil totoo rin dito ang patakaran ng sanaysay sa pagmamay-ari: hindi kailanman nagdedepende ang mga app sa isa’t isa. Nagdedepende ang mga app sa mga contract.

Ang taxonomy na iyan ang nagpapasya ng lahat ng iba pa sa post. Ang susunod ay bawat hilera nito, binuo.

Ang pader, at lumipat ang reducer

Kailangang sumali ng slice ng party sa tumatakbong store, at may API ang Redux Toolkit (RTK) para mismo dito: gumagawa ang combineSlices ng reducer na may inject method, at ang slice na na-inject sa runtime ay nagsisimulang mag-reduce mula sa sandaling iyon. Kaya kailangang tawagin ng party app ang inject sa mismong reducer object na iwina-wire ng store ng host.

Inline na binuo ng host ng post 6 ang reducer na iyon:

// apps/host/src/store.ts, sa post 6
export const store = configureStore({
  reducer: combineSlices(baseApi),
  middleware: getDefaultMiddleware => getDefaultMiddleware().concat(baseApi.middleware),
});

Walang maaabot. Ang reducer ay isang expression sa loob ng source ng host, hindi kailanman na-export, at kahit may export hindi rin makakatulong: hindi nag-i-import ng app ang mga app. Ang kopya ay mas masama pa sa walang silbi: ang combineSlices(baseApi) sa party app ay gumagawa ng pangalawang reducer na walang store na nagpapatakbo, at ang pag-inject doon ay walang binabago sa screen.

Ito ang argumento ng identity mula sa post 6, sa pangatlong pagkakataon. Lumipat ang baseApi sa contract dahil ang consumer ay makakapag-inject lang ng mga endpoint sa parehong instance na iwina-wire ng store. Lumilipat ang root reducer sa parehong dahilan, isang file lang ang layo. packages/contracts/src/store.ts:

import { combineSlices } from '@reduxjs/toolkit';
import { baseApi } from './api';

export const rootReducer = combineSlices(baseApi);

At pumapayat ang store ng host sa isang import:

// apps/host/src/store.ts
import { configureStore } from '@reduxjs/toolkit';
import { baseApi, rootReducer } from '@pokedex/contracts';

export const store = configureStore({
  reducer: rootReducer,
  middleware: getDefaultMiddleware => getDefaultMiddleware().concat(baseApi.middleware),
});

Ang host pa rin ang may-ari ng store: ang middleware, ang Provider, ang wiring. Ang contract na ngayon ang may-ari ng reducer tulad ng pagiging may-ari nito ng api instance. Dahil singleton ng federation ang contracts, ang rootReducer ay iisang object sa buong runtime, at ang slice na na-inject ng isang app na binuo buwan pagkatapos ng shell ay lumalapag sa reducer na pinapatakbo na ng shell.

Dala ng contract ang pagtawid

Ngayon ang gitnang hilera ng taxonomy. Ang mga interaksiyong tumatawid ng app boundary ay pupunta sa bagong packages/contracts/src/party.ts, sinadyang maikli:

import { createAction, nanoid } from '@reduxjs/toolkit';

export const MAX_PARTY = 6;

export interface PartyMember {
  uid: string;
  id: number;
  name: string;
  spriteUri: string;
}

export const addToParty = createAction(
  'party/add',
  (member: Omit<PartyMember, 'uid'>) => ({ payload: { ...member, uid: nanoid() } }),
);

export const partyStateReady = createAction('party/stateReady');

export interface PartySliceShape {
  party?: { members: PartyMember[] };
}

Ang addToParty ang nag-iisang action na dini-dispatch ng anumang nasa labas ng party. Ang contract ang may-ari ng hugis ng action; ang reducer ng party ang may-ari ng ibig sabihin nito. Ang prepare callback ang nagtatatak sa bawat miyembro ng nanoid() (kasama na sa RTK, walang bagong dependency) para manatiling pure ang reducer at manatiling magkakaiba ang dalawang kopya ng parehong Pokémon. Pinapayagan ang mga duplicate sa disenyo: ang party ng anim na Magikarp ay isang lehitimong desisyon sa buhay, at ang uid ang nagbubukod sa kanila kapag tinanggal ang isa.

Nakaupo ang MAX_PARTY sa seam dahil bawat surface na sumasalamin sa patakaran ay kailangang magbasa ng parehong numero: isang naka-disable na button sa isang app, isang bilang sa isa pa, ang guwardiya sa reducer ng may-ari.

Ang partyStateReady ang kakaiba sa grupo: walang reducer case na humahawak dito, at walang nagbabago sa party kapag na-dispatch ito. Nariyan ito para sa boot sequence, at ipinapakita ng seksyon tungkol sa pag-load ng state module ang iisang kapaki-pakinabang na ginagawa nito.

Ang PartySliceShape ang panig ng pagbasa, at ang opsyonal na marka ang disenyo, hindi depensa. Ang slice ay ini-inject sa runtime ng isang module na hindi kontrolado ng nagbabasa, kaya sa sandaling magbasa ang isang module mula sa labas, maaaring wala pa ang state.party. Sumusulat ang nagbabasa ng s.party?.members ?? [] at nagre-render ng tapat na sagot sa parehong kaso. May mga typed na alternatibo (kayang isingit ng withLazyLoadedSlices ng RTK ang posibleng-wala na slice sa pamamagitan ng declaration merging), pero itinuturo ng maluwag na hugis ang sitwasyon sa halip na itago, at ang sabotahe sa Ngayon, sirain natin ay nakadepende sa pag-unawa mo rito.

Pansinin ang wala. Magkakaroon din ang party ng remove action, at hindi ito lumalabas sa anumang contract, dahil walang ibang nagdi-dispatch nito. Dala ng contract ang tumatawid, wala nang iba; ang entry na walang kumokonsumo ay isang pananagutan na may bersyon.

Parehong additive ang dalawang bagong file, kaya minor ang bersyon; dala ng tag ang 3.1.3, ang minor kasama ang parehong hardening patch na natanggap ng linyang 3.0.x. I-publish:

cd packages/contracts
npm install && npm publish
+ @pokedex/contracts@3.1.3

Parehong nasa ^3.0.0 ang host at ang list, at dito mas mahalaga ang lockfile kaysa sa caret. Walang binabago ang karaniwang npm install: nakapako ang lockfile sa 3.0.3, at iginagalang ng install ang lockfile kahit tatanggapin ng caret ang 3.1.3. Ang pagpapasulong ng caret ay may sariling utos:

( cd apps/host && npm update @pokedex/contracts )

Walang pag-edit ng package.json, isang linya ng lockfile ang gumalaw. Ang pin ay ginagalaw lang kapag tumawid ang consumer ng major. May isang consumer na malapit nang gumawa niyan.

Ang may-ari

Ang party app ang naging control group ng serye: contract sa ^1.0.0, detail sa ^1.0.0, walang access sa store, isang static na grid ng anim na bakanteng puwesto. Tinatapos ng pagpapalaki ng slice ang lahat ng iyan, at ang pag-adopt ay isang sinadyang pag-edit dahil ang dalawang major ay totoong layo. apps/party/package.json:

"@pokedex/contracts": "^3.1.0",
"@pokedex/detail": "^3.1.0",
"@reduxjs/toolkit": "^2.12.0",
"react-redux": "^9.3.0"

npm install, at agad nasisira ang build, sa compiler at hindi sa runtime. Minomount pa rin ng stack ng party ang detail screen sa paraang 1.0.0: import PokemonDetailScreen from '@pokedex/detail', diretso sa component=. Wala nang default export ang 3.x na package, kaya ang import na iyon ay nagre-resolve na ngayon sa namespace object ng package, at hindi na pumapasa sa type check ang mount:

error TS2322: Type 'typeof import(".../@pokedex/detail/dist/index")' is not
assignable to type 'ScreenComponentType<PartyParamList, "PokemonDetail"> | undefined'.

Tatlong major ng detail package ang lumipas sa paligid ng app na ito habang hindi ito nakatingin. Ang ine-export ng 3.x ay ang may pangalang PokemonDetailView, isang view na humihingi ng data bilang props, kaya pinipilit ng compile error ang party na isulat ang parehong walong-linyang container na isinulat ng list app sa post 6. Nangangailangan ng data ang container, at magiging sarili nitong pader iyon sa Kailangan ng party ng sariling data. Ang mga ganitong kadena ang totoong halaga ng pagkahuli sa bersyon: hindi crash sa production, kundi salansan ng mga pader sa araw na sa wakas ay nag-adopt ka.

Ang slice ang puso ng post, at kasya ito sa isang screen. apps/party/src/partySlice.ts:

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

export const partySlice = createSlice({
  name: 'party',
  initialState: { members: [] as PartyMember[] },
  reducers: {
    // Private: nobody else dispatches remove, so it ships in no contract.
    remove(state, action: PayloadAction<string>) {
      state.members = state.members.filter(m => m.uid !== action.payload);
    },
  },
  extraReducers: builder => {
    builder.addCase(addToParty, (state, { payload }) => {
      if (state.members.length >= MAX_PARTY) return; // the cap lives with the owner
      state.members.push(payload);
    });
  },
});

export const { remove } = partySlice.actions;

// Importing this module is what adds the reducer to the shared store.
rootReducer.inject(partySlice);

Dalawang bagay ang may dala ng disenyo. Ang tumatawid na action ay nakatali sa pamamagitan ng extraReducers: itinutugma ng party ang addToParty ng contract. Maging eksakto tayo kung bakit ito gumagana sa mga app na magkahiwalay na binuo, dahil madaling maipagkamali kung ano ang may gawa nito. Binabasa ng builder.addCase ang actionCreator.type, at ang string na iyon ang ginagamit nitong susi para sa reducer, kaya ang kailangang magtugma ay ang party/add, hindi ang identity ng creator object. Magtutugma pa rin ang dalawang kopya ng contract. Ang pagdedeklara ng string, ng cap at ng hugis ng pagbabasa nang isang beses lang, sa halip na ulit-ulitin sa bawat panig, ang trabaho ng versioned na package: ang paglalathala ng contract ang pumipigil sa drift na iyon, singleton man o hindi. Kung saan may saysay ang runtime singleton identity ay sa mga object, ang rootReducer at ang baseApi: dumadapo lang ang isang injection sa store na iwina-wire ng host kung parehong instance ang hawak ng dalawang panig.

At ang guwardiya ng cap ay narito, sa may-ari. Inilalathala ng contract ang numero; ang may-ari lang ang nagpapatupad nito. Ang dispatcher na nakalimutang i-disable ang button nito ay hindi pa rin makakapagpasok ng pampitong miyembro.

Ang huling linya ang mekanismo. Tinatanggap ng inject ang slice mismo (ginagamit ng createSlice ang name bilang default na reducerPath, kaya ang state ay naka-mount sa state.party, kasya sa PartySliceShape), at ang pag-import ng module ang nagsasagawa ng injection. Ginagawa nitong state module ang slice: isang federated module na ang halaga ay ang side effect nito. Inilalantad ito ng bundler config ng party katabi ng stack:

exposes: {
  './PartyStack': './src/PartyStack.tsx',
  './partySlice': './src/partySlice.ts',
},

Sumasali rin ang party sa state trio sa shared map nito: ang @reduxjs/toolkit at react-redux na may version na idineklarang mano-mano na kailangan ng bawat package na may exports map sa config na ito, ang @pokedex/contracts bilang singleton, walang eager, eksaktong tulad ng deklarasyon ng list app. Parehong patakaran ng post 6: ang host ang nagbibigay ng mga kopya, ang mga remote ang kumokonsumo.

Isang obserbasyon sa dev loop, dahil may binabantayang panganib ang RTK dito: sa papel, ang muling pagpapatakbo ng module na ito ay sumusubok magpasok ng function na may bagong identity sa dati nang reducerPath, at tumatangging magpalit ang inject ng RTK (console error sa development; buhay pa rin ang orihinal na reducer). Sa setup na ito gamit ang Re.Pack, nananatiling teorya ang panganib: ang pag-edit sa file ng slice ay nagre-reload ng buong app sa halip na mag-hot-swap ng module, kaya bagong store ang makukuha mo sa halip na dobleng injection. Sulit malaman kung alin sa dalawa ang ginagawa ng stack mo bago magtiwala sa alinman.

May state nang ire-render, kaya nagiging screen na ang pansamantalang tab. Ang mga interesanteng linya ng PartyScreen.tsx:

import { useDispatch, useSelector } from 'react-redux';
import { MAX_PARTY, type PartySliceShape } from '@pokedex/contracts';
import { remove } from './partySlice';

const members = useSelector((s: PartySliceShape) => s.party?.members ?? []);

Nire-render ng mga punong puwesto ang sprite, ang pangalan, at isang kontrol sa pagtanggal na nagdi-dispatch ng remove(member.uid); pinapanatili ng mga bakante ang putol-putol na gilid hanggang anim; binibilang ng header ang {members.length}/{MAX_PARTY}; ang pagpindot sa miyembro ay nagtutulak ng PokemonDetail na may { id }, ang parehong DetailParams ng post 5, walang bagong field. Pansinin na binabasa ng may-ari ang sarili nitong state sa pamamagitan ng maluwag na hugis. Kahit dito, sulit ang opsyonal: maaaring maunahan ng unang render ang unang action papasok sa store, at ang injection ay nagrerehistro ng reducer pero iniiwang undefined ang state.party hanggang dumating ang susunod na action.

Naglo-load ang mga state module sa boot

Nai-inject ang slice kapag na-import ang module nito. Sa ngayon iisang bagay pa lang ang nag-i-import nito: ang screen ng party, na nag-i-import din ng remove. Ibig sabihin, nandiyan lang ang slice pagkatapos buksan ng user ang Party tab, at malapit nang mag-dispatch ang Pokédex papunta rito nang mas maaga pa. May kailangang mag-load ng state module nang maaga, at iisang kalahok lang ang buhay sa boot sa lahat ng landas: ang shell. apps/host/App.tsx, sa loob ng isang effect:

// Screens load on demand; state modules load at boot.
useEffect(() => {
  import('partyApp/partySlice')
    .then(() => store.dispatch(partyStateReady()))
    .catch(err => console.warn('party state module failed to load', err));
}, []);

Dineklara na ng host ang partyApp sa remotes map nito, kaya wala itong idinadagdag na coupling na wala pa. Pinapagana nito ang import at hindi nagtatago ng reference sa laman ng module: tina-type ng ambient declaration ang module bilang walang laman, dahil ini-import ito ng host para sa side effect at wala itong pakialam sa anumang nasa loob. Nananatiling lazy ang mga screen, dahil walang gastos ang pagpapaliban ng screen na hindi pa binubuksan ng user.

Ang pag-load sa boot ay isang pananguna, hindi isang garantiya. Tumatawid ang chunk sa network, at walang pumipigil sa mabilis na daliri na maabot ang Add button bago ito dumating. Ang party/add na na-dispatch sa store na walang reducer para rito ay naglalaho nang walang bakas: walang babala, walang error, dahil binabalewala ng Redux ayon sa disenyo ang aksyong walang tumatanggap. Kaya ginagawang nakikita ang pag-resolve, at dito nagiging kapaki-pakinabang ang partyStateReady. Pinapalitan ng inject() ang isang entry sa reducer map at muling binubuo ang pinagsamang reducer, pero hindi ito kailanman nagdi-dispatch, kaya nananatiling undefined ang state.party hanggang sa dumaan ang susunod na aksyon sa bagong reducer. Ang marker ang aksyong iyon: dini-dispatch ito ng .then sa mismong sandaling mag-resolve ang module, lumilitaw ang state.party, at muling nagre-render ang bawat subscriber na nasa lugar na ang slice.

Isinasara ng panig ng pagsulat ang siklo doon mismo: dini-disable ng container sa Dumarating ang write bilang prop ang Add habang undefined ang s.party. Ang pindot na hindi puwedeng mangyari ay dispatch na hindi puwedeng mawala.

Obserbasyon ang pagkakalagay sa effect, hindi diagnosis. Sa module scope, gumagawa ang import na ito ng Can't perform a React state update on a component that hasn't mounted yet sa ilang cold start at sa iba naman hindi, at ang paglipat nito sa isang effect ang nagpawala sa babala sa paulit-ulit na cold start. Kung ano ang gumagawa ng update na iyon ay hindi naipako ng post na ito: hindi ang rootReducer.inject() ang salarin, dahil pinapalitan nito ang isang entry sa reducer map at muling binubuo ang pinagsamang reducer nang hindi nagdi-dispatch o tumatawag ng replaceReducer, kaya wala itong inaabisuhan kapag mag-isa. Hangga't hindi natutunton ang update pabalik sa pinagmulan nito, ituring ang pagkakalagay bilang lokal na obserbasyon: ang effect ang ayos na nagpatahimik sa babala, at ang effect ang inaasahan ng React na kalagyan ng side effect.

Walang naghihintay sa import na iyon, kaya hindi kayang harangin ng bumagsak na party server ang boot. Sulit gawin ang pagsubok na iyan sa halip na pagkatiwalaan. Ihinto ang dev server ng party at i-cold-start ang app: iuulat ng federation runtime ang bigong manifest fetch nang malakas sa development ([ Federation Runtime ]: Failed to get manifest. #RUNTIME-003), at tuloy-tuloy ang shell. Nagre-render ang Pokédex, nagbabasa ang bilang ng tapat na 0/6 sa pamamagitan ng maluwag na hugis, nananatiling naka-disable ang Add button dahil hindi kailanman nag-fire ang partyStateReady, at tumatakbo ang app nang walang slice, na siyang mismong estado na idinisenyo ng PartySliceShape na ilarawan.

Isang detalye mula sa naobserbahang takbo: ang import ay nagse-settle nang hindi nagre-reject, kaya hindi kailanman tumatakbo ang .catch sa pagkabigong ito; kinokontrol ito ng runtime at inuulat nang mag-isa. Nananatili ang catch, isang linyang nagbabantay sa landas ng rejection para hindi kailanman mauwi sa unhandled rejection ang bigong pag-load.

Dumarating ang write bilang prop

Ang panig ng Pokédex sa pagtawid ay nagsisimula sa detail view, at ang detail view ay isang naka-install na component library: ang nag-iisang lugar kung saan hindi dapat ilagay ang write. Iginuhit ng sanaysay sa pagmamay-ari ang linyang ito: ang shared component ay nagre-render ng ibinibigay dito; ang write na tumatawid ng domain boundary ay iwina-wire ng consumer. Kaya ang @pokedex/detail 3.1.0 ay isang additive minor na may tatlong opsyonal na props:

export interface PokemonDetailViewProps {
  pokemon?: PokemonDetail;
  loading: boolean;
  error: boolean;
  onRetry: () => void;
  onAddToParty?: () => void;
  addDisabled?: boolean;
  addLabel?: string;
}

Nagre-render ang view ng button kapag inabutan ito ng consumer ng onAddToParty at walang nire-render kapag hindi. Wala pa rin itong ini-import na store, contract, o action creator. Tumatawid ang write ng domain boundary, kaya dumarating ito bilang callback at umaalis bilang isang tap.

Iwina-wire ng container ng list app ang tatlo:

function PokemonDetailRoute({ route }: { route: { params: DetailParams } }) {
  const { data, isLoading, isError, refetch } = useGetPokemonDetailQuery(route.params.id);
  const dispatch = useDispatch();
  const members = useSelector((s: PartySliceShape) => s.party?.members);
  const partyReady = members !== undefined;
  const count = members?.length ?? 0;
  const full = count >= MAX_PARTY;
  return (
    <PokemonDetailView
      pokemon={data}
      loading={isLoading}
      error={isError}
      onRetry={refetch}
      onAddToParty={() =>
        data && dispatch(addToParty({ id: data.id, name: data.name, spriteUri: data.spriteUri }))
      }
      addDisabled={full || !partyReady}
      addLabel={full ? 'Party is full' : 'Add to party'}
    />
  );
}

Basahin kung ano ang ginagawa ng tap na iyon. Ang screen na pag-aari ng Pokédex team ay nagdi-dispatch ng action na pag-aari ng contract, at napupunta ang action na iyon sa reducer na pag-aari ng party team. Tatlong panig, walang nag-i-import ng code ng iba. Binabasa ng disabled state ang parehong MAX_PARTY na ginagamit ng reducer bilang guwardiya, kaya hindi maghihiwalay ang button at ang cap. Pinipigil din nito ang button hangga’t wala pa ang state.party, kaya ang nag-iisang dispatch na puwedeng mawala ay iyon mismong hindi kailanman puwedeng gawin. At nakukuha ng header ng Pokédex ang bilang nito sa parehong pagbasa:

const partyCount = useSelector((s: PartySliceShape) => s.party?.members.length ?? 0);
// ...
<Text style={styles.partyCount}>My Party {partyCount}/{MAX_PARTY}</Text>

Walang ine-edit na version pin ang list app para sa lahat ng ito: nasa ^3.0.0 ito sa parehong package, at ang npm update @pokedex/contracts @pokedex/detail ang nagpapasulong ng dalawang caret sa mga bagong minor. Ang nahuling app lang ang gumalaw ng package.json nito. Ito ang caret na gumagana ayon sa disenyo: dumarating ang minor kapag hiningi; nangangailangan ang major ng sinadyang pag-edit.

Ang container ng party ang isa pang consumer ng parehong view, at wala itong iwina-wire sa tatlong props. Ang Pokémon na binuksan mula sa loob ng party ay walang ipinapakitang Add button:

Ang detail screen na binuksan mula sa Party tab: ang parehong shared view na walang Add button, dahil walang iwina-wire na write ang container ng party

Isang view, dalawang consumer, isa sa kanila ang nagwa-wire ng write. Ang screenshot na iyan ang component rule ng sanaysay sa pagmamay-ari, tumatakbo.

Kailangan ng party ng sariling data

Ang container sa panig ng party ang pader na ipinangako kanina. Ang pagpindot sa miyembro ng party ay nagtutulak ng PokemonDetail sa loob ng stack ng party, at kailangan ng container sa likod nito ang getPokemonDetail, na nasa list app, na hindi maaaring i-import ng party. Nagdedepende ang mga app sa mga contract, hindi kailanman sa isa’t isa, at ito ang unang pagkakataon na may nakikitang gastos ang patakaran.

Ang nakatuksong labasan ay isang shared data package: @pokedex/data, may-ari ng endpoint na kailangan ng dalawang app. Buburahin nito ang duplikasyon, at para sa dalawang feature ng iisang team, maaaring tama pa nga. Ang presyo ang inilatag ng sanaysay sa pagmamay-ari: inilalagay ng shared package ang data access ng party sa release train ng ibang team. Hindi pinipilit ng mga compatible na release ang sinuman na gumalaw, pero sa araw na kailanganin ng party ang isang ayos sa package na iyon, o may dumating na breaking version, naghihintay ang petsa ng paglabas ng party sa review at release ng ibang team. Hindi binibili ng dalawampung linya ng query definition ang ganoong coupling. Sinusulat ng party ang sarili nitong apps/party/src/detailApi.ts na may parehong pangalan ng endpoint, parehong queryFn, at parehong parsePokemonDetail mula sa contract, sinadyang kinopya:

const detailApi = baseApi.injectEndpoints({
  endpoints: build => ({
    getPokemonDetail: build.query<PokemonDetail, number>({
      // byte for byte, the list app's definition
    }),
  }),
});

export const { useGetPokemonDetailQuery } = detailApi;

Dalawang app na ngayon ang nag-i-inject ng endpoint na nagngangalang getPokemonDetail sa iisang baseApi, at napapansin ito ng RTK. Patakbuhin ang app, buksan ang Party tab, at nagpi-print ang development console:

called `injectEndpoints` to override already-existing endpointName getPokemonDetail without specifying `overrideExisting: true`

Kung iiwan mong ganoon, nilalaktawan ng RTK ang pangalawang injection at pinapanatili ang una: maingay sa development, tahimik sa production, kung saan tinatanggal ito sa compile. Idinedeklara ng build na ito ang doble sa halip: parehong pumapasa ang dalawang app ng overrideExisting: true, nawawala ang console error, at ang huling injection ang nananalo. Sa alinmang paraan, ang pagkakasunod ng pag-load ang nagpapasya, at hindi kontrolado ng alinman sa dalawang app ang pagkakasunod na iyon. Iyon ang tapat na dahilan kung bakit kailangang magkapareho, hindi lang magkamukha, ang dalawang definition.

Hindi nagbabago ang cache: ang iisang pangalan ng endpoint ay nangangahulugang iisang hanay ng entry, kaya ang pagbukas kay Bulbasaur mula sa Pokédex at pagkatapos mula sa party ay isang fetch lang sa kabuuan. Ang bitag ay ang drift: deklarado man o hindi, tahimik na nananalo para sa dalawa ang kopyang pinapaboran ng pagkakasunod ng pag-load, at kapag napatahimik na ng overrideExisting ang console, wala nang babalang maaaring balewalain. Kaya ang panuntunan: panatilihing byte-for-byte na magkapareho ang mga kopya, ideklara ang banggaan para makitang sinadya ito, o bigyan ng sarili mong pangalan ang endpoint mo.

Ngayon, sirain natin

Ang pahayag na aatakihin: ang bantay sa state.party ang namamagitan sa mabagal na chunk at nawawalang pindot. Tanggalin muna ang bantay (ibalik ang addDisabled ng container sa payak na full), tapos i-comment ang boot import:

// useEffect(() => {
//   import('partyApp/partySlice')
//     .then(() => store.dispatch(partyStateReady()))
//     .catch(err => console.warn('party state module failed to load', err));
// }, []);

Mag-relaunch nang malamig, at huwag buksan ang Party tab. Mahalaga iyan: nag-i-import ang PartyScreen ng remove mula sa file ng slice, kaya ang pagbisita sa tab ay nag-i-inject ng slice bilang side effect at nagtatago ng bug. Ang user na diretso sa Pokédex ang paraan ng pag-reproduce nito.

Pindutin si Bulbasaur. Pindutin ang Add to party. Lumapag ang tap, nagbasa ang bilang ng 0/6, at wala nang ibang nangyari. Walang babala, walang error, walang linya sa console. Ang pagsira sa post 6 ay may spinner man lang na matititigan mo. Dito, umabot ang dispatch sa store, walang nakitang rehistradong reducer ang store para sa party/add, at ginawa ng Redux ang ginagawa ng Redux sa action na walang katugma: wala, ayon sa disenyo. Nahulog ang tap ng user sa isang kawalan na dating isang error.

Ngayon, sabihin natin nang malakas ang mas matalim na bahagi. Kahit wala ang boot import, mabubuo rin ang slice sa bandang huli: isang pagbisita sa Party tab ang mag-i-inject nito, at gagana ang bawat add pagkatapos noon. Kaya ang pagkabigo ay may mas makitid at mas pangit na hugis: bawat add na ginawa bago nagkataong buksan ng user ang tab ng may-ari ay tahimik na nawawala, at nagre-reproduce ito para sa ilang user at hindi kailanman para sa iba, depende sa pagkakasunod ng tab. At ang boot import nang mag-isa ay nagpapaliit lang ng window, mula sa “hanggang buksan ng user ang Party tab” pababa sa “hanggang dumating ang chunk”. Hindi nito ito maisasara, dahil walang utang sa iyo ang network.

Trabaho ng bantay ang pagsasara: ibalik ang bantay, iwang naka-comment ang import, at ipapakita ng parehong cold start ang Add button na naka-disable lang. Pangit, nakikita, at tapat: isang tap na hindi puwedeng mangyari sa halip na isang tap na nagsisinungaling.

Ibalik ang import, mag-relaunch, at gumigising ang button sa mismong sandaling mag-fire ang partyStateReady; itinataas ng parehong tap ang bilang sa 1/6. Tatlong piraso ang pattern: ang boot import para sa pang-una, ang marker para lumitaw ang slice, at ang bantay para sa siwang na hindi kayang isara ng dalawa.

Tapusin nang eksakto sa huling estado ng post na ito. Inilalathala ng gabay ang mga file na sumusuporta sa build; nasa tag ang mga manifest, config, test at maliliit na pagbabago. Para maging byte-for-byte na katulad ng tag ang tree mo, i-copy ang reference tree sa ibabaw ng sa iyo. Ang file na tama mong naisulat ay ino-overwrite ng kaparehong laman, at pinupunan ng pag-copy ang hindi inilathala ng teksto:

npx degit@3.8.0 --force warrendeleon/react-native-module-federation#post-08-client-state /tmp/pokedex-ref-08
cp -R /tmp/pokedex-ref-08/. .

Ang pag-copy din ang paraan para mapatunayan ang behaviour: dala nito ang dalawang regression test na kailangan ng disenyo ng post na ito (nilakad ng party store ang window na vanish-inject-marker-add, at hinawakan ng Add gate ng list container ang pagpindot hanggang sa maging handa ang state), ang mga mock at ang Jest wiring na pinagpapatakbuhan nila, at ang mga ambient federation declaration na binuod ng gabay sa halip na ilathala.

Patakbuhin

Nakalathala at nakainstall na ang mga package, kaya ang takbo ay tatlong dev server at isang build sa simulator:

cd apps/list && npm run start:remote      # :8082
cd apps/party && npm run start:remote     # :8083
cd apps/host && npm start                 # :8081
cd apps/host && npm run ios

Lumalapag ang cold start sa Pokédex na may bilang na nagbabasa ng 0/6. Huwag itong basahin bilang patunay na gumana ang boot import: nagbabalik ng 0/6 ang maluwag na selector nakarehistro man ang slice o wala pa, at iyon mismo ang punto nito. Ang patunay ay ang pagiging enabled mismo ng Add button, dahil gumigising lang ito kapag nailabas na ng partyStateReady ang slice. Magdagdag mula sa isang detail, panoorin ang bilang na tumaas, at hanapin ang miyembrong nakaupo sa Party tab:

Ang add flow sa iOS: nagbabasa ang header ng Pokédex ng My Party 0/6, bumubukas ang detail na may Add to party button, lumalapag ang tap, tumataas ang bilang sa 1/6, at ipinapakita ng Party tab si Bulbasaur sa unang puwesto

Magpatuloy magdagdag hanggang anim at darating ang cap mula sa dalawang panig nang sabay: ang pang-anim na add ang nagpapalit nang live sa button ng nakabukas na detail sa disabled na estado nito, dahil ang parehong selector na bumibilang para sa header ay bumibilang para sa addDisabled:

Ang detail screen sa cap ng party: ang Add button na naka-disable at may bagong label na Party is full

Magtanggal ng miyembro sa Party tab at babalik sa putol-putol na gilid ang napalayang puwesto, bababa ang bilang sa lahat ng dako nang sabay, at gagana ang susunod na add. Isang slice, isang may-ari, tatlong surface na nagkakasundo dahil lahat sila ay nagbabasa ng parehong state sa parehong hugis.

Ang binuo mo, at ang susunod

May pag-aari na ang app. Nasa isang slice ang party na ang party app lang ang may-ari, na-inject sa boot sa isang store na iwina-wire ng host sa paligid ng rootReducer ng contract. Nakarehistro, hindi napunan: nananatiling wala ang state.party hanggang may susunod na action na umabot sa combined reducer. Ang partyStateReady marker ang action na iyon, at sinasaklaw ng maluwag na hugis ng pagbabasa ang bawat sandali bago ito mag-fire. Ang nag-iisang interaksiyong tumatawid (ang addToParty, ang cap nito, ang hugis ng pagbasa nito) ay dala ng contract nang may bersyon, umaabot ang write sa shared detail view bilang prop na iwina-wire ng consumer nito, at kinukuha ng party ang sarili nitong data sa halip na manghiram ng iskedyul ng release ng ibang team. Nakita mo na rin ang pagkabigong iniiwasan ng disenyong ito: isang dispatch papunta sa slice na hindi kailanman nag-load ang may-ari, itinapon nang walang ingay.

May natitirang tapat na limitasyon: i-restart ang app at mawawala ang party. Nasa memorya ang slice, problema ng ibang post ang persistence, at ang pagkukunwaring hindi ay ang klase ng tahimik na paglawak ng saklaw na iniiwasan ng seryeng ito.

Ang mas matalim na tanong ang paulit-ulit na ibinabato ng stack na ito. Ang party ay anim na bagay at isang patakaran, at ang magalang na pagtawid sa seam ay nangailangan ng store, ng contract minor, ng injected na reducer at ng boot import. Ginawang posible ng RTK ang pagtawid; hindi nito ginawang maliit. Sa susunod, muling bubuuin ng serye ang mismong app na ito gamit ang TanStack Query at Zustand, ang stack na unang inaabot ng karamihan sa mga React Native team, at papanoorin kung ano ang gagawin ng federation sa pagpipiliang iyon.

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