From 62158eecdc7e44af0f2a2261b5e27bc7a975e1a8 Mon Sep 17 00:00:00 2001 From: boban Date: Wed, 17 Jun 2026 17:30:24 +0200 Subject: [PATCH] fix(modals): guard wire-elements/modal close path against activeComponent/components desync Wrap window.LivewireUIModal at alpine:init to null-guard closingModal() the way the vendor already guards getActiveComponentModalAttribute, so the click-away path no longer throws "Cannot read properties of undefined (reading 'name')" when @persist widens the Alpine/Livewire desync window. In-sync close behavior (incl. a modal vetoing its own close) is unchanged. Verified: deterministic browser repro flips threw:true -> threw:false; the real closeModalOnClickAway() entry is clean under forced desync and the modal still closes; @persist first-open after navigate, spinner, and Escape/Abbrechen all remain clean (0 console errors). 182 tests pass; Vite build OK; Codex review approved. Co-Authored-By: Claude Opus 4.8 (1M context) --- resources/js/app.js | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/resources/js/app.js b/resources/js/app.js index a4878d9..129251f 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -77,6 +77,47 @@ document.addEventListener('livewire:init', () => { }); document.addEventListener('alpine:init', () => { + // ── wire-elements/modal close-path guard (vendor bug workaround) ───── + // The package's modal.js closingModal() reads components[activeComponent].name + // with NO null-guard — unlike its sibling getActiveComponentModalAttribute(), + // which DOES guard the very same access (`components[activeComponent] !== undefined`). + // When Alpine's `activeComponent` and the Livewire-side `components` map are + // momentarily out of sync at close time — a window that @persist'ing the modal + // manager across wire:navigate widens (the manager + its Alpine state survive a + // navigation while Livewire re-hydrates `components`) — that read throws + // TypeError: Cannot read properties of undefined (reading 'name') + // on the click-away path (closeModalOnClickAway → closingModal). The modal still + // closes, so it is console noise, but it violates R12 (zero console errors). + // + // We wrap the global factory to apply the SAME guard the vendor already uses + // elsewhere: when there is no live component for the active id there is nothing to + // notify and nothing can veto the close, so we report "closing allowed" (return + // true) and let closeModal() proceed exactly as before. The in-sync path is + // untouched (delegates to the original closingModal, preserving a modal's ability + // to veto its own close). Runs at alpine:init — before Alpine evaluates the + // persisted manager's x-data="LivewireUIModal()" — so every instance is guarded. + // Keeps the @persist navigate fix and the spinner/error-toast UX + // intact (this file is the only thing touched). + const modalFactory = window.LivewireUIModal; + if (typeof modalFactory === 'function' && !modalFactory.__clusevGuarded) { + const guarded = function () { + const data = modalFactory(); + const original = data.closingModal; + if (typeof original === 'function') { + data.closingModal = function (eventName) { + const components = this.$wire?.get('components'); + if (!components || components[this.activeComponent] === undefined) { + return true; // desync / already torn down: nothing to notify, allow close + } + return original.call(this, eventName); + }; + } + return data; + }; + guarded.__clusevGuarded = true; + window.LivewireUIModal = guarded; + } + // ── Modal trigger ─────────────────────────────────────────────────── // Reused by . Gives EVERY modal-opening control an immediate // spinner (pending flips synchronously on click — no round-trip needed) that clears