Skip to content

Overlay left open with nothing rendered — invisible backdrop blocks all clicks (two races in the close path) #567

Description

@razvan-p

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});

componentActivatedsetActiveComponentopen = true for a component that no longer exists in $wire.get('components'). Nothing renders.

Reproduction (deterministic once the orphan exists):

  1. Open ModalA, then open ModalB stacked on top of it.
  2. 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.
  3. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions