feat: support native dialog/popover elements#3
Merged
Conversation
lazarv
pushed a commit
that referenced
this pull request
Apr 25, 2026
This PR was opened by the [Changesets release](https://github.com/changesets/action) GitHub action. When you're ready to do a release, you can merge this and the packages will be published to npm automatically. If you're not ready to do a release yet, that's fine, whenever you add more changesets to main, this PR will be updated. # Releases ## virtual-frame@0.1.2 ### Patch Changes - [#3](#3) [`5e5b976`](5e5b976) Thanks [@lazarv](https://github.com/lazarv)! - Support native dialog/popover elements Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds top-layer projection for
<dialog>.showModal()and the Popover API across both same-origin and cross-origin modes, so the projected clone is genuinely promoted to the host document's top layer rather than just inheriting anopenattribute.Motivation
The browser only promotes an element to the top layer for the specific element the imperative method was called on. Mirroring the
openattribute through the existing MutationObserver isn't enough: without an explicitshowModal()on the clone,:modalnever matches,::backdropnever paints, the dialog stays at its UA-defaultposition: absolute(lands relative to its nearest positioned ancestor instead of the viewport), and clipping ancestors withoverflow: hiddencut it off. Popovers worked when activated declaratively viapopovertargetbecause the browser handles that locally on the cloned button, butshowPopover()/hidePopover()/togglePopover()from script had the same gap.Implementation
The fix mirrors the call itself, not just its visible side effects. In same-origin mode the host patches
HTMLDialogElement.prototypeandHTMLElement.prototypeinside the iframe's realm, looks up the mirror via the existingelementMapWeakMap, and replays the call on the clone. In cross-origin mode the bridge does the equivalent prototype patching inside the iframe and emits a newvf:invokeMethodmessage carrying{ targetId, method, args }; the host resolves the target via_remoteIdToNodeand calls the method on the clone. The same-origin patch is idempotent per window via a__vfTopLayerPatchedflag. Echo loops are prevented by a_suppressInvokecounter in the bridge and a per-nodeSymbol.for("__vfTopLayerSuppress")plus per-mirrorWeakSetin same-origin mode.The reverse direction is equally important: if the user dismisses the projected dialog via ESC, backdrop click, or an in-content close button, the source must close too, otherwise the next
showModal()throwsInvalidStateError. Both modes attach a one-shotcloselistener (ortogglelistener watching fornewState === "closed"on popovers) whenever an opener method is mirrored. The listeners self-remove on fire so re-opening doesn't accumulate handlers.Example
The vanilla example gets a new "Dialog" tab pointing at
examples/shared/dialog.html, which exercises both APIs and includes a dialog triggered from inside anoverflow: hiddenancestor — the deliberate clipping test that originally surfaced the issue. The CSS reset in the fixture is intentionally scoped tobody, h1, h2, h3, prather than* { margin: 0; padding: 0 }, because a blanket reset overrides the UA'smargin: autoondialog[open]and[popover], which is what centers them in the top layer.Test plan
Two new test files cover both transport modes.
packages/core/test/dialog.test.tsdrives the same-origin path through a real iframe fixture and asserts that sourceshowModal()causes the mirror to match:modal, thatshow()opens both ends but does not match:modal, that sourceclose()propagates to the mirror, that mirror dismissal echoes back, that closing fires the sourcecloseevent exactly once (catches echo-loop bugs), that re-opening after close works (catches stuck-suppress-flag bugs), that re-running the install is a no-op, and equivalent coverage for popover open/hide/toggle and host-side dismissal.packages/core/test/cross-origin-dialog.test.tsreuses the existingsetupCrossOrigin/bridgeSend/performHandshakeharness with synthetic postMessage to verify the inboundvf:invokeMethodhandler drives the mirror, ignores unknowntargetId, ignores methods that don't exist on the target, swallowsInvalidStateErrorfrom duplicateshowModal, and that mirror dismissal produces exactly one outbound echo with the correct method, target, and args.Manual verification: load the vanilla example, switch to the Dialog tab, click "Open dialog" and confirm the projection is centered with a backdrop covering the whole viewport; click "Open from here" inside the clipped parent and confirm the dialog still escapes the
overflow: hiddenboundary; toggle the popover and confirm it appears above all content without manualz-index.Known gaps
If a dialog is created and
showModal'd in the same tick, the MutationObserver hasn't yet shipped the new node to the host, so the clone lookup misses. This is rare in real code and out of scope here; a queue-and-flush on the next mutation pass would address it.requestClose()andCloseWatcherare not intercepted specifically — the resultingcloseevent still round-trips correctly, but the cancel-able close pattern would need its own path if anyone relies on it.