
Lahir dan Diperbaiki dalam Dua Belas Menit
Changelog GeekBye v1.8.10 bilang ia memperbaiki sebuah crash saat mengedit pintasan keyboard. Memang begitu โ tapi crash itu diperkenalkan dan diperbaiki dalam pull request yang sama, terpaut dua belas menit, dan tak pernah mencapai satu pun pengguna. Kisah sebenarnya adalah kaskade keandalan yang menghasilkannya: sebuah perubahan kecil dan benar tentang kapan pintasan ditangguhkan, dan bug teardown React yang jatuh dari satu baris yang dimaksudkan untuk membuat editor lebih aman.
Inilah kalimat paling jujur yang bisa kutulis tentang GeekBye v1.8.10: crash yang terkenal ia perbaiki tak pernah terjadi pada siapa pun. Changelog bilang "Fixed a crash while editing keyboard shortcuts and switching windows mid-edit," yang benar, dan terbaca seperti sebuah bug yang dirilis, menggigit pengguna, dan ditambal dalam tindak lanjut. Bukan. Crash itu diperkenalkan oleh satu commit dan dihapus oleh commit lain dalam pull request yang sama, terpaut dua belas menit, di pagi Jumat yang sama. Ia hidup sepenuhnya di dalam sebuah branch. Catatan rilis menggambarkan sebuah bekas luka yang terbentuk dan sembuh sebelum siapa pun di luar repo bisa melihatnya.
Itu bukan kegagalan changelog; itu adalah bentuk jujur dari kerja iteratif, dan layak diceritakan karena dua belas menit itu memuat sebuah bug React yang sungguh-sungguh mendidik. Tapi untuk memahami crash-nya kamu harus memahami untuk apa rilis ini sebenarnya โ karena crash itu adalah kerusakan kolateral dari memperbaiki sesuatu yang lain.
Tentang apa rilis ini sebenarnya
GeekBye membiarkanmu mengikat ulang pintasan keyboard-nya di Pengaturan. Saat kamu merekam kombinasi baru, aplikasi harus menghentikan pintasan global-nya dari terpicu โ kalau tidak, menekan Cmd+B untuk menetapkannya justru akan men-toggle jendela alih-alih ditangkap. Jadi editor menangguhkan pintasan global lewat IPC: renderer memanggil window.electronAPI.setShortcutsSuspended(true), dan ShortcutsHelper milik main process merespons dengan memanggil unregisterAll(), menjatuhkan setiap registrasi globalShortcut sehingga penekanan tombol mencapai input penangkap alih-alih memicu sebuah aksi. Saat melanjutkan ia memanggil registerGlobalShortcuts() untuk mengembalikannya.
Bug yang memulai semua ini (issue #233) adalah soal cakupan penangguhan itu. Kode lama menangguhkan pintasan sepanjang waktu halaman pengaturan pintasan terbuka โ bukan hanya saat kamu aktif merekam sebuah tombol. Sebagian besar waktu itu tak terlihat. Tapi jika kamu membuka halaman itu, mulai melakukan sesuatu yang lain, dan menavigasi menjauh tanpa pernah menyelesaikan sebuah pengeditan, aplikasi bisa tertinggal dengan semua pintasan globalnya tak terdaftar โ mati diam-diam โ sampai kamu kembali. Commit yang memperbaikinya berterus terang soal gejalanya: menangguhkan terlalu luas berisiko "making the app appear broken if the user forgot they had the page open."
Perbaikannya adalah penyempitan satu baris: tangguhkan hanya ketika sebuah baris benar-benar sedang diedit โ dikunci pada !!editingShortcut, state hook yang menyimpan id baris yang kamu rekam โ alih-alih sepanjang kunjungan. Perubahan bagus. Tapi "hanya tangguhkan saat mengedit" memunculkan sebuah pertanyaan langsung: apa yang dihitung sebagai menyelesaikan sebuah pengeditan? Menekan sebuah tombol menyelesaikannya. Menekan Escape membatalkannya. Tapi bagaimana jika kamu hanyaโฆ mengklik menjauh, atau berpindah ke aplikasi lain sepenuhnya, di tengah penangkapan? Jika tak ada yang membatalkan pengeditan, kamu tertinggal merekam sebuah pintasan ke dalam jendela yang tak terfokus, dan pintasan tetap ditangguhkan. Jadi PR yang sama menambahkan sebuah katup pengaman: batalkan pengeditan ketika fokus pergi. Dan itulah baris yang crash.
Bug dua belas menit
Katup pengaman itu adalah sebuah useEffect di ShortcutsSettings.tsx yang, selama sebuah baris sedang diedit, mendengarkan blur:
const handleBlur = () => cancelEditing()
window.addEventListener('blur', handleBlur)
inputRef.current?.addEventListener('blur', handleBlur) // the landmine
Dua listener. Satu pada window โ terpicu saat kamu berpindah ke aplikasi lain. Satu dilampirkan langsung, sebagai listener DOM mentah, ke <input> penangkap โ dimaksudkan untuk terpicu saat kamu mengklik menjauh ke elemen lain. Mereka tampak berlebihan dan tak berbahaya. Mereka bukan.
Telusuri urutannya untuk "switching windows mid-edit," yang persis adalah frasa changelog:
- Kamu sedang merekam sebuah pintasan;
<input>terfokus. Kamu Cmd+Tab ke aplikasi lain. - Blur jendela terpicu.
handleBlurmenjalankancancelEditing(), yang menyeteleditingShortcutkembali kenull. - Menyetel state itu meng-unmount
<input>โ baris keluar dari mode edit, jadi input dihapus dari DOM. - Menghapus sebuah elemen yang terfokus dari DOM secara sinkron mengirim sebuah event
blurpada elemen itu. ListenerinputRefmentah menangkapnya dan memanggilcancelEditing()lagi โ kali ini di tengah fase commit React, saat pohon komponen sedang dirobohkan. - Memanggil sebuah state setter pada sebuah fiber yang sedang di tengah teardown menyandung salah satu invarian internal React, dan ia melempar:
"Should have a queue". Tampilan pengaturan crash.
Bug-nya bukan listener jendela dan bukan pula benar-benar konsep "batalkan saat blur." Melainkan bahwa salah satu dari dua listener itu adalah sebuah addEventListener mentah pada sebuah elemen yang dikelola React. React tak tahu tentang listener itu, tak membersihkannya saat unmount, dan โ yang krusial โ browser memicu blur selama penghapusan DOM yang sedang dilakukan React itu sendiri, jadi handler masuk kembali ke logika state-mu pada saat paling buruk yang mungkin. Pesan commit perbaikannya mengatakannya lebih tepat daripada yang bisa kulakukan: "when cancelEditing unmounts the input, the blur fires and calls cancelEditing again during React's render cycle."
Perbaikannya
bf28a50, dua belas menit setelah crash-nya lahir, melakukan dua hal kecil.
Pertama, ia menghapus listener input mentah dan menyimpan hanya yang jendela, yang merupakan satu-satunya yang memang perlu menjadi sebuah listener DOM manual (tak ada React onBlur untuk "seluruh jendela kehilangan fokus"):
const handleWindowBlur = () => cancelEditing()
window.addEventListener('blur', handleWindowBlur)
return () => {
window.removeEventListener('blur', handleWindowBlur)
}
Kedua, ia memindahkan kasus klik-menjauh โ "pengguna mengklik elemen lain" โ ke prop onBlur milik React sendiri pada input, dan menundanya satu tik:
onBlur={() => {
// Deferred to avoid state updates during React's render cycle.
setTimeout(() => cancelEditing(), 0)
}}
Kedua bagian itu penting. Menggunakan prop onBlur React alih-alih sebuah listener mentah berarti React memiliki siklus hidup handler dan tak akan meninggalkannya menggantung. Dan setTimeout(โฆ, 0) menjamin bahwa bahkan ketika blur ini memang dipicu oleh sebuah unmount, panggilan cancelEditing() mendarat pada tik berikutnya โ setelah render dan commit saat ini selesai โ sehingga ia tak pernah bisa memperbarui state pada sebuah komponen yang masih dirobohkan. Jika komponennya sudah hilang saat itu, panggilan yang ditunda adalah no-op yang tak berbahaya.
Itulah keseluruhan perbaikannya. Hapus listener yang tak bisa dilihat React; biarkan React menjadwalkan yang tersisa.
Mengapa ini jenis membosankan yang baik
Ada godaan untuk mengarsipkan ini sebagai "sepele" โ sebuah setTimeout, sebuah baris yang dihapus โ dan melanjutkan. Tapi bentuknya adalah salah satu cara paling umum aplikasi desktop React crash, dan layak diinternalisasi:
- Crash hanya muncul di sebuah batas siklus hidup (unmount), dipicu oleh sebuah event yang tak dijadwalkan framework (sebuah blur native selama penghapusan DOM). Ia tak akan muncul di sebuah tes klik-lewat; ia butuh jalur spesifik "fokus pergi saat mengedit."
- Akar penyebabnya adalah mencampur dua model kepemilikan โ event DOM mentah dan event sintetis, terjadwal milik React โ pada elemen yang sama. Saat handler-mu memutasi state yang bisa meng-unmount elemen itu, jalur mentah menjadi re-entrant.
- Perbaikannya bukan "tambahkan sebuah bendera penjaga" (meski sebuah ref
isMountedakan menyembunyikannya); melainkan menghapus listener mentah sepenuhnya, yang merupakan perubahan lebih kecil dan yang benar. Pengurangan lagi.
Dan meta-pelajarannya, yang terus diputari seluruh seri ini: baca diff-nya, bukan catatan rilisnya. "Fixed a crash while editing keyboard shortcuts" akurat, tapi ia menyembunyikan bahwa crash itu ditimbulkan sendiri, di pagi yang sama, dan tak pernah dirilis โ dan bahwa rilis yang sebenarnya adalah kemenangan ketepatan yang tenang tentang kapan menangguhkan sebuah pintasan global.
Apa lagi yang dirilis
Dua hal lagi ikut menumpang di v1.8.10, keduanya layak satu baris. Sistem pintasan juga memperoleh penyembuhan-diri lintas tidur/bangun: macOS bisa diam-diam menjatuhkan registrasi pintasan global sebuah aplikasi ketika mesin tidur, jadi sebuah handler resume kini memverifikasi dan mendaftarkannya ulang, yang berarti pintasanmu masih bekerja setelah laptop bangun tanpa peluncuran ulang. Dan di sisi AI, klien menjadi lebih pintar soal batas laju โ permintaan streaming yang menabrak sebuah 429 kini mundur secara eksponensial dan mereset penghitungnya ketika kamu berhenti merekam, alih-alih menunggu sebuah penundaan tetap. Tiga perbaikan keandalan pada subsistem yang berbeda, salah satunya adalah crash yang tak pernah menjadi crash.
Untuk bab sebelumnya dalam kisah v1, mencetak rapat ke PDF tanpa library PDF (v1.8.9); dan untuk keseluruhan alurnya, anatomi merilis software sampai sempurna.