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ą:
| moment | kopia górnopoziomowa (Dialog) | kopia pod vaul (Drawer) | body |
|---|---|---|---|
| otwarcie dialogu | zapamiętuje "", ustawia none | — | none |
| otwarcie drawera na wierzchu | — | zapamiętuje none, ustawia none | none |
| dialog znika | przywraca "" | — | "" |
| drawer znika | — | przywraca none | none |
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 pierwszymyarn 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.pointerEventspo zamknięciu modala — to maskuje licznik Radiksa i psuje poprawnie zagnieżdżone warstwy. - Podbijając
vaullubcmdksprawdź, czy nie wciągnęły własnej kopiireact-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ęć →
Edytuj → Anuluj. Po zamknięciu document.body nie może mieć
pointer-events: none.