Przejdź do głównej zawartości

Drawer nad dialogiem: jedna kopia react-dismissable-layer

Ograniczenie architektoniczne wynikające z AP-950 (na telefonie wejście w edycję gry/rezerwacji potrafiło zablokować całą aplikację).

👤 Dla kogo

Dokument techniczny. Przeczytaj go, zanim dodasz resolutions dla paczek Radix, podbijesz vaul/cmdk albo zbudujesz ekran, na którym Drawer otwiera się nad otwartym Dialog.

🌍 Problem

Radix pilnuje document.body.style.pointerEvents licznikiem otwartych warstw modalnych. Licznik i zapamiętana wartość wyjściowa (originalBodyPointerEvents) żyją w zasięgu modułu @radix-ui/react-dismissable-layer — nie w React context providerze, tylko w domyślnej wartości contextu tworzonej przy załadowaniu modułu.

Jeśli w node_modules znajdą się dwie kopie tej paczki, każda prowadzi własny licznik i własną wartość wyjściową:

momentkopia górnopoziomowa (Dialog)kopia pod vaul (Drawer)body
otwarcie dialoguzapamiętuje "", ustawia nonenone
otwarcie drawera na wierzchuzapamiętuje none, ustawia nonenone
dialog znikaprzywraca """"
drawer znikaprzywraca nonenone

Wygrywa warstwa, która odmontuje się jako ostatnia. Gdy animacja wyjścia drawera trwa dłużej niż animacja dialogu, na body zostaje pointer-events: none przy zerowej liczbie otwartych dialogów — strona wygląda normalnie, ale nie reaguje na dotyk. Użytkownik zgłasza to jako zawieszenie lub crash aplikacji.

Objaw był mobile-only, bo tylko na wąskim ekranie EditGameDialog renderuje Drawer (vaul). Na desktopie renderuje zwykły Dialog, czyli tę samą kopię modułu co dialog pod spodem — licznik zlicza się wtedy poprawnie.

✅ Rozwiązanie

W package.json sekcja resolutions wymusza jedną kopię:

"resolutions": {
"@radix-ui/react-dismissable-layer": "^1.1.13"
}

Wpis nie ma prefiksu paczki, więc dotyczy wszystkich deskryptorów tego identyfikatora w drzewie — yarn rozwiązuje je do jednej wersji. vaul i cmdk nadal mają własne, zagnieżdżone @radix-ui/react-dialog (1.1.15 obok górnopoziomowego 1.1.17), ale te kopie importują już tę jedną, hoistowaną react-dismissable-layer.

:::warning Zakres poprawki

Zdeduplikowana jest wyłącznie react-dismissable-layer. Pozostałe paczki stosu modalnego — @radix-ui/react-dialog, react-focus-scope, react-focus-guards, react-portal, react-presence, react-remove-scroll — występują nadal w trzech kopiach. Nie każda z nich jest groźna (react-remove-scroll-bar trzyma licznik blokad w atrybucie na body, więc duplikacja mu nie szkodzi), ale react-focus-scope ma modułowy focusScopesStack i ten sam wzorzec co opisany wyżej błąd — dwa jednocześnie aktywne trapy fokusu przy drawerze nad dialogiem. Potwierdzone i świadomie zostawione poza zakresem AP-950; osobne zgłoszenie: AP-954.

:::

⚠️ Czego nie robić

  • Nie usuwaj tego wpisu z resolutions — duplikat wraca przy pierwszym yarn install, a błąd jest niedeterministyczny (zależy od tego, która animacja wyjścia skończy się później), więc łatwo go przeoczyć w code review.
  • Nie „naprawiaj” objawu przez ręczne czyszczenie document.body.style.pointerEvents po zamknięciu modala — to maskuje licznik Radiksa i psuje poprawnie zagnieżdżone warstwy.
  • Podbijając vaul lub cmdk sprawdź, czy nie wciągnęły własnej kopii react-dismissable-layer.

🔍 Jak sprawdzić

find node_modules -path "*react-dismissable-layer/package.json" | wc -l

Musi zwrócić 1. Ten sam niezmiennik sprawdza test __tests__/components/dismissable-layer-single-copy.test.ts — przechodzi node_modules rekurencyjnie i liczy kopie paczki.

Ręczna weryfikacja w przeglądarce (wąski ekran): grafik → kafelek zajęć → EdytujAnuluj. Po zamknięciu document.body nie może mieć pointer-events: none.