Command Select Mati Setelah Modal Ditutup: Memburu Lock Pointer-Events yang Tertinggal

Abd. Asis
Command Select Mati Setelah Modal Ditutup: Memburu Lock Pointer-Events yang Tertinggal

Laporan dari pengguna aplikasi ERP kami singkat dan membingungkan: "Setelah simpan resep, semua dropdown jadi tidak bisa diklik. Harus refresh dulu."

Gejalanya aneh karena tidak ada error di console. Halaman terlihat normal. Tombol bisa di-hover, kursor berubah jadi tangan, tapi klik tidak melakukan apa pun. Dropdown pencarian bahan yang biasanya terbuka dengan satu klik sekarang diam saja. Pindah halaman lewat menu masih jalan, karena itu juga tidak butuh klik pada elemen di dalam halaman.

Kalau kamu menemui pola seperti ini pada kombinasi Command select di dalam modal dialog, penyebabnya hampir selalu satu: sebuah lapisan modal gagal melepas kunci pointer-events pada body. Artikel ini menelusuri satu kasus nyata di aplikasi Sakapilar, dari gejala sampai perbaikan yang akhirnya dipakai.

Kenapa Command select paling sering jadi korban

Komponen pencarian berpola Command (cmdk) biasanya disusun tiga lapis:

  1. Dialog dari Radix sebagai wadah modal.
  2. Popover dari Radix sebagai wadah daftar, dirender di portal yang menempel ke body.
  3. Command dari cmdk di dalam popover itu, berisi input pencarian dan daftar opsi.

Di aplikasi Sakapilar, lapisan ini ada di CommandCombobox: Popover plus Command, dengan trigger berupa tombol sejajar input form. Komponennya dipakai di halaman produk, dokumen konversi, dan dialog resep.

Karena popover-nya hidup di portal terpisah, dia bergantung penuh pada kunci modal yang dikelola Radix. Begitu body punya pointer-events: none, seluruh klik di halaman mati, termasuk trigger popover dan item daftarnya. Elemen lain yang tidak butuh klik terlihat sehat, jadi pengguna menyimpulkan "hanya dropdown yang rusak".

Diagnosis lima menit

Sebelum menyentuh kode, ukur dulu keadaannya. Tiga pemeriksaan ini cukup untuk memastikan hipotesis.

JavaScriptJS
// 1. Apakah body masih terkunci?
getComputedStyle(document.body).pointerEvents;   // "none" = terkunci

// 2. Apakah ada sisa node dialog yang tertinggal?
document.querySelectorAll('[data-slot="dialog-content"], [data-slot="popover-content"]').length;

// 3. Apakah sisa node itu sudah tertutup tapi belum dilepas?
[...document.querySelectorAll('[data-slot="dialog-content"]')]
    .map(el => el.getAttribute('data-state'));   // ["closed"] = tertinggal

Kalau pointerEvents bernilai none padahal tidak ada dialog yang terlihat, dan ada node dengan data-state="closed" masih menempel di body, penyebabnya sudah ketemu. Tidak perlu membaca ulang seluruh komponen.

Yang membuat bug ini mahal adalah ketiadaan pesan error. Tidak ada exception, tidak ada warning, dan React DevTools menunjukkan pohon komponen yang tampak normal. Satu-satunya petunjuk adalah nilai computed style di atas.

Akar masalahnya: animasi keluar yang tidak pernah selesai

Radix memakai Presence untuk mengatur kapan sebuah node portal boleh dilepas. Selama elemen masih dalam keadaan closing, Presence menunggu sinyal animationend dari animasi keluar. Baru setelah sinyal itu datang, node dilepas bersama seluruh efek sampingnya: kunci scroll, pointer-events, dan status aria-hidden.

Di aplikasi berbasis Inertia, urutan kejadiannya bisa bertabrakan:

  1. Pengguna menekan Simpan di dalam dialog.
  2. Request selesai, Inertia memulai kunjungan ke halaman berikutnya.
  3. Komponen dialog di-unmount di tengah animasi keluar.
  4. animationend tidak pernah terkirim, karena elemennya sudah tidak ada.
  5. Node portal tertinggal, dan kunci pointer-events di body tidak pernah dilepas.

Masalah ini bukan teori. Dokumentasinya malah tersimpan sebagai komentar di kode kami sendiri, pada resources/js/components/ui/dialog.tsx:

Deviasi disengaja dari pola "exit lebih halus dari enter": animasi keluar dibuang. Radix Presence baru melepas node portal setelah animationend animasi keluar; saat halaman ditukar Inertia tepat ketika dialog ditutup (submit form → redirect), sinyal itu bisa tak pernah tiba sehingga node tertinggal di <body> (data-state="closed", opacity 0), id field jadi ganda, dan kunci scroll/pointer-events dialog tidak dilepas.

Perhatikan dua akibat tambahan yang disebut di situ: id field jadi ganda dan kunci scroll tidak dilepas. Itu menjelaskan gejala lain yang sempat dilaporkan terpisah, seperti form yang nilainya tertimpa dan halaman yang tidak bisa digulir setelah modal tertutup. Semuanya satu akar.

Hal yang sama berlaku pada popover. PopoverContent di aplikasi kami juga tidak memakai animasi keluar, dengan komentar yang menunjuk ke DialogContent sebagai alasannya. Dengan begitu, lapisan yang paling sering menampung Command tidak menyisakan node saat halaman berpindah.

Perbaikan yang dipakai

Perbaikannya sederhana dan tidak menambah kode: hapus animasi keluar, biarkan animasi masuk tetap ada.

Sebelum, kelas animasi dipasang dua arah:

React TSXTSX
className={cn(
  "duration-200",
  "data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
  "data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
)}

Sesudah, hanya arah masuk yang dianimasikan:

React TSXTSX
className={cn(
  "duration-100",
  "data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
)}

Tanpa aturan animasi keluar, Presence melepas node seketika ketika open berubah menjadi false. Tidak ada lagi jendela waktu yang bisa kalah cepat dengan navigasi. Efek sampingnya, modal menutup tanpa fade, dan itu harga yang sepadan.

Yang penting: perbaikan ini harus dipasang di sumbernya, bukan di satu halaman. Di aplikasi kami, DialogContent dan PopoverContent adalah komponen bersama, jadi satu perubahan menutup semua pemakaian, termasuk dialog resep yang memuat CommandCombobox.

Jangan pakai jalan pintas document.body.style.pointerEvents = 'auto' di dalam useEffect. Itu menutup gejala, tapi node sisa tetap ada, aria-hidden tetap salah, dan duplikasi id field tetap terjadi. Kalau kamu menemukan kode seperti itu di proyek lama, anggap sebagai penanda bahwa akar masalahnya belum dibereskan.

Kalau animasi keluar tetap ingin dipertahankan

Ada kalanya desainer meminta modal punya animasi menutup. Empat opsi berikut bisa dipakai, dengan konsekuensinya masing-masing.

PendekatanCara kerjaKonsekuensi
Tutup dialog sebelum navigasipanggil onClose() dulu, baru router.visit setelah dialog selesai menutupbutuh penundaan kecil, dan harus disiplin di setiap pemanggil
forceMount lalu lepas manualkendalikan sendiri kapan node dilepaslebih banyak kode, mudah bocor
Animasi lewat AnimatePresencetunggu onExitComplete baru unmounthanya cocok kalau seluruh lapisan memakai Framer Motion
Tanpa animasi keluarPresence melepas seketikamodal menutup tanpa fade

Urutan pada tabel itu juga urutan risiko. Untuk aplikasi internal dengan banyak dialog dan navigasi Inertia di mana-mana, pilihan terakhir yang paling stabil.

Cara memverifikasi perbaikannya

Jangan berhenti di "sekarang bisa diklik". Buktikan dengan pengukuran, karena bug ini sifatnya balapan dan bisa muncul lagi di jalur navigasi lain.

Di aplikasi Sakapilar, alur ujinya begini: buka dialog resep, buka dropdown cari bahan, pilih satu bahan, tekan Simpan, lalu tunggu halaman selesai berpindah. Setelah itu ukur dua hal:

JavaScriptJS
getComputedStyle(document.body).pointerEvents;              // harus "auto"
document.querySelectorAll('[data-slot="dialog-content"]').length;  // harus 0

Saya juga menguji ulang lapisan ini di harness minimal memakai versi paket yang sama dengan proyek (@radix-ui/react-dialog 1.1.15, cmdk 1.1.1, React 19.2) untuk memisahkan mana yang salah library dan mana yang salah urutan kita:

SkenarioHasil ukur
Dialog terbukabody pointer-events none (memang seharusnya)
Klik opsi di daftar Commandnilai terisi, popover tertutup
Escape saat popover terbukahanya popover yang tertutup, dialog tetap terbuka
Dialog ditutup normalpointer-events kembali auto
Dua dialog bersarangjumlah dialog 2, setelah Escape jadi 1

Artinya penutupan normal sudah benar di versi ini. Yang bermasalah adalah penutupan yang berbarengan dengan perpindahan halaman, dan itulah yang diperbaiki dengan membuang animasi keluar.

Yang sudah dijelaskan orang lain

Sebelum menyimpulkan sendiri, ada baiknya tahu bahwa kelas masalah ini sudah terdokumentasi di repositori Radix:

  • #1241 melaporkan pointer-events: none tertinggal di body setelah dialog ditutup, dan sampai pembaruan 2025 masih muncul di versi baru.
  • #2122 menjelaskan bahwa DialogContent versi modal memang mematikan pointer-events untuk seluruh halaman, dan itu bertabrakan dengan lapisan modal dari pihak lain.
  • #1927 membahas hideOthers yang menambahkan aria-hidden ke banyak node, membuat scrollbar berkedip dan halaman berat melambat saat popover dibuka.
  • #1658 mencatat event sentuh yang bocor ke elemen di belakang saat memilih opsi Select.
  • shadcn-ui/ui#2944 mencatat item Command yang tidak bisa diklik setelah perubahan cmdk, kasus yang mirip gejalanya tapi beda penyebabnya.

Membaca lima tautan itu membuat pola pikirnya berubah: kalau dropdown di dalam modal tidak bisa diklik, jangan langsung menuduh komponen dropdown. Periksa dulu siapa yang sedang memegang kunci halaman.

Checklist pencegahan

  • Pastikan komponen dialog dan popover bersama tidak punya animasi keluar kalau aplikasimu memakai navigasi yang bisa memindahkan halaman saat modal masih menutup.
  • Kalau animasi keluar tetap dipakai, tutup dialog lebih dulu dan tunggu selesai sebelum memulai navigasi.
  • Jangan menambal dengan pointer-events: auto di body. Cari node yang tertinggal.
  • Beri data-slot yang konsisten pada setiap lapisan portal, supaya pemeriksaan di console cukup satu query.
  • Tambahkan uji otomatis yang mengukur getComputedStyle(document.body).pointerEvents setelah alur simpan-dan-pindah-halaman. Satu assertion kecil menangkap kelas bug yang tidak pernah muncul di console.
  • Uji juga alur batal: buka dialog, tekan Escape saat popover terbuka, lalu pastikan hanya lapisan teratas yang tertutup.

Penutup

Bug "command select bentrok dengan modal dialog" jarang berakhir di komponen Command. Yang bertabrakan biasanya pengelola lapisan: satu pihak menunggu animasi selesai sebelum melepas kunci, pihak lain memindahkan halaman lebih cepat dari animasi itu.

Cara paling cepat memastikan: buka console, periksa pointer-events pada body, dan cari node dengan data-state="closed" yang masih menempel. Kalau ketemu, kamu sudah tahu harus memperbaiki apa, tanpa perlu menebak-nebak di komponen dropdown yang sebenarnya tidak bersalah.

Abd. Asis
Ditulis oleh
Abd. Asis

Software Developer dari Madura. Menulis tentang PHP, Laravel, dan pengembangan web modern dalam Bahasa Indonesia.