livewire/livewire: Skip transitions for top-layer UI
Contributed by calebporzio
Part of the livewire v4.4.7 release
Livewire's wire:transition feature drives animated DOM morphs with the browser's View Transitions API, and this patch fixes a visual conflict between those transitions and top-layer UI such as Flux toasts, native popovers, and modal dialogs. Because the browser paints ::view-transition pseudo-elements above the top layer, an open toast or popover could be completely covered by a running transition — and no amount of CSS or z-index can change that ordering. Livewire already skipped transitions behind modal dialogs; PR #10552 generalizes that policy to popovers, aborts in-flight transitions the instant top-layer UI opens, and cleans up the promise rejections an intentional skip used to produce. There are no API or configuration changes for anyone upgrading from v3.8.10.
What changed
- The pre-flight check in the transition directive now looks for both modal dialogs and open popovers:
document.querySelector('dialog:modal, :popover-open'). If top-layer UI is already on screen, the animation is skipped entirely. - While a transition is running, a capturing
beforetogglelistener ondocumentskips it the instant adialogor[popover]element starts opening (event.newState === 'open'), before the browser can paint a frame with the overlay above your UI. - The existing
MutationObserverthat watches for theopenattribute set byshowModal()is kept as a fallback for browsers that don't emitbeforetogglefor modal dialogs. - Expected promise rejections are now consumed (
transition.ready.catch(() => {}), plus a.catch()on thefinishedchain), so a deliberate skip no longer surfaces as an unhandled rejection. - A browser regression test reproduces the original Flux toast scenario using a native
popover="manual"element with 2-second fade animations.
The same logic is wired into both code paths of the directive, including the fallback branch for Firefox 144+, which supports View Transitions only with a callback signature.
Why top-layer UI needs special handling
The top layer is where browsers render modal dialogs and popovers. It sits above every other element, outside all normal stacking contexts — with one exception: the ::view-transition snapshot tree is composited above the top layer. That's the bug behind livewire/flux#2740: a Flux toast (implemented as a popover) could be open and interactive yet remain visually buried under the old/new page snapshots for the full duration of a wire:transition animation.
Since CSS cannot repaint that ordering, the only reliable fix is to not run the transition while top-layer UI is visible — and to abort it if top-layer UI appears mid-flight. Livewire already did both for modal dialogs (including dialogs opened during a transition via Alpine x-effect); this PR extends the same policy to popovers.
The scenario in practice
<div>
@foreach ($items as $index => $item)
<div wire:transition="card-{{ $index }}" wire:key="item-{{ $index }}">
{{ $item }}
</div>
@endforeach
<button wire:click="save">Save</button>
{{-- A Flux toast is a popover in the top layer --}}
<div popover x-effect="if ($wire.saved) $el.showPopover()">Saved!</div>
</div>
Before this fix, clicking "Save" re-rendered the list, Livewire started a view transition, and the toast opened via x-effect — but the transition snapshots kept painting over it until the animation finished. After the fix, the captured beforetoggle event fires the moment the popover opens, the transition is skipped, the morph applies immediately, and the toast is visible on the very next frame. Transitions that run without any top-layer UI involved continue exactly as before.
What "skipping" actually skips
Only the animation — never your data. The directive awaits transition.updateCallbackDone, so the callback that performs the morph always runs, even when skipTransition() is called. State and DOM stay correct; you simply lose the cosmetic transition for that one update.
The promise cleanup matters here too. In Chromium, calling skipTransition() guarantees that transition.ready rejects, and previously an intentional policy skip could log an unhandled rejection as if something had failed. The new regression test asserts both outcomes: no animations are still running once the popover opens, and no unhandled rejection is recorded (window.__transitionUnhandledRejection stays null).
Upgrading from v3.8.10
- Drop-in. No public API, config, or markup changes — update the package (or rebuild assets with
npm run build) and the fix applies automatically. - Intentional behavior change. View transitions are now suppressed whenever a
dialog:modalor:popover-openelement is on screen, and aborted when one opens mid-transition. If you previously relied on animations continuing behind an open popover, they will now be skipped — consistent with the long-standing dialog behavior. - Validation. The
SupportTransitionsbrowser suite passes with 8 tests and 25 assertions, and the original Flux toast case plus a transition-only control were verified in a local before/after playground.
Takeaways
wire:transitionnow treats popovers like modal dialogs: top-layer UI always wins, transitions defer.- Pre-flight skip for
dialog:modal, :popover-open, plus a mid-flight abort via a capturingbeforetogglelistener. - The
open-attributeMutationObserverremains as a fallback for browsers that don't firebeforetoggleonshowModal(). - Skips only drop the animation; the morph always applies, and expected rejections are consumed so consoles stay clean.
- No upgrade steps are required from v3.8.10 beyond pulling the new build.
Category: fix (id 3)