Investigace · GL

Případ
mizejících pinů

Updated: 11. července 2026· Version 1

Čtyři dny, pět vyvrácených oprav, jedna frame-by-frame analýza videa a nakonec jeden usvědčený viník: interop vrstva mezi React Native Fabric a react-native-maps 1.20.1, která pod zátěží ztrácela mount/unmount operace. Tohle je příběh, jak se z „na iOS občas chybí pilulka" stal nativní bug se čtyřmi protichůdnými tvářemi — a jak jsme ho dostali.

01TL;DR

Jeden nativní bug, čtyři třídy symptomů, žádná chyba v našem JS kódu.

4
Třídy symptomů
5+
Vyvrácené opravy
4
Dní intenzivní práce
18
Commitů na větvi
1
Skutečná příčina

02Symptomy: čtyři tváře jednoho bugu

Kdyby se to chovalo aspoň konzistentně. Nechovalo.

To, co začalo jako jedno hlášení („rozvětvený sloupec pilulek má páteř, ale některé pilulky na koncích chybí"), se během vyšetřování rozpadlo do čtyř tříd, které si navzájem protiřečily:

Slepé markery

Marker se namountuje (React ho vidí, HUD ho počítá), ale nativní GMSMarker se nikdy nevykreslí. Někdy se „uzdraví" odjetím a vrácením kamery, někdy nikdy.

Duchové

Opačný případ: React marker odmountuje, ale pilulka zůstane viset na mapě. Na jednom screenshotu ~35+ pilulek z předchozího zoomu, zatímco HUD ukazoval přesně 25 živých markerů.

Mizející páteř

Polyline (páteř vějíře) zmizela, i když se jí v daném commitu nedotkla žádná React změna — jen se remountovali sousedé. Nejsilnější důkaz špatně směrovaných nativních operací.

Zamaskovaný crash

Starší NSRangeException crash v insertReactSubview, který jsme dříve „opravili" clampem — ve skutečnosti jsme crash proměnili v tichou desynchronizaci.

Zpětně dává všechno smysl: ztracený mount = slepý marker, ztracený unmount = duch, špatně adresovaný remove = zmizelá páteř, out-of-bounds insert = ten clampnutý crash. Jedna vrstva, čtyři projevy. Jenže to jsme čtyři dny nevěděli — a každý projev vypadal jako jiný bug s jinou opravou.

03Proč to bylo tak těžké

Nedeterministický nativní bug za třemi vrstvami abstrakce, na platformě, kterou lokálně skoro nejde iterovat.

04Éra slepé forenzní analýzy: v1–v5

Pět verzí opravy, každá postavená na diagnóze té předchozí — a každá vyvrácená nebo jen částečná. Celý žebřík je zachovaný na archivní větvi.

Bez instrumentace zbývalo číst zdrojáky react-native-maps a stavět hypotézy. Každá verze měla koherentní teorii, opravu a predikci — a realita ji pokaždé srazila:

VerzeHypotézaOpravaVerdikt
v1 — freeze-on-paintSlepý 100ms timer zmrazil markery do prázdných bitmap dřív, než se stihly vykreslitFreeze až po vykreslení (onLayout + double-rAF), souřadnice v deps pro self-healingVyvráceno — pan na fan zoomu dál nechává osamělé páteře
v2 — defer-freezeGoogle SDK rasterizuje offscreen views naprázdno; markery mimo obrazovku nechat živéisOffscreen flag, freeze až po settle na obrazovceVyvráceno a hůř — díry nově i uprostřed obrazovky
v3 — remount on revealMarker přidaný offscreen je otrávený; uzdraví ho jen remount na obrazovceisOffscreen složený do React klíče → první odhalení = čerstvý nativní markerČástečné — offscreen třídu léčí, ale markery narozené NA obrazovce dál slepnou
v4 — redraw sweepSlepé markery uzdraví plošný nativní redraw() po každém settleMarkerRegistry + redraw() všech markerů 600 ms po settleVyvráceno a model převrácen — sweep mazal ZDRAVÉ markery
v5 — one-rasterizationSpolehlivý je jen čerstvý add + jediný freeze; KAŽDÁ další re-rasterizace je hod kostkouIkona po zmrazení neměnná; všechny vizuální varianty složené do klíče → změna = remount čerstvou cestouNeprůkazné na simu — zůstal merge kandidát, rezidua přetrvala
Poučení z v4

Nejcennější selhání celé série. Že „léčebný" sweep mazal zdravé markery, převrátilo model vzhůru nohama: problém není kdy marker rasterizujeme, ale že se mounted markeru vůbec ještě někdo dotýká. I v1 si škodila sama — souřadnice v deps znamenaly, že severojižní pan přearmoval freeze celé řady zdravých pilulek.

Celý žebřík v1–v5 je zachovaný na lokální větvi archive/110-map-pin-ios-fix-attempts včetně checkpoint SHA — kdyby bylo někdy potřeba bisectovat na zařízení.

05Obrat: přestat hádat, začít měřit

Konec slepé iterace. Každý další krok si vynutila konkrétní nezodpověditelná otázka.

Po v5 padlo rozhodnutí: žádné další opravy naslepo. Místo toho instrumentace — dev-only diagnostický HUD přímo v aplikaci a [map-diag] logging, obojí stavěné tak, aby každá přidaná metrika odpovídala na jednu konkrétní otázku:

TechnikaOtázku, kterou zodpovídáJak funguje
HUD counter arithmeticJe to JS bug, slepý marker, nebo duch?mnt − unm = kolik markerů React VĚŘÍ, že je namountovaných. Porovnej s layout řádkem (co chce JS) a s tím, co je vidět na obrazovce (co udělal nativ). Tři čísla, tři vrstvy, čistá separace viníka.
Key-diff loggingProč se tenhle marker remountoval?Každá vizuální varianta složená do React klíče; každá změna klíče loguje remount pin <id> old → new — log sám pojmenuje flag, který se přepnul.
Born/gone loggingCo se přidalo a odebralo — a bylo to na obrazovce?Každý čerstvý add loguje born on|off, každý odchod z layoutu gone. Zavřelo slepé místo, které odhalila frame analýza (recompose = nový React element = žádný remount log).
HUD presetyJak zreprodukovat totéž dvakrát?Tlačítka BRN/RST/OVA/OVAf skočí kamerou na přesné souřadnice — repro přestalo záviset na ruce testera.

První instrumentovaná session přinesla okamžitý zisk: layout log ukázal pts=16 … drop=0, JS namountoval všech 16 markerů — a na obrazovce jich bylo ~12. Data, budget i query byly exonerovány naživo, jedním řádkem logu. Bug je nativní, s jistotou.

06Mirkovo video: frame-by-frame usvědčení

Nejsilnější důkazní materiál celého vyšetřování — obyčejný screen recording, rozřezaný na snímky a zkřížený s logy.

Mirek natočil testovací session na nativním Mac simulátoru (čímž mimochodem zabil pochybnost o QEMU fidelity). Záznam jsme rozřezali na snímky po 3 fps, srovnali časovou osu videa s časovou osou logů (±2–3 s skew — použitelné je jen překryvové okno) a prošli snímek po snímku:

  1. ~10:07:37
    POOF 1 — zmizení při zoomu
    Kamera se na pixel nehnula, ale HUD skočil z14 → z15 a dvoupilulkový vějíř zmizel z nezměněného pohledu. Log: 33× gone, HUD delta přesně −33. Layout se přepočítal proti CÍLOVÉMU regionu zoomu, zatímco na obrazovce byl ještě starý — datově koherentní, vizuálně „poof". (Samostatná třída → ctn.6.)
  2. ~10:07:49
    POOF 2 — kouřící zbraň
    Nulový pohyb kamery, žádný zoom — a 4 pilulky zmizí naráz. V lozích pro ně NENÍ ANI ŘÁDEK. Recompose vějíře změnil identitu komponent (leg ↔ samostatný pin = nový React element = čerstvý nativní add) a ty čerstvé addy se narodily slepé. Marker narozený na obrazovce nemá ŽÁDNOU léčebnou cestu — jeho klíč se už nikdy nezmění. Mirek do záznamu: „and here's the problem."
  3. celý záznam
    Reveal mechanismus exonerován
    Poofy nekorelovaly s reveal remounty — reveal z 10:07:27 se vykreslil v pořádku. Hypotéza „náš léčebný mechanismus je spouštěč" padla. Zároveň se potvrdil zesilovač: každý settle zaplavil nativ 5–33 gone operacemi, stejné body oscilovaly gone/re-add 2–3× za 30 s.
Proč to stálo za to

Jedna hodina analýzy videa udělala víc práce než dvě verze oprav. Definitivně zavrhla dvě hypotézy, potvrdila mechanismus (čerstvý add sdílející commit s gone-floodem umí přistát slepý), odhalila slepé místo v loggingu a založila dva nové, do té doby neviditelné bugy (ctn.6, ctn.7).

07Eliminace churnu

Když nemůžeš opravit vrstvu, která ztrácí operace, přestaň jí operace posílat.

Souběžně s honem na hlavního viníka padly skutečné JS bugy, které churn živily — a pak přišel klíčový architektonický tah:

Anchor lever — tah, který vyhrál JS polovinu války

Celý layout markerů se ukotvil ke snapshotu cluster query (snapnutý bbox + diskrétní zoom) místo k živé kameře. Důsledek: pan, který nepřekročí grid ani nezmění zoom, odvodí BIT-IDENTICKÝ layout — nula mountů, nula unmountů, nula mini-flipů. Churn spadl z 30–60 operací na settle na jednotky. Android verdikt od Mirka a Mikea: „so smooth" — jako vedlejší efekt zmizelo i třepání pilulek při malých panech.

A právě anchor lever dodal poslední důkaz: i při minimálním churnu iOS rezidua přetrvala — v commitech, kde nebylo NIC než pár reveal remountů, mizely viditelné pilulky a jednou zmizela Polyline páteř, které se v tom commitu nedotkla žádná React změna. JS páky byly vyčerpané. Viník musel sedět níž.

08Rozsudek a migrace

Všechny čtyři třídy symptomů konvergovaly k jedné vrstvě — a upstream ji mezitím celou vyměnil.

React (JS)
mount / unmount příkazy
HUD: mnt − unm = pravda Reactu
Fabric legacy-interop (1.20.1) — tady se operace ztrácely
Nativ (GMS)
ztracený mount → slepý marker
ztracený unmount → duch

react-native-maps 1.20.1 obsluhoval markery přes legacy-interop vrstvu Fabricu. Upstream changelog mezitím četl jako přiznání: 1.21.0 „major release (Fabric Support!)", 1.28.1 „fix ghost features on MapView", 1.29.0 (červen 2026) „ios fabric support (GoogleMaps Marker)". Markery v 1.29 interop vrstvu vůbec nepoužívají — mount/unmount předává objekty napřímo, přesně ta plocha, kde se operace ztrácely, zmizela.

  1. Rebuild #1
    Regrese: VŠECHNY custom markery neviditelné
    Po bumpu na 1.29 se vykreslovaly jen páteře (interop Polyline). Příčina nalezená ve zdrojácích 1.29: Fabric vkládá děti markeru do kontejneru ikony, ale jediné dvě věci, které ten kontejner kdy NASTAVÍ NA SPRÁVNOU VELIKOST, jsou paper-era callbacky, které pod Fabricem nikdy nevystřelí. Kontejner zůstane 0×0 a SDK rasterizuje prázdný obdélník. Bug samotné 1.29, ne náš.
  2. Patch 94aeb50
    Lokální oprava + diagnostika v jednom rebuildu
    Patch volá sizing rutinu při vložení dítěte a ještě jednou před freezem, plus NSLog diagnostika, která by při dalším selhání pojmenovala fázi. Pravidlo „každý rebuild nese opravu i logging" v praxi. Kandidát na upstream issue.
  3. Rebuild #2
    Úspěch
    Pilulky se vykreslují, ŽÁDNÉ mizení, žádné osamělé páteře. Zbýval občasný blik při settle — a log ho usvědčil: náš vlastní reveal-lifecycle (léčebný remount z interop éry) na Fabricu bliká místo léčí, protože čerstvé addy prostě fungují. Offscreen-born markery se prokazatelně kreslí samy.
  4. 8e7209f
    Léčebný mechanismus odstraněn
    Reveal lifecycle smazán — workaround přežil nemoc, kterou léčil. JS-only změna, Metro reload, žádný rebuild.
Jedno oko otevřené

Polyline (páteř vějíře) na iOS JEŠTĚ Fabric nemá — jede přes interop dál. Páteře sledovat při device soaku specificky.

09Ze zákulisí

Aby bylo jasné, že tohle nepsal stroj bez emocí: pár autentických momentů z chatových logů investigace.

Slovník vyšetřování se ustálil rychle: piny dělaly „poof", vějíře nechávaly „lonely spine" (osamělou páteř bez pilulek) a otázka po každém buildu zněla, jestli je to „more or less fucky than before".

  1. 7. 7.
    Slepá éra a únava materiálu
    Pět verzí opravy za jeden den, simulátor zabil každou. Večerní kapitulace: „damn, i think its still happening… but ive been getting tired boss. i really am not sure anymore." Bonusová scéna: kompletní iOS build pipeline proběhla, zatímco Mike byl ve sprše — a stabilitu crashe pak testoval metodou „agains pls" (4×, překlep se ujal).
  2. 9. 7.
    Instinkt proti místnosti
    Týmem kolovala hypotéza, že mapa prostě limituje počet markerů. Mikeův gut říkal, že se tahá za špatnou páku — a první instrumentovaná session to rozhodla jedním řádkem logu: markery doručené, jen nevykreslené (drop=0 flip=0). Session končila: „Thanks for today, we made great advancements."
  3. 10. 7. večer
    Od dna k vrcholu za jeden večer
    Otevřeno s „dude i think its like really bad now ? :D" — a po upgradu na 1.29 a icon-container patchi zavřeno s „I dont wanna call it too early but DUDE I THINK WE DID IT… android vibes almost ngl… omggg this feels good." Session skončila doslova vyčerpáním měsíčního limitu na AI.

10Kde jsme teď

Verze bump to s největší pravděpodobností řeší — ale poslední slovo má železo.

Pozn.: 3 předchozí failující testy v use-unlock-candidate.spec.ts jsou pre-existing a s větví nesouvisí (reprodukují se i bez ní) — potřebují vlastní issue.

11Co dál: navazující práce

Zbylé beads issues seskupené do GitLab issues — jedna GL issue = jedna větev, žádné mikro-branche.

GL issue (návrh)BeadsObsahKdy
mapa: úklid po Fabric migracinové (z ctn.11)Odstranit zbylé interop-era workaroundy: one-rasterization / variant-key remount disciplínu v map-markers (Fabric možná umí in-place update ikon jako Android — zabilo by to i remount bliky u dim/select), zvážit odstranění NSRange clamp patche. Součástí i sepsání upstream issues na react-native-maps (nevelikostěný icon kontejner, gradle artefakty v balíčku). Až PO čistém device soaku.po soaku
mapa: vizuální polish — zoom přechody + declutterctn.6 + ctn.7 + ctn.10 (zbytek)Recluster běží proti cílovému regionu, zatímco je vidět stará kamera (vějíř odmountuje uprostřed pohledu) + natažené/utržené nohy vějířů mezi zoomem a settle + immovable-vs-immovable překryvy (cluster badge na noze vějíře — chce badge displacement). Všechno layout vrstva markerů, jedna větev.další sprint
mapa: crash firemního účtu na iOSctn.9Nejdřív jen retest na 1.29 (crash seděl v nahrazené vrstvě). Pokud zmizel, zavřít bez větve; pokud přetrvá: .ips crash report ze simu → vlastní větev.retest hned po merge
FYI

Uzavírací práce (ctn.1/2/3/8) nejsou nové větve — jen verifikace na zařízeních a zavření issues. PM demo potřebuje větev mergnutou (PM si staví vlastní build).