ज़्यादातर UI बग सिर्फ दिखावटी होते हैं। इनमें से कुछ ऐसे नंबर बदल देते हैं जिन्हें यूज़र बदलना ही नहीं चाहता था, और अगर वे नंबर leverage, ऑर्डर साइज़ या कोई price हों, तो बग अब सिर्फ दिखावटी नहीं रहता।
ये दो हैं जिनसे हम टकराए, दोनों ब्राउज़र और लाइब्रेरी के ऐसे व्यवहार से पैदा हुए जो तकनीकी रूप से documented हैं पर व्यवहार में अदृश्य। इनके फ़िक्स छोटे हैं। इन्हें ढूँढना नहीं था।
मुख्य बातें
- एक focused <input type="number"> पर जब आप व्हील स्क्रॉल करते हैं तो उसका मान बढ़ या घट जाता है। किसी money फ़ील्ड पर यह चुपचाप वैल्यू दोबारा लिख देता है।
- इसे preventDefault() से नहीं, blur() से ठीक करें: blur करने पर increment रुक जाता है और page व modal दोनों का scrolling पूरी तरह सलामत रहता है।
- document.body में portal किया गया कोई Radix popover, Radix dialog के अंदर focus नहीं थाम सकता, क्योंकि dialog का focus scope focus को वापस खींच लेता है। फ़ील्ड ज़िंदा दिखती है पर हर कीस्ट्रोक खा जाती है।
- dialog के अपने element को popover का portal container बना दें ताकि वह trapped subtree का हिस्सा बन जाए।
- वह फ़िक्स हाथ से लिखे click-outside handlers को बेकार कर देता है: एक बार content portal हो जाने पर, वह उस wrapper का DOM descendant नहीं रहता जिसके सापेक्ष आप माप रहे थे।
1. व्हील एक editing gesture है, और यूज़र को किसी ने नहीं बताया
नेटिव नंबर इनपुट में एक व्यवहार है जो spec जितना पुराना है: जब फ़ील्ड focused हो, तो उस पर माउस व्हील स्क्रॉल करने से मान उसके step attribute के हिसाब से ऊपर-नीचे होता है।
शॉपिंग कार्ट के किसी quantity picker पर यह हल्की-सी खीझ है। हमारे forms पर यह बिल्कुल अलग बात है, क्योंकि यहाँ जो फ़ील्ड हैं वे leverage, trading amount, profit-share percentage, और listing price हैं। यूज़र किसी फ़ील्ड में क्लिक करता है, बाकी form पढ़ने के लिए page स्क्रॉल करता है, और जो नंबर उसने पहले सेट किया था वह बदल चुका होता है। कुछ भी घोषणा नहीं करता। Form फिर भी validate हो जाता है। बॉट एक ऐसी वैल्यू से शुरू होता है जिसे यूज़र ने कभी चुना ही नहीं।
भोला-भाला फ़िक्स यह है कि हर इनपुट पर एक wheel handler लगाकर preventDefault() कॉल कर दें। वह काम करता है, और साथ ही scrolling तोड़ देता है: जब कर्सर संयोग से किसी नंबर फ़ील्ड के ऊपर हो तब यूज़र page स्क्रॉल नहीं कर पाता, जो एकदम अलग तरीके से टूटा हुआ महसूस होता है।
बेहतर फ़िक्स spec की उसी precondition का इस्तेमाल करता है। increment सिर्फ तभी होता है जब फ़ील्ड focused हो, इसलिए focus हटा देना ही काफ़ी है:
const onWheel = (e) => {
const active = document.activeElement;
if (
active instanceof HTMLInputElement &&
active.type === "number" &&
(active === e.target || active.contains(e.target))
) {
active.blur(); // kills the increment, leaves scrolling intact
}
};
document.addEventListener("wheel", onWheel, { passive: true, capture: true });
उस snippet में तीन सोचे-समझे चुनाव:
preventDefault()के बजायblur()। हम scroll रद्द नहीं कर रहे, हम वह शर्त हटा रहे हैं जो इसे edit बनाती है। Page सामान्य रूप से स्क्रॉल होता रहता है।capture: true। listener event को नीचे जाते समय देखता है, इसलिए यह तब भी fire होता है जब कोई भीतरी component propagation रोक दे।passive: true। ठीक इसीलिए सुरक्षित क्योंकि हम कभीpreventDefault()कॉल ही नहीं करते, और यह listener को scrolling के critical path से बाहर रखता है।
एक ही global listener, एक बार mount होकर, app के हर नंबर इनपुट को कवर करता है, उन्हें भी जो अभी मौजूद नहीं हैं। दूसरा विकल्प, हर फ़ील्ड को अलग से patch करना, उसी पल फेल हो जाता है जब कोई नया form जोड़ता है और भूल जाता है।
2. Dialog के अंदर एक popover जिसमें टाइप ही नहीं हो सकता
दूसरा एक नामुमकिन-से bug report की तरह सामने आया: एक modal के अंदर, एक dropdown के अंदर, एक search box जो साफ़ focus होता था और फिर उसमें टाइप की गई हर चीज़ को नज़रअंदाज़ कर देता था।
वजह दो सही व्यवहारों का टकराना है।
Radix popovers डिफ़ॉल्ट रूप से अपने content को document.body में portal करते हैं, जो किसी सामान्य page पर सही है, क्योंकि यह overflow और stacking contexts से बच निकलता है। Radix dialogs focus को अपने ही subtree के अंदर trap करते हैं, जो भी सही है, और यही किसी modal को modal बनाता है।
इन दोनों को मिला दें तो DOM के लिहाज़ से popover का content dialog के बाहर होता है। dialog का focus scope हर आने वाले focus event को मोटे तौर पर ऐसे जाँचता है:
container.contains(target) ? allow : focus(lastFocusedElement)
search box dialog के अंदर contained नहीं है, इसलिए उस पर उतरने वाला हर focus event तुरंत पलट दिया जाता था। फ़ील्ड render होती, एक पल के लिए caret दिखाती, और कोई कीस्ट्रोक register होने से पहले ही focus खो देती। यह ज़िंदा दिखती थी और सब कुछ निगल जाती थी।
फ़िक्स यह है कि popover कहाँ रहता है, इस बारे में झूठ बोलना बंद कर दें। Radix का portal एक container स्वीकार करता है, इसलिए हम dialog के अंदर एक खाली, zero-height div render करते हैं और popover को उसी में portal कर देते हैं:
// inside the dialog
<div ref={setDropdownHost} />
...
<PopoverContent container={dropdownHost}>…</PopoverContent>
दो बातें जिन्हें गलत करना आसान है:
- ref नहीं, State। host node पहली render पर मौजूद नहीं होता, और जब वह बन जाए तो portal को एक re-render चाहिए। कोई
useRefnode तो थाम लेता पर वह render कभी trigger नहीं करता। - host को layout पर असर नहीं डालना चाहिए। यह खाली और zero-height है, इसलिए यह dialog की markup में कहीं भी सुविधानुसार बैठ सकता है और कुछ भी खिसकाता नहीं।
3. वह फ़िक्स जो चुपचाप बगल के कोड को तोड़ देता है
यही वह हिस्सा है जिस पर मैं सबसे ज़ोर से झंडी दिखाऊँगा, क्योंकि यहीं एक साफ़-सुथरा फ़िक्स एक regression बन जाता है।
portalled popover पर जाने से पहले, dropdown में हाथ से लिखा एक click-outside handler था: wrapper पर एक ref रखो, document के clicks सुनो, और अगर click अंदर नहीं था तो बंद कर दो।
एक बार content portal हो जाने पर, वह उस wrapper का DOM descendant नहीं रहता, भले ही दिखने में उसके अंदर हो। तो handler अब dropdown के अपने search box पर हुए clicks को बाहर के clicks के रूप में देखता है, और उसी चीज़ को बंद कर देता है जिसे यूज़र इस्तेमाल करने की कोशिश कर रहा है।
उस handler को हटाना cleanup नहीं था, वह फ़िक्स का हिस्सा था। आम नियम:
Portalling, DOM containment पर टिकी हर धारणा को तोड़ देता है। Click-outside handlers,
closest()lookups, event delegation, और CSS descendant selectors सबका मतलब चुपचाप बदल जाता है। जब आप किसी चीज़ को portal करें, तो जाकर वह कोड ढूँढें जिसने containment मान रखा था।
इन दोनों में क्या समान है
दोनों बग "लाइब्रेरी सही व्यवहार कर रही है" और "यूज़र का वक़्त खराब हो रहा है" के बीच की खाई में बैठते हैं। कुछ भी throw नहीं हुआ। कुछ भी log नहीं हुआ। दोनों मामलों में component किसी screenshot में सही दिखता था।
आम सबक जो मैं याद रखूँगा:
- जानिए platform किन gestures को edit मानता है। किसी focused नंबर इनपुट पर व्हील एक है; Linux पर middle-click paste दूसरा। अगर कोई फ़ील्ड पैसा थामती है, तो इनका audit करें।
- किसी event को रद्द करने के बजाय precondition हटाना बेहतर मानें।
blur()नेpreventDefault()को इसलिए मात दी क्योंकि उसने लक्षण दबाने के बजाय कारण पर निशाना लगाया, और उसने असंबंधित व्यवहार को बिना छेड़े छोड़ दिया। - जब failure mode "कोई भूल जाता है" हो, तो इसे एक बार, globally हल करें। हर-फ़ील्ड वाला फ़िक्स एक expiry date वाला फ़िक्स होता है।
- जब आप portal करें, तो containment धारणाओं का audit करें। DOM tree और visual tree सहमत होना बंद कर देते हैं, और जिस भी कोड ने इन्हें एक मान लिया था वह अब गलत है।
