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) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-17 17:30:24 +02:00
parent e58a08b974
commit 62158eecdc
1 changed files with 41 additions and 0 deletions

View File

@ -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 <x-modal-trigger> 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 <x-modal-trigger>. Gives EVERY modal-opening control an immediate
// spinner (pending flips synchronously on click — no round-trip needed) that clears