diff --git a/containers/libreportal/frontend/core/forms/js/custom-select.js b/containers/libreportal/frontend/core/forms/js/custom-select.js index c678068..077c5d7 100644 --- a/containers/libreportal/frontend/core/forms/js/custom-select.js +++ b/containers/libreportal/frontend/core/forms/js/custom-select.js @@ -314,12 +314,46 @@ if (el.closest('.custom-select')) return false; if (el.multiple) return false; // multi-selects need different UX if (el.hasAttribute('data-no-enhance')) return false; + // Detached nodes can't be enhanced: build() inserts the wrapper via + // select.parentNode, which is null here and would throw. This happens for + // real — the app config form builds its category panels in an async loop, + // so the observer can see a select that a later render already replaced. + // Skipping is safe: attaching the subtree fires the observer again, and the + // select is enhanced then, connected. + if (!el.isConnected || !el.parentNode) return false; return ENHANCE_CLASSES.some(c => el.classList.contains(c)); } + // One bad select must never cost the others their dropdown. Before this, + // enhancement ran in a bare forEach: a single throw aborted the whole pass, + // so every select AFTER it silently stayed native — page-wide breakage from + // one edge case, with nothing in the console to say so. + function enhanceOne(select) { + try { + new CustomSelect(select); + } catch (err) { + // Roll back a half-built widget: a select left with .custom-select-native + // is invisible (opacity 0, pointer-events none) — worse than un-enhanced. + try { + select.classList.remove('custom-select-native'); + select[ENHANCED] = false; + const wrapper = select.closest('.custom-select'); + if (wrapper && wrapper.parentNode) wrapper.parentNode.insertBefore(select, wrapper); + if (wrapper) wrapper.remove(); + } catch (_) { /* best effort — the native select still works */ } + console.warn('[custom-select] could not enhance', select.name || select.id || select, err); + } + } + function enhanceAll(root = document) { + // shouldEnhance is inside the guard too: it walks the DOM (closest), which + // can itself throw on a node being torn down mid-pass. root.querySelectorAll(ENHANCE_SELECTOR).forEach(s => { - if (shouldEnhance(s)) new CustomSelect(s); + try { + if (shouldEnhance(s)) enhanceOne(s); + } catch (err) { + console.warn('[custom-select] skipped a select', err); + } }); } @@ -330,10 +364,14 @@ for (const m of mutations) { for (const node of m.addedNodes) { if (node.nodeType !== 1) continue; - if (shouldEnhance(node)) { - new CustomSelect(node); - } else { - enhanceAll(node); + // Guarded for the same reason as enhanceAll: a throw here would + // abandon the remaining mutation records in this batch, so one + // awkward node could leave a whole freshly-rendered form native. + try { + if (shouldEnhance(node)) enhanceOne(node); + else enhanceAll(node); + } catch (err) { + console.warn('[custom-select] observer pass failed', err); } } }