
ถือกำเนิดและถูกแก้ในสิบสองนาที
changelog ของ GeekBye v1.8.10 บอกว่ามันแก้ crash ตอนแก้ไข keyboard shortcut มันแก้จริง — แต่ crash นั้นถูกใส่เข้ามาและถูกแก้ใน pull request เดียวกัน ห่างกันสิบสองนาที และไม่เคยไปถึงผู้ใช้แม้แต่คนเดียว เรื่องจริงคือปฏิกิริยาลูกโซ่ด้านความน่าเชื่อถือที่ผลิตมันออกมา: การเปลี่ยนแปลงเล็ก ๆ ที่ถูกต้องเรื่องว่าจะ suspend shortcut เมื่อไหร่ และ React teardown bug ที่หล่นออกมาจากโค้ดบรรทัดเดียวที่ตั้งใจให้ editor ปลอดภัยขึ้น
นี่คือประโยคที่ซื่อสัตย์ที่สุดที่ผมเขียนได้เกี่ยวกับ GeekBye v1.8.10: crash ที่มันโด่งดังเรื่องการแก้ ไม่เคยเกิดกับใครเลย changelog บอกว่า "Fixed a crash while editing keyboard shortcuts and switching windows mid-edit," ซึ่งจริง และอ่านเหมือน bug ที่ถูก ship, กัดผู้ใช้, แล้วถูก patch ในรอบตามมา มันไม่ใช่ crash นั้นถูกใส่โดย commit หนึ่งและถูกลบโดยอีก commit หนึ่งใน pull request เดียวกัน ห่างกันสิบสองนาที ในเช้าวันศุกร์เดียวกัน มันอยู่ทั้งชีวิตภายใน branch เดียว release notes กำลังพรรณนาถึงแผลเป็นที่ก่อตัวและหายก่อนใครนอก repo จะได้เห็นมัน
นั่นไม่ใช่ความล้มเหลวของ changelog; มันคือรูปร่างที่ซื่อสัตย์ของงานแบบวนซ้ำ และมันคุ้มที่จะเล่าเพราะสิบสองนาทีนั้นบรรจุ React bug ที่ให้บทเรียนจริง ๆ แต่จะเข้าใจ crash ได้ คุณต้องเข้าใจก่อนว่ารีลีสนี้จริง ๆ แล้วมี ไว้เพื่อ อะไร — เพราะ crash คือความเสียหายพลอยได้จากการแก้อย่างอื่น
รีลีสนี้จริง ๆ แล้วเกี่ยวกับอะไร
GeekBye ให้คุณ rebind keyboard shortcut ของมันใน Settings ขณะที่คุณกำลังบันทึกคอมโบใหม่ แอปต้องหยุด global shortcut ของมันไม่ให้ยิง — ไม่งั้นการกด Cmd+B เพื่อกำหนดมันจะแค่ toggle หน้าต่างแทนที่จะถูก capture ดังนั้น editor จึง suspend global shortcut ผ่าน IPC: renderer เรียก window.electronAPI.setShortcutsSuspended(true) แล้ว ShortcutsHelper ใน main process ตอบด้วยการเรียก unregisterAll() ทิ้งทุก registration ของ globalShortcut เพื่อให้การกดคีย์ไปถึง capture input แทนที่จะ trigger action ตอน resume มันเรียก registerGlobalShortcuts() เพื่อเอาพวกมันกลับคืน
bug ที่เริ่มทั้งหมดนี้ (issue #233) เกี่ยวกับ ขอบเขต ของ suspension นั้น โค้ดเก่า suspend shortcut ตลอดเวลาที่หน้า settings ของ shortcut เปิดอยู่ — ไม่ใช่แค่ตอนที่คุณกำลังบันทึกคีย์อยู่จริง ๆ ส่วนใหญ่มันมองไม่เห็น แต่ถ้าคุณเปิดหน้านั้น เริ่มทำอย่างอื่น แล้ว navigate ออกไปโดยไม่เคยแก้ไขให้เสร็จ แอปอาจถูกทิ้งไว้ให้ global shortcut ทั้งหมด unregister — ตายเงียบ ๆ — จนกว่าคุณจะกลับมา commit ที่แก้มันพูดตรง ๆ เรื่องอาการ: การ suspend กว้างเกินไปเสี่ยงต่อ "making the app appear broken if the user forgot they had the page open."
ทางแก้คือการหดหนึ่งบรรทัด: suspend เฉพาะตอนที่แถวหนึ่งกำลังถูกแก้ไขอยู่จริง ๆ — คีย์อยู่ที่ !!editingShortcut, state ของ hook ที่ถือ id ของแถวที่คุณกำลังบันทึก — แทนที่จะตลอดการเยี่ยม เป็นการเปลี่ยนที่ดี แต่ «suspend เฉพาะตอนแก้ไข» ก่อคำถามทันที: อะไรนับว่า เสร็จ การแก้ไข? การกดคีย์ทำให้มันเสร็จ การกด Escape ยกเลิกมัน แต่ถ้าคุณแค่… คลิกออกไป หรือสลับไปแอปอื่นทั้งดุ้นกลาง capture ล่ะ? ถ้าไม่มีอะไรยกเลิกการแก้ไข คุณก็ถูกทิ้งให้บันทึก shortcut ลงในหน้าต่างที่ไม่ได้ focus และ shortcut ก็ค้าง suspend อยู่ ดังนั้น PR เดียวกันจึงเพิ่มวาล์วนิรภัย: cancel การแก้ไขเมื่อ focus จากไป และนั่นคือบรรทัดที่ crash
bug สิบสองนาที
วาล์วนิรภัยคือ useEffect ใน ShortcutsSettings.tsx ที่ ขณะแถวหนึ่งกำลังถูกแก้ไข ฟัง blur:
const handleBlur = () => cancelEditing()
window.addEventListener('blur', handleBlur)
inputRef.current?.addEventListener('blur', handleBlur) // the landmine
listener สองตัว ตัวหนึ่งบน window — ยิงเมื่อคุณสลับไปแอปอื่น อีกตัวแนบ ตรง ๆ ในฐานะ raw DOM listener เข้ากับ <input> ที่ใช้ capture — ตั้งใจให้ยิงเมื่อคุณคลิกออกไปยัง element อื่น พวกมันดูซ้ำซ้อนและไม่มีพิษภัย แต่มันไม่ใช่
เดินตามลำดับของ "switching windows mid-edit," ซึ่งเป็นวลีของ changelog เป๊ะ ๆ:
- คุณกำลังบันทึก shortcut;
<input>ถูก focus คุณกด Cmd+Tab ไปแอปอื่น - blur ของ window ยิง
handleBlurรันcancelEditing()ซึ่งตั้งeditingShortcutกลับเป็นnull - การตั้ง state นั้น unmount
<input>— แถวออกจาก edit mode ดังนั้น input ถูกเอาออกจาก DOM - การเอา element ที่ถูก focus ออกจาก DOM dispatch event
blurบน element นั้นแบบ synchronous listenerinputRefตัว raw จับมันได้และเรียกcancelEditing()อีกครั้ง — คราวนี้กลาง commit phase ของ React ขณะ component tree กำลังถูกรื้อลง - การเรียก state setter บน fiber ที่กำลัง teardown อยู่ ไปสะดุด invariant ภายในตัวหนึ่งของ React แล้วมันโยน:
"Should have a queue"หน้า settings crash
bug ไม่ใช่ window listener และไม่ใช่แนวคิด «cancel on blur» จริง ๆ มันคือการที่ listener หนึ่งในสองตัวเป็น raw addEventListener บน element ที่ React จัดการอยู่ React ไม่รู้จัก listener ตัวนั้น ไม่ cleanup มันตอน unmount และ — สำคัญที่สุด — browser ยิง blur ระหว่างการเอาออกจาก DOM ตัวที่ React กำลังทำอยู่พอดี ดังนั้น handler จึงเข้า logic ของ state คุณซ้ำในวินาทีที่แย่ที่สุดเท่าที่จะเป็นได้ ข้อความของ commit ที่แก้พูดได้แม่นกว่าที่ผมพูดได้: "when cancelEditing unmounts the input, the blur fires and calls cancelEditing again during React's render cycle."
ทางแก้
bf28a50, สิบสองนาทีหลังจาก crash ถือกำเนิด, ทำสองอย่างเล็ก ๆ
อย่างแรก มัน ลบ raw input listener และเก็บไว้แต่ตัว window ซึ่งเป็นตัวเดียวที่จำเป็นต้องเป็น manual DOM listener เลย (ไม่มี onBlur ของ React สำหรับ «ทั้งหน้าต่างเสีย focus»):
const handleWindowBlur = () => cancelEditing()
window.addEventListener('blur', handleWindowBlur)
return () => {
window.removeEventListener('blur', handleWindowBlur)
}
อย่างที่สอง มันย้ายกรณีคลิกออกไป — «ผู้ใช้คลิก element อื่น» — ไปที่ onBlur prop ของ React เองบน input และ เลื่อนมันไปหนึ่ง tick:
onBlur={() => {
// Deferred to avoid state updates during React's render cycle.
setTimeout(() => cancelEditing(), 0)
}}
ทั้งสองส่วนสำคัญ การใช้ onBlur prop ของ React แทน raw listener หมายความว่า React เป็นเจ้าของ lifecycle ของ handler และจะไม่ทิ้งมันห้อยไว้ และ setTimeout(…, 0) รับประกันว่าแม้เมื่อ blur นี้ ถูก trigger โดย unmount การเรียก cancelEditing() ก็ตกลงใน tick ถัดไป — หลังจาก render และ commit ปัจจุบันจบแล้ว — ดังนั้นมันจึงไม่มีทาง update state บน component ที่ยังรื้อลงอยู่ได้เลย ถ้า component หายไปแล้วเมื่อถึงตอนนั้น การเรียกที่ถูกเลื่อนก็เป็น no-op ที่ไม่มีพิษภัย
นั่นคือทางแก้ทั้งหมด ลบ listener ที่ React มองไม่เห็น; ให้ React schedule ตัวที่เหลือ
ทำไมนี่คือความน่าเบื่อชนิดที่ดี
มีสิ่งล่อใจที่จะจัดสิ่งนี้ไว้ใต้ «จิ๊บจ๊อย» — setTimeout ตัวหนึ่ง, บรรทัดที่ถูกลบหนึ่งบรรทัด — แล้วเดินหน้าต่อ แต่รูปร่างของมันเป็นหนึ่งในวิธีที่พบบ่อยที่สุดที่ React desktop app crash และมันคุ้มที่จะซึมซับ:
- crash โผล่เฉพาะที่ ขอบเขต lifecycle (unmount) เท่านั้น ถูก trigger โดย event ที่ framework ไม่ได้ schedule (native blur ระหว่างการเอาออกจาก DOM) มันจะไม่โผล่ในการ test แบบคลิกทะลุ; มันต้องการ path «focus จากไปขณะแก้ไข» ที่เฉพาะเจาะจง
- root cause คือการ ผสม ownership model สองแบบ — raw DOM event และ synthetic event ที่ถูก schedule ของ React — บน element เดียวกัน วินาทีที่ handler คุณ mutate state ที่สามารถ unmount element นั้นได้ path แบบ raw ก็กลายเป็น re-entrant
- ทางแก้ไม่ใช่ «เพิ่ม guard flag» (แม้ว่า
isMountedref จะช่วยกลบมันได้); มันคือการเอา raw listener ออกทั้งหมด ซึ่งเป็นการเปลี่ยนที่ เล็กกว่า และเป็นตัวที่ถูก การลบออกอีกครั้ง
และบทเรียนระดับ meta ที่ทั้งซีรีส์นี้วนเวียนซ้ำ ๆ: อ่าน diff ไม่ใช่ release note "Fixed a crash while editing keyboard shortcuts" นั้นแม่นยำ แต่มันซ่อนว่า crash นั้นเป็นแผลที่ทำเอง เช้าเดียวกัน และไม่เคยถูก ship — และว่ารีลีสจริงคือชัยชนะด้านความถูกต้องเงียบ ๆ เรื่องว่า เมื่อไหร่ จะ suspend global shortcut
มีอะไร ship อีกบ้าง
มีอีกสองอย่างที่ติดมากับ v1.8.10 คุ้มค่าอย่างละบรรทัด ระบบ shortcut ยังได้ การเยียวยาตัวเองข้าม sleep/wake: macOS สามารถทิ้ง registration ของ global shortcut ของแอปเงียบ ๆ ตอนเครื่อง sleep ดังนั้นตอนนี้ resume handler จะ re-verify และ re-register พวกมัน หมายความว่า shortcut ของคุณยังทำงานหลังแล็ปท็อปตื่นโดยไม่ต้อง relaunch และในฝั่ง AI client ฉลาดขึ้นเรื่อง rate limit — streaming request ที่ชน 429 ตอนนี้จะ back off แบบ exponential และ reset counter เมื่อคุณหยุดบันทึก แทนที่จะรอ delay ตายตัวจนหมด สามการแก้ด้านความน่าเชื่อถือใน subsystem ต่าง ๆ หนึ่งในนั้นคือ crash ที่ไม่เคยเป็น crash
สำหรับบทก่อนหน้าในเรื่องยุค v1 ดูพิมพ์การประชุมเป็น PDF โดยไม่มี PDF library (v1.8.9); และสำหรับภาพรวมทั้งเส้นเรื่อง ดูกายวิภาคของการส่งมอบซอฟต์แวร์ให้สมบูรณ์แบบ