Overlay can be left open with no component rendered — an invisible backdrop that blocks every click
Two independent races in the close path of resources/js/overlay-component.js both end in the same state: open === true with no component to render. The overlay's full-viewport container stays mounted and swallows every click, while the page underneath looks completely normal. Nothing is logged and nothing throws — the only way out is a reload.
They have different causes and need different fixes, but they present identically, which is what made them hard to pin down. Filing together for that reason; happy to split.
Environment
|
|
| wire-elements/pro |
5.0.13 (latest) |
| livewire/livewire |
v4.4.2 |
| laravel/framework |
v13.29.0 |
| PHP |
8.5.4 |
| Overlay |
modal (animationOverlapDuration: 350, the package default in resources/views/modal/component.blade.php) |
The shared symptom
open: true activeComponent: <an id>
showActiveComponent: true
$wire.get('components'): [] // nothing registered
visible panes: 0 // nothing rendered
document.elementFromPoint(centre) → div.wep-modal-inner-container
The backdrop measures exactly the viewport, so every click lands on it instead of the page.
Bug 1 — a history entry can outlive its component, and the next close returns to it
setActiveComponent pushes to the shared store inside the animation timeout (line 157), while closeActiveComponent pops synchronously (line 165):
// setActiveComponent
setTimeout(() => {
this.activeComponent = id;
this.showActiveComponent = true;
this.activeComponentWidth = this.getElementAttribute('size');
this.store.trackHistory(id, this.type, this.getActiveComponentName(), this.getActiveComponentParameters());
}, (this.activeComponent !== false) ? this.animationOverlapDuration : 0);
// closeActiveComponent
this.open = false;
this.store.history.pop();
Close an overlay inside that animationOverlapDuration window and the push lands after the pop, leaving an entry for a component that is already gone. Alpine.store('WepOverlayComponent').history lives as long as the page, so the orphan just sits there.
The next close then finds it and treats it as the parent to return to (lines 167–171):
let previousOverlayComponent = this.store.history[this.store.history.length - 1];
if (previousOverlayComponent !== undefined && (previousOverlayComponent?.id !== this.activeComponent)) {
this.store.history.pop();
Livewire.dispatch(`${previousOverlayComponent.type}.componentActivated`, {id: previousOverlayComponent.id});
componentActivated → setActiveComponent → open = true for a component that no longer exists in $wire.get('components'). Nothing renders.
Reproduction (deterministic once the orphan exists):
- Open ModalA, then open ModalB stacked on top of it.
- Save/close ModalB, then dispatch
modal.close ~150ms later — inside the 350ms window.
Everything closes correctly, but store.history is now [ModalA] with $wire.get('components') empty. A telltale: the orphan's name is undefined, because getActiveComponentName() ran when the component was already gone.
- Now open one ordinary modal, anywhere, and close it.
The orphan is read as its parent and reactivated → stuck.
Step 3 is the part that makes this hard to report: the leak and the freeze are different modals, often on different pages and minutes apart, so they rarely look related. A heavy modal provokes it because a longer round trip is a wider window.
Bug 2 — the deferred resetState() wipes any component opened during the close
The closed handler resets server state on a timer (lines 217–221):
Livewire.on(`${this.type}.closed`, (event) => {
if ((event?.options?.reset ?? true)) {
setTimeout(() => {
this.activeComponent = false;
this.$wire.resetState();
}, 300);
}
resetState() does $this->components = []; $this->activeComponent = null;. Anything registered in the intervening 300ms is destroyed, including a component the application opened deliberately.
Reproduction: close an overlay and open another one within 300ms:
Livewire.dispatch('modal.close');
Livewire.dispatch('modal.open', { component: 'modal-b' }); // same tick
Measured against the gap between the two dispatches:
| gap |
$wire.get('components') after |
modal opens |
| 0ms |
0 |
❌ |
| 200ms |
0 |
❌ |
| 400ms |
1 |
✅ |
| 700ms |
1 |
✅ |
The cutoff sits exactly on the 300ms timer. Below it, activeComponent is correctly set to the new component's id and open is true, but the registration has been wiped — so the modal never appears and the overlay is left in the stuck state above.
This one bites a very natural pattern: a modal that closes with close(andDispatch: [...]) and an application listener that opens a follow-up modal in response. There's no supported "close and hand off to another overlay" idiom that I could find — close() takes $withForce, $andEmit, $andDispatch, $andForget, none of which cover it — so any application doing this loses the race every time.
removeComponentFromState at line 209 has the same shape (a 500ms timer against state that may have moved on), though I haven't isolated a failure from it specifically.
Suggested direction
Both are deferred writes in the close path that aren't guarded against a subsequent activation:
- Bug 1 — track history at the same moment the close path pops it, rather than deferring the push; or reconcile against
$wire.get('components') before trusting an entry. As a stopgap, we wrap closeActiveComponent and drop history entries of the same overlay type whose id the server no longer knows. A genuine parent is always still in components, a ghost never is, so it prunes precisely — verified it leaves legitimate stacked closes untouched.
- Bug 2 — carry a generation/sequence number so the deferred
resetState() and activeComponent = false become no-ops when a newer component has been activated since the close began. We haven't found a clean application-side workaround for this one, since the damaging call is inside the handler's closure.
Happy to open a PR for either if that's useful, or to provide a minimal reproduction repo.
Overlay can be left open with no component rendered — an invisible backdrop that blocks every click
Two independent races in the close path of
resources/js/overlay-component.jsboth end in the same state:open === truewith no component to render. The overlay's full-viewport container stays mounted and swallows every click, while the page underneath looks completely normal. Nothing is logged and nothing throws — the only way out is a reload.They have different causes and need different fixes, but they present identically, which is what made them hard to pin down. Filing together for that reason; happy to split.
Environment
modal(animationOverlapDuration: 350, the package default inresources/views/modal/component.blade.php)The shared symptom
The backdrop measures exactly the viewport, so every click lands on it instead of the page.
Bug 1 — a history entry can outlive its component, and the next close returns to it
setActiveComponentpushes to the shared store inside the animation timeout (line 157), whilecloseActiveComponentpops synchronously (line 165):Close an overlay inside that
animationOverlapDurationwindow and the push lands after the pop, leaving an entry for a component that is already gone.Alpine.store('WepOverlayComponent').historylives as long as the page, so the orphan just sits there.The next close then finds it and treats it as the parent to return to (lines 167–171):
componentActivated→setActiveComponent→open = truefor a component that no longer exists in$wire.get('components'). Nothing renders.Reproduction (deterministic once the orphan exists):
modal.close~150ms later — inside the 350ms window.Everything closes correctly, but
store.historyis now[ModalA]with$wire.get('components')empty. A telltale: the orphan'snameisundefined, becausegetActiveComponentName()ran when the component was already gone.The orphan is read as its parent and reactivated → stuck.
Step 3 is the part that makes this hard to report: the leak and the freeze are different modals, often on different pages and minutes apart, so they rarely look related. A heavy modal provokes it because a longer round trip is a wider window.
Bug 2 — the deferred
resetState()wipes any component opened during the closeThe
closedhandler resets server state on a timer (lines 217–221):resetState()does$this->components = []; $this->activeComponent = null;. Anything registered in the intervening 300ms is destroyed, including a component the application opened deliberately.Reproduction: close an overlay and open another one within 300ms:
Measured against the gap between the two dispatches:
$wire.get('components')afterThe cutoff sits exactly on the 300ms timer. Below it,
activeComponentis correctly set to the new component's id andopenis true, but the registration has been wiped — so the modal never appears and the overlay is left in the stuck state above.This one bites a very natural pattern: a modal that closes with
close(andDispatch: [...])and an application listener that opens a follow-up modal in response. There's no supported "close and hand off to another overlay" idiom that I could find —close()takes$withForce,$andEmit,$andDispatch,$andForget, none of which cover it — so any application doing this loses the race every time.removeComponentFromStateat line 209 has the same shape (a 500ms timer against state that may have moved on), though I haven't isolated a failure from it specifically.Suggested direction
Both are deferred writes in the close path that aren't guarded against a subsequent activation:
$wire.get('components')before trusting an entry. As a stopgap, we wrapcloseActiveComponentand drop history entries of the same overlay type whose id the server no longer knows. A genuine parent is always still incomponents, a ghost never is, so it prunes precisely — verified it leaves legitimate stacked closes untouched.resetState()andactiveComponent = falsebecome no-ops when a newer component has been activated since the close began. We haven't found a clean application-side workaround for this one, since the damaging call is inside the handler's closure.Happy to open a PR for either if that's useful, or to provide a minimal reproduction repo.