Skip to content
Merged
Show file tree
Hide file tree
Changes from 25 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 { subscribeSharedListener } = unlock( composePrivateApis );
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.

I wouldn't be afraid to make it public immediately. It's a solid API and there are no doubts about its usefulness.

One little suggestion: instead of the capture param, support options = { capture } object. Or both, the goal is symmetry with addEventListener.

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.

The only thing I'm not sure of is the naming 😆

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.

I was thinking about subscribeDocumentListener, but that doesn't work very well for window 🙂

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

addSharedListener ?

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.

It shouldn't be "add" because it doesn't behave like addEventListener. It returns an unsubscribe callback, there is no "remove" function like removeEventListener. So, the "subscribe" part is right 🙂 It's about the adjective: "shared listener", "document listener"?

I asked Cursor (inside React repo context, where this pattern is used for the entire event system) and it says firmly it should be subscribeDelegatedListener, insisting that "event delegation" is a standard established name for what we're doing.

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.

I like subscribeDelegatedListener

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.

I renamed, but I'll leave it private for now. It's always easy to make public.


/**
* 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 subscribeSharedListener( 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 { subscribeSharedListener } = 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 = subscribeSharedListener(
node,
'mouseout',
listener
);
const unsubscribeOver = subscribeSharedListener(
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 { subscribeSharedListener } = 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 = subscribeSharedListener(
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 { subscribeSharedListener } = 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 subscribeSharedListener( defaultView, 'paste', _onPaste );
};
14 changes: 9 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 { subscribeSharedListener } = unlock( composePrivateApis );

const LINK_SETTINGS = [
...LinkControl.DEFAULT_LINK_SETTINGS,
Expand Down Expand Up @@ -107,10 +112,9 @@ 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 subscribeSharedListener( element, 'keydown', onKeyDown, true );
}, [] );
}

Expand Down
15 changes: 10 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 { subscribeSharedListener } = unlock( composePrivateApis );

export default function useEnter( props ) {
const { replaceBlocks, selectionChange } = useDispatch( blockEditorStore );
Expand Down Expand Up @@ -82,9 +88,8 @@ 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 subscribeSharedListener( 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 { subscribeSharedListener } = 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 subscribeSharedListener(
element,
'keydown',
onKeyDown,
true
);
},
[ clientId, indentListItem ]
);
Expand Down
19 changes: 14 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 { subscribeSharedListener } = unlock( composePrivateApis );

export function useOnEnter( props ) {
const { batch } = useRegistry();
const { moveBlocksToPosition, replaceBlocks, selectionChange } =
Expand Down Expand Up @@ -119,9 +129,8 @@ 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 subscribeSharedListener( 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 `subscribeSharedListener` 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 { subscribeSharedListener } = 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 subscribeSharedListener(
element,
'keydown',
_onKeyDown,
true
);
}, [] ),
] );

Expand Down
4 changes: 4 additions & 0 deletions packages/compose/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,10 @@ _Related_

- <https://lodash.com/docs/4#flow>

### privateApis

Private @wordpress/compose APIs.

### pure

> **Deprecated** Use `memo` or `PureComponent` instead.
Expand Down
Loading
Loading