Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
3c24501
Rich text: Share window listeners across RichText instances
ellatrix May 14, 2026
0c8e189
Rich text: Extend shared listeners to block-editor's enter and paste …
ellatrix May 14, 2026
9d6b776
Rich text: Share the keydown listener for full-content delete
ellatrix May 18, 2026
9de866d
Rich text: Share the keydown listener for format boundaries
ellatrix May 18, 2026
f7a6880
Rich text: Use capture phase for format-boundaries shared listener
ellatrix May 18, 2026
b508cdc
Rich text: Share the click and focusin listeners for object selection
ellatrix May 18, 2026
769437c
Rich text: Share the pointerdown and keydown listeners for selection-…
ellatrix May 18, 2026
d4c1ae8
Rich text: Share the input/composition/focus listeners
ellatrix May 18, 2026
a66ea64
Rich text: Use capture phase for input and compositionend shared list…
ellatrix May 19, 2026
94939d6
Rich text: Bail in onFocus when contentEditable is false
ellatrix May 19, 2026
8d6cd58
Block editor: Share the focusin listener for per-block focus handling
ellatrix May 19, 2026
4b784e1
Block editor: Share the mouseover and mouseout listeners for hover
ellatrix May 19, 2026
9ef966a
Compose: Hoist subscribeSharedListener from rich-text
ellatrix May 19, 2026
277c285
Compose: Add TypeScript types to subscribeSharedListener
ellatrix May 19, 2026
b6c77af
Autocomplete: Share the keydown listener across instances
ellatrix May 19, 2026
7a4a92a
Autocomplete: Use Event type for shared listener callback
ellatrix May 19, 2026
eb2415e
Block library: Share per-block keydown listeners for use-enter/use-space
ellatrix May 19, 2026
6e208e4
Components: Add CHANGELOG entry for Autocomplete listener sharing
ellatrix May 19, 2026
51a5355
Use === guards in keydown/input shared listeners
ellatrix May 19, 2026
e3681e8
Compose: subscribeSharedListener accepts Element for ancestry dispatch
ellatrix May 20, 2026
ec3ae61
Compose: Fix subscribeSharedListener cross-realm Document/Window check
ellatrix May 20, 2026
9cda3ba
Compose: Make subscribeSharedListener private
ellatrix May 20, 2026
b11e62f
Private APIs: Allow @wordpress/compose to opt in
ellatrix May 20, 2026
55e94fa
Compose: Reference @wordpress/private-apis in tsconfig
ellatrix May 20, 2026
7c84727
Compose: Update lockfile for @wordpress/private-apis dependency
ellatrix May 20, 2026
81c691d
Compose: Weakly hold element subscribers in subscribeSharedListener
ellatrix May 20, 2026
aa309f0
Compose: Rename subscribeSharedListener → subscribeDelegatedListener
ellatrix May 20, 2026
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
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,19 @@
* WordPress dependencies
*/
import { useSelect, useDispatch } from '@wordpress/data';
import { useRefEffect } from '@wordpress/compose';
import {
useRefEffect,
privateApis as composePrivateApis,
} from '@wordpress/compose';

/**
* Internal dependencies
*/
import { isInsideRootBlock } from '../../../utils/dom';
import { store as blockEditorStore } from '../../../store';
import { unlock } from '../../../lock-unlock';

const { subscribeDelegatedListener } = unlock( composePrivateApis );

/**
* Selects the block if it receives focus.
Expand Down Expand Up @@ -57,11 +63,7 @@ export function useFocusHandler( clientId ) {
selectBlock( clientId );
}

node.addEventListener( 'focusin', onFocus );

return () => {
node.removeEventListener( 'focusin', onFocus );
};
return subscribeDelegatedListener( node, 'focusin', onFocus );
},
[ isBlockSelected, selectBlock ]
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,17 @@
/**
* WordPress dependencies
*/
import { useRefEffect } from '@wordpress/compose';
import {
useRefEffect,
privateApis as composePrivateApis,
} from '@wordpress/compose';

function listener( event ) {
if ( event.defaultPrevented ) {
return;
}
/**
* Internal dependencies
*/
import { unlock } from '../../../lock-unlock';

event.preventDefault();
event.currentTarget.classList.toggle(
'is-hovered',
event.type === 'mouseover'
);
}
const { subscribeDelegatedListener } = unlock( composePrivateApis );

/**
* Adds `is-hovered` class when the block is hovered and in navigation or
Expand All @@ -31,12 +29,31 @@ export function useIsHovered( { isEnabled = true } = {} ) {
return;
}

node.addEventListener( 'mouseout', listener );
node.addEventListener( 'mouseover', listener );
function listener( event ) {
if ( event.defaultPrevented ) {
return;
}
event.preventDefault();
node.classList.toggle(
'is-hovered',
event.type === 'mouseover'
);
}

const unsubscribeOut = subscribeDelegatedListener(
node,
'mouseout',
listener
);
const unsubscribeOver = subscribeDelegatedListener(
node,
'mouseover',
listener
);

return () => {
node.removeEventListener( 'mouseout', listener );
node.removeEventListener( 'mouseover', listener );
unsubscribeOut();
unsubscribeOver();

// Remove class in case it lingers.
node.classList.remove( 'is-hovered' );
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@
*/
import { ENTER } from '@wordpress/keycodes';
import { insert, remove } from '@wordpress/rich-text';
import { privateApis as composePrivateApis } from '@wordpress/compose';

/**
* Internal dependencies
*/
import { unlock } from '../../../lock-unlock';

const { subscribeDelegatedListener } = unlock( composePrivateApis );

export default ( props ) => ( element ) => {
function onKeyDownDeprecated( event ) {
Expand Down Expand Up @@ -75,10 +83,14 @@ export default ( props ) => ( element ) => {

// Attach the listener to the window so parent elements have the chance to
// prevent the default behavior.
defaultView.addEventListener( 'keydown', onKeyDown );
const unsubscribeKeyDown = subscribeDelegatedListener(
defaultView,
'keydown',
onKeyDown
);
element.addEventListener( 'keydown', onKeyDownDeprecated );
return () => {
defaultView.removeEventListener( 'keydown', onKeyDown );
unsubscribeKeyDown();
element.removeEventListener( 'keydown', onKeyDownDeprecated );
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,17 @@
import { pasteHandler } from '@wordpress/blocks';
import { isEmpty, insert, create } from '@wordpress/rich-text';
import { isURL } from '@wordpress/url';
import { privateApis as composePrivateApis } from '@wordpress/compose';

/**
* Internal dependencies
*/
import { store as blockEditorStore } from '../../../store';
import { addActiveFormats } from '../utils';
import { getPasteEventData } from '../../../utils/pasting';
import { unlock } from '../../../lock-unlock';

const { subscribeDelegatedListener } = unlock( composePrivateApis );

/** @typedef {import('@wordpress/rich-text').RichTextValue} RichTextValue */

Expand Down Expand Up @@ -141,8 +145,5 @@ export default ( props ) => ( element ) => {

// Attach the listener to the window so parent elements have the chance to
// prevent the default behavior.
defaultView.addEventListener( 'paste', _onPaste );
return () => {
defaultView.removeEventListener( 'paste', _onPaste );
};
return subscribeDelegatedListener( defaultView, 'paste', _onPaste );
};
19 changes: 14 additions & 5 deletions packages/block-library/src/button/edit.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@ import {
getDefaultBlockName,
getBlockBindingsSource,
} from '@wordpress/blocks';
import { useMergeRefs, useRefEffect } from '@wordpress/compose';
import {
useMergeRefs,
useRefEffect,
privateApis as composePrivateApis,
} from '@wordpress/compose';
import { useSelect, useDispatch } from '@wordpress/data';
import { NEW_TAB_TARGET, NOFOLLOW_REL } from './constants';
import { getUpdatedLinkAttributes } from './get-updated-link-attributes';
Expand All @@ -49,6 +53,7 @@ import useDeprecatedTextAlign from '../utils/deprecated-text-align-attributes';
import { getWidthClasses, isPercentageWidth } from './utils';

const { HTMLElementControl } = unlock( blockEditorPrivateApis );
const { subscribeDelegatedListener } = unlock( composePrivateApis );

const LINK_SETTINGS = [
...LinkControl.DEFAULT_LINK_SETTINGS,
Expand Down Expand Up @@ -107,10 +112,14 @@ function useEnter( props ) {
selectionChange( middle.clientId );
}

element.addEventListener( 'keydown', onKeyDown );
return () => {
element.removeEventListener( 'keydown', onKeyDown );
};
// Capture phase so we run before writing-flow's ancestor-bubble
// keydown handlers that gate on `event.defaultPrevented`.
return subscribeDelegatedListener(
element,
'keydown',
onKeyDown,
true
);
}, [] );
}

Expand Down
20 changes: 15 additions & 5 deletions packages/block-library/src/list-item/hooks/use-enter.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import {
cloneBlock,
} from '@wordpress/blocks';
import { useRef } from '@wordpress/element';
import { useRefEffect } from '@wordpress/compose';
import {
useRefEffect,
privateApis as composePrivateApis,
} from '@wordpress/compose';
import { ENTER } from '@wordpress/keycodes';
import { useSelect, useDispatch } from '@wordpress/data';
import { store as blockEditorStore } from '@wordpress/block-editor';
Expand All @@ -16,6 +19,9 @@ import { store as blockEditorStore } from '@wordpress/block-editor';
* Internal dependencies
*/
import useOutdentListItem from './use-outdent-list-item';
import { unlock } from '../../lock-unlock';

const { subscribeDelegatedListener } = unlock( composePrivateApis );

export default function useEnter( props ) {
const { replaceBlocks, selectionChange } = useDispatch( blockEditorStore );
Expand Down Expand Up @@ -82,9 +88,13 @@ export default function useEnter( props ) {
selectionChange( middle.clientId );
}

element.addEventListener( 'keydown', onKeyDown );
return () => {
element.removeEventListener( 'keydown', onKeyDown );
};
// Capture phase so we run before writing-flow's ancestor-bubble
// keydown handlers that gate on `event.defaultPrevented`.
return subscribeDelegatedListener(
element,
'keydown',
onKeyDown,
true
);
}, [] );
}
20 changes: 15 additions & 5 deletions packages/block-library/src/list-item/hooks/use-space.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
/**
* WordPress dependencies
*/
import { useRefEffect } from '@wordpress/compose';
import {
useRefEffect,
privateApis as composePrivateApis,
} from '@wordpress/compose';
import { SPACE, TAB } from '@wordpress/keycodes';
import { store as blockEditorStore } from '@wordpress/block-editor';
import { useSelect } from '@wordpress/data';
Expand All @@ -11,6 +14,9 @@ import { useSelect } from '@wordpress/data';
*/
import useIndentListItem from './use-indent-list-item';
import useOutdentListItem from './use-outdent-list-item';
import { unlock } from '../../lock-unlock';

const { subscribeDelegatedListener } = unlock( composePrivateApis );

export default function useSpace( clientId ) {
const { getSelectionStart, getSelectionEnd, getBlockIndex } =
Expand Down Expand Up @@ -55,10 +61,14 @@ export default function useSpace( clientId ) {
}
}

element.addEventListener( 'keydown', onKeyDown );
return () => {
element.removeEventListener( 'keydown', onKeyDown );
};
// Capture phase so we run before writing-flow's ancestor-bubble
// keydown handlers that gate on `event.defaultPrevented`.
return subscribeDelegatedListener(
element,
'keydown',
onKeyDown,
true
);
},
[ clientId, indentListItem ]
);
Expand Down
24 changes: 19 additions & 5 deletions packages/block-library/src/paragraph/use-enter.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@
* WordPress dependencies
*/
import { useRef } from '@wordpress/element';
import { useRefEffect } from '@wordpress/compose';
import {
useRefEffect,
privateApis as composePrivateApis,
} from '@wordpress/compose';
import { ENTER } from '@wordpress/keycodes';
import { useSelect, useDispatch, useRegistry } from '@wordpress/data';
import { store as blockEditorStore } from '@wordpress/block-editor';
Expand All @@ -13,6 +16,13 @@ import {
getDefaultBlockName,
} from '@wordpress/blocks';

/**
* Internal dependencies
*/
import { unlock } from '../lock-unlock';

const { subscribeDelegatedListener } = unlock( composePrivateApis );

export function useOnEnter( props ) {
const { batch } = useRegistry();
const { moveBlocksToPosition, replaceBlocks, selectionChange } =
Expand Down Expand Up @@ -119,9 +129,13 @@ export function useOnEnter( props ) {
} );
}

element.addEventListener( 'keydown', onKeyDown );
return () => {
element.removeEventListener( 'keydown', onKeyDown );
};
// Capture phase so we run before writing-flow's ancestor-bubble
// keydown handlers that gate on `event.defaultPrevented`.
return subscribeDelegatedListener(
element,
'keydown',
onKeyDown,
true
);
}, [] );
}
4 changes: 4 additions & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@

- `Popover`: Don't close when focus moves into the `@wordpress/ui` compat overlay slot, or is restored to the popover from any portaled descendant. This unblocks nested overlays such as `@wordpress/ui` `Select`, which previously dismissed the host `Popover` on hover and on overlay dismissal ([#78407](https://github.com/WordPress/gutenberg/pull/78407)).

### Internal

- `Autocomplete`: Share the per-instance `keydown` listener across instances via `subscribeDelegatedListener` so a typical post-editor mount adds 1 native keydown listener on the document instead of one per `RichText` ([#78310](https://github.com/WordPress/gutenberg/pull/78310)).

## 33.1.0 (2026-05-14)

### Enhancements
Expand Down
32 changes: 25 additions & 7 deletions packages/components/src/autocomplete/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ import {
useReducer,
useRef,
} from '@wordpress/element';
import { useInstanceId, useMergeRefs, useRefEffect } from '@wordpress/compose';
import {
useInstanceId,
useMergeRefs,
useRefEffect,
privateApis as composePrivateApis,
} from '@wordpress/compose';
import {
create,
slice,
Expand Down Expand Up @@ -36,6 +41,9 @@ import type {
UseAutocompleteProps,
} from './types';
import getNodeText from '../utils/get-node-text';
import { unlock } from '../lock-unlock';

const { subscribeDelegatedListener } = unlock( composePrivateApis );

const EMPTY_FILTERED_OPTIONS: KeyedOption[] = [];

Expand Down Expand Up @@ -390,13 +398,23 @@ export function useAutocompleteProps( options: UseAutocompleteProps ) {
const mergedRefs = useMergeRefs( [
ref,
useRefEffect( ( element: HTMLElement ) => {
function _onKeyDown( event: KeyboardEvent ) {
onKeyDownRef.current?.( event );
function _onKeyDown( event: Event ) {
onKeyDownRef.current?.( event as KeyboardEvent );
}
element.addEventListener( 'keydown', _onKeyDown );
return () => {
element.removeEventListener( 'keydown', _onKeyDown );
};
// Capture phase. When the autocomplete popover is open,
// Up/Down/Enter/Escape must navigate the completion list —
// they shouldn't be consumed by ancestor handlers (e.g.
// block-editor's writing-flow) for block navigation, block
// splitting, or "move out of parent" actions. Those handlers
// fire at bubble phase and gate on `event.defaultPrevented`,
// so firing in capture lets us preventDefault first when the
// popover is active.
return subscribeDelegatedListener(
element,
'keydown',
_onKeyDown,
true
);
}, [] ),
] );

Expand Down
Loading
Loading