Investigace · GL
Případ
mizejících pinů
Č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.
- Mizející platové pilulky na mapě nebyly chyba layoutu ani dat — JS pokaždé namountoval všechno, ale nativní vrstva část markerů nikdy nevykreslila (nebo naopak nikdy neodstranila).
- Skutečná příčina: legacy-interop vrstva React Native Fabric v react-native-maps 1.20.1 pod náporem mount/unmount operací („churn") ztrácela nebo špatně směrovala instrukce. Ztracený mount = neviditelný marker, ztracený unmount = duch.
- Oprava má dvě nohy: eliminace churnu na JS straně (layout ukotvený ke snapshot dotazu — pan už nemountuje/neodmountovává nic) a upgrade na react-native-maps 1.29, kde iOS Google markery jedou nativně přes Fabric a interop vrstva pro ně úplně zmizela.
- Verze 1.29 si přivezla vlastní bug (Fabric nikdy nenastaví velikost kontejneru ikony → markery se rasterizují do prázdna) — opraveno lokálním patchem, kandidát na upstream issue.
- Stav: simulátor čistý, Android „so smooth". Zbývá device soak u iOS členů týmu a pár navazujících úklidů — viz poslední sekce.
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:
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.
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ů.
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í.
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.
- Nedeterminismus. Stejné gesto někdy pilulku zabilo, někdy ne. Odjet a vrátit se to většinou spravilo — což svádělo k hypotézám o „offscreen otravě", které byly z půlky pravdivé a z půlky slepá ulička.
- Vzájemně si protiřečící důkazy. Oprava, která pomohla jedné třídě, zhoršila druhou. Sweep, který měl slepé markery uzdravit (v4), naopak mazal ty zdravé — a tím převrátil celý model.
- Cena iterace. Každá nativní změna na iOS = rebuild v QEMU macOS VM, řádově hodina. Vzniklo z toho pravidlo: žádný rebuild bez opravy nebo bez loggingu, který pojmenuje další příčinu.
- Pochyby o simulátoru. Dlouho viselo ve vzduchu, jestli GPU translace v QEMU nezkresluje rasterizaci markerů. Až Mirkův test na nativním Mac simulátoru tuhle pochybnost zabil — bug je skutečný i na Apple železe.
- Android mátl stopu. Část symptomů (pozdní pop-in na hraně obrazovky) vypadala stejně, ale byla to nevinná mount-latence, která se sama zahojí. Trvalo, než se oddělilo „cross-platform JS clipping" (skutečné bugy, opravené) od „sticky iOS blank" (ten pravý nepřítel).
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:
| Verze | Hypotéza | Oprava | Verdikt |
|---|---|---|---|
| v1 — freeze-on-paint | Slepý 100ms timer zmrazil markery do prázdných bitmap dřív, než se stihly vykreslit | Freeze až po vykreslení (onLayout + double-rAF), souřadnice v deps pro self-healing | Vyvráceno — pan na fan zoomu dál nechává osamělé páteře |
| v2 — defer-freeze | Google SDK rasterizuje offscreen views naprázdno; markery mimo obrazovku nechat živé | isOffscreen flag, freeze až po settle na obrazovce | Vyvráceno a hůř — díry nově i uprostřed obrazovky |
| v3 — remount on reveal | Marker přidaný offscreen je otrávený; uzdraví ho jen remount na obrazovce | isOffscreen 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 sweep | Slepé markery uzdraví plošný nativní redraw() po každém settle | MarkerRegistry + redraw() všech markerů 600 ms po settle | Vyvráceno a model převrácen — sweep mazal ZDRAVÉ markery |
| v5 — one-rasterization | Spolehlivý je jen čerstvý add + jediný freeze; KAŽDÁ další re-rasterizace je hod kostkou | Ikona po zmrazení neměnná; všechny vizuální varianty složené do klíče → změna = remount čerstvou cestou | Neprůkazné na simu — zůstal merge kandidát, rezidua přetrvala |
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:
| Technika | Otázku, kterou zodpovídá | Jak funguje |
|---|---|---|
| HUD counter arithmetic | Je 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 logging | Proč 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 logging | Co 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 presety | Jak 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:
- ~10:07:37POOF 1 — zmizení při zoomuKamera 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.)
- ~10:07:49POOF 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."
- celý záznamReveal mechanismus exonerovánPoofy 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.
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:
- Vějíře za sheet bandem (ctn.2): cluster query rezervovala pruh pod bottom-sheetem, takže vějíř s kotvou za peekem kompletně odmountoval — i pilulky trčící NAD sheet. Oprava: render query jede přes celý region, rezerva zůstala jen pro počítadlo a fit.
- Piny přivázané k viewportu (ctn.3): fetch bbox byl přesný viewport, render bbox o viewport větší — každý pan = double layout pass a výměna datasetu (24 → 16 bodů). Oprava na BE: mapPoints se řídí jen filtrem, ne bbox — pan už nedělá žádný pin refetch. Bonusem gzip na API (swagger 85,6 kB → 10,7 kB).
- Grid-snap bboxu: teoreticky hezké (padded bbox zaokrouhlený na mřížku → malé pany reuse), prakticky skoro k ničemu — reálné pany kříží mřížku téměř pokaždé. Ponecháno, neškodí.
- Dvoufázové adds/removes — vyvráceno a revertováno tentýž večer: odložené odebírání mělo zajistit, že čerstvý add nikdy nesdílí commit s gone-floodem. Místo toho: viditelné dvojnice markerů, „flash" při každém settle, a hlavně první masivní výskyt DUCHŮ. Revert, ale duchové se zapsali do spisu jako klíčový důkaz (ztracené unmount operace).
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-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.
- Rebuild #1Regrese: 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áš.
- Patch 94aeb50Lokální oprava + diagnostika v jednom rebuilduPatch 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.
- Rebuild #2ÚspěchPilulky 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.
- 8e7209fLéčebný mechanismus odstraněnReveal lifecycle smazán — workaround přežil nemoc, kterou léčil. JS-only změna, Metro reload, žádný rebuild.
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".
- 7. 7.Slepá éra a únava materiáluPě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).
- 9. 7.Instinkt proti místnostiTý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." - 10. 7. večerOd dna k vrcholu za jeden večerOtevř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.
- iOS sim: pilulky se kreslí, žádné mizení, žádní duchové, blik odstraněn s reveal lifecyclem
- Android: „so smooth" — třepání pilulek pryč (anchor lever), declutter umí 2D nudge + pre-claim kroužků
- Piny se nerefetchují při panu (ctn.3) a nemizí za sheet bandem (ctn.2) — čeká jen na device potvrzení
- Gates zelené: mobile lint/typecheck/format, 55 e2e + 143 unit/layout testů
- Device soak u iOS členů týmu (pilulky, páteře Polyline, duchové)
- Retest crashe firemního účtu na 1.29 (ctn.9) — stejná nativní vrstva, možná spadl s ní
- Zavřít ctn.1 / ctn.2 / ctn.3 / ctn.8 po čistém soaku
- Upstream issue na react-native-maps: nevelikostěný Fabric icon kontejner (+ gradle artefakty v balíčku)
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) | Beads | Obsah | Kdy |
|---|---|---|---|
| mapa: úklid po Fabric migraci | nové (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 + declutter | ctn.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 iOS | ctn.9 | Nejdří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 |
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).