Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Stop propagating portaled syntetic events
  • Loading branch information
jsnajdr committed Oct 22, 2026
commit 5e7f1659ddc97c87634cc5af37abdc1d1304494a
2 changes: 1 addition & 1 deletion packages/block-editor/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
- `ColorPanel`: Fix inconsistent heading level and all-caps styling on the Elements panel by defaulting to heading level 2 ([#85247](https://github.com/WordPress/gutenberg/pull/85247)).
- Position, Text shadow: Show the "Default" option as selected instead of the `Select` placeholder ([#85172](https://github.com/WordPress/gutenberg/pull/85172)).
- Leave the props that `LineHeightControl`, `LetterSpacingControl`, `TextIndentControl`, `ColorPaletteControl`, `ColorGradientSettingsDropdown` and `FontSizePicker` override out of their types, and accept partial color objects in the color lookups ([#85213](https://github.com/WordPress/gutenberg/pull/85213)).
- Forward `pointerdown`, `mousedown`, and `click` from the editor canvas iframe onto the outer `<iframe>` element so parent-document popups can dismiss on canvas presses ([#85263](https://github.com/WordPress/gutenberg/pull/85263)).
- Forward `pointerdown`, `mousedown`, and `click` from the editor canvas iframe onto the outer `<iframe>` element so parent-document popups can dismiss on canvas presses, and stop React portal bubbling for those redispatched events so parent trees do not see them twice ([#85263](https://github.com/WordPress/gutenberg/pull/85263)).

### Internal

Expand Down
156 changes: 92 additions & 64 deletions packages/block-editor/src/components/iframe/index.tsx

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ui bridge also covered the site preview iframes (SitePreview in edit-site and boot), which don't bubble events. Should they get the same bubbling, or are we fine leaving that gap since they only render for classic themes on the home route?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SitePreview is interesting. It's a plain <iframe src={homeUrl} />, no portals, no srcdoc. homeUrl is a same-origin URL, but not always. On WordPress.com Simple the admin can be example.wordpress.com/wp-admin while the site is example.blog, a mapped domain.

It's more like a cross-origin embed, which we can't handle anyway. Make we can sacrifice these frames, I don't know how big the practical issue is.

Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import clsx from 'clsx';
import { version as reactVersion } from 'react';
import type { CSSProperties, ForwardedRef, Ref, SyntheticEvent } from 'react';
import type {
CSSProperties,
EventHandler,
ForwardedRef,
IframeHTMLAttributes,
Ref,
SyntheticEvent,
} from 'react';
import { useState, createPortal, forwardRef } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { useMergeRefs, useRefEffect, useDisabled } from '@wordpress/compose';
Expand All @@ -13,6 +20,30 @@ import { store as blockEditorStore } from '../../store';
import type { ResolvedAssets } from '../../store/types';
import type { EventConstructor, IframeProps } from './types';

/**
* Events redispatched onto the outer `<iframe>` element.
*
* Native listeners attach inside the iframe document (`useBubbleEvents`).
* Matching React portal bubbles are stopped so parents do not also see the
* portal delivery.
*/
const BRIDGED_EVENTS = [
{ native: 'dragover', react: 'onDragOver', ctor: DragEvent },
{ native: 'mousemove', react: 'onMouseMove', ctor: MouseEvent },
{ native: 'pointerdown', react: 'onPointerDown', ctor: PointerEvent },
{ native: 'mousedown', react: 'onMouseDown', ctor: MouseEvent },
{ native: 'click', react: 'onClick', ctor: MouseEvent },
{ native: 'keydown', react: 'onKeyDown', ctor: KeyboardEvent },
] as const satisfies ReadonlyArray< {
native: string;
react: keyof IframeHTMLAttributes< HTMLIFrameElement >;
ctor: EventConstructor;
} >;

type BridgedReactEvent = ( typeof BRIDGED_EVENTS )[ number ][ 'react' ];
type BridgedEventHandler =
EventHandler< SyntheticEvent< HTMLIFrameElement > > | undefined;

function bubbleEvent(
event: Event | SyntheticEvent,
Constructor: EventConstructor,
Expand Down Expand Up @@ -59,19 +90,64 @@ function bubbleEvent(
}
}

/**
* Stops a React synthetic event that bubbled across the portal boundary from
* the iframe document into the parent React tree.
*
* @param event React synthetic event that may have crossed documents.
*/
function stopReactPortalPropagation( event: SyntheticEvent ) {
if (
event.currentTarget.ownerDocument ===
( event.target as Element ).ownerDocument
) {
return;
}

const { stopPropagation } = event.nativeEvent;
event.nativeEvent.stopPropagation = () => {};
event.stopPropagation();
event.nativeEvent.stopPropagation = stopPropagation;
}

/**
* React props that stop portal bubbles for redispatched events at the outer
* `<iframe>`. Forwards any matching consumer handler first.
*
* @param props Iframe props that may include event handlers.
*/
function getPortalBoundaryEventProps( props: IframeProps ) {
const boundaryProps = {} as Pick<
IframeHTMLAttributes< HTMLIFrameElement >,
BridgedReactEvent
>;

for ( const { react: propName } of BRIDGED_EVENTS ) {
boundaryProps[ propName ] = ( event ) => {
( props[ propName ] as BridgedEventHandler )?.( event );
stopReactPortalPropagation( event );
};
}

return boundaryProps;
}

/**
* Bubbles selected native events from the iframe document onto the outer
* `<iframe>` element so parent-document listeners can observe them.
*
* `dragover` and `mousemove` keep drag-and-drop and pointer tracking working
* across the frame boundary. `pointerdown`, `mousedown`, and `click` let
* parent-document popups (menus, selects, etc.) treat a canvas press as an
* outside press. Keyboard shortcuts are bubbled separately from the React
* `onKeyDown` handler on the iframe element.
* - `dragover` and `mousemove` keep drag-and-drop and pointer tracking working
* across the frame boundary.
* - `pointerdown`, `mousedown`, and `click` let parent-document popups treat
* a canvas press as an outside press.
* - `keydown` lets parent shortcuts see canvas keystrokes.
*
* Ideally, we should remove event bubbling in the future. Keyboard shortcuts
* should be context dependent, e.g. actions on blocks like Cmd+A should not
* work globally outside the block editor.
* Pair with `getPortalBoundaryEventProps` so React ancestors do not also
* receive the portal-bubbled synthetic event for those same interactions.
*
* Ideally, we should remove the `keydown` event bubbling in the future.
* Keyboard shortcuts should be context dependent, e.g. actions on blocks
* like Cmd+A should not work globally outside the block editor.
*
* @param iframeDocument Document to attach listeners to.
*/
Expand All @@ -85,35 +161,17 @@ function useBubbleEvents( iframeDocument: Document | undefined ) {
}
const { frameElement } = defaultView;
const html = iframeDocument!.documentElement;
const eventTypes = [
'dragover',
'mousemove',
'pointerdown',
'mousedown',
'click',
];
const handlers: Record< string, ( event: Event ) => void > = {};
for ( const name of eventTypes ) {
handlers[ name ] = ( event ) => {
const prototype = Object.getPrototypeOf( event );
const constructorName = prototype.constructor.name;
// The window has the constructors of the events it dispatches.
const Constructor = (
window as unknown as Record< string, EventConstructor >
)[ constructorName ];
// The iframe window's frame is the iframe.
bubbleEvent(
event,
Constructor,
frameElement as HTMLIFrameElement
);
for ( const { native, ctor } of BRIDGED_EVENTS ) {
handlers[ native ] = ( event ) => {
bubbleEvent( event, ctor, frameElement as HTMLIFrameElement );
};
html.addEventListener( name, handlers[ name ] );
html.addEventListener( native, handlers[ native ] );
}

return () => {
for ( const name of eventTypes ) {
html.removeEventListener( name, handlers[ name ] );
for ( const { native } of BRIDGED_EVENTS ) {
html.removeEventListener( native, handlers[ native ] );
}
};
} );
Expand Down Expand Up @@ -355,9 +413,9 @@ function Iframe( {
const iframe = (
<>
{ shouldRenderFocusCaptureElements && before }
{ /* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */ }
<iframe
{ ...props }
{ ...getPortalBoundaryEventProps( props ) }
style={ {
...props.style,
height: props.style?.height,
Expand All @@ -371,36 +429,6 @@ function Iframe( {
tabIndex={ tabIndex }
src={ src }
title={ title }
onKeyDown={ ( event ) => {
if ( props.onKeyDown ) {
props.onKeyDown( event );
}
// If the event originates from inside the iframe, it means
// it bubbled through the portal, but only with React
// events. We need to bubble native events as well,
// though by doing so we also trigger another React event,
// so we need to stop the propagation of this event to avoid
// duplication.
if (
event.currentTarget.ownerDocument !==
( event.target as Element ).ownerDocument
) {
// We should only stop propagation of the React event,
// the native event should further bubble inside the
// iframe to the document and window.
// Alternatively, we could consider redispatching the
// native event in the iframe.
const { stopPropagation } = event.nativeEvent;
event.nativeEvent.stopPropagation = () => {};
event.stopPropagation();
event.nativeEvent.stopPropagation = stopPropagation;
bubbleEvent(
event,
window.KeyboardEvent,
event.currentTarget
);
}
} }
>
{ iframeDocument &&
createPortal(
Expand Down