Skip to content
Merged
Show file tree
Hide file tree
Changes from 19 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
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* WordPress dependencies
*/
import { useSelect, useDispatch } from '@wordpress/data';
import { useRefEffect } from '@wordpress/compose';
import { useRefEffect, subscribeSharedListener } from '@wordpress/compose';

/**
* Internal dependencies
Expand Down Expand Up @@ -30,6 +30,12 @@ export function useFocusHandler( clientId ) {
* @param {FocusEvent} event Focus event.
*/
function onFocus( event ) {
// Document-scoped listener: bail when focus didn't land
// inside this block's wrapper.
if ( ! node.contains( event.target ) ) {
return;
}
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'd expect the node check to be inside subscribeSharedListener. The React event system has only one document listener and then calls individual node listeners according to event.target. Our version calls all listeners on all events, even when they happen in a different element.

A better API would be:

subscribeSharedListener( node, 'focusin', onFocus );

where the onFocus handler is called only when the event really happens inside node, and the helper can figure out node.ownerDocument from the node.

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.

Yes, it's a good idea. Adjusted 🙂


// When the whole editor is editable, let writing flow handle
// selection.
if (
Expand Down Expand Up @@ -57,11 +63,11 @@ export function useFocusHandler( clientId ) {
selectBlock( clientId );
}

node.addEventListener( 'focusin', onFocus );

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

function listener( event ) {
if ( event.defaultPrevented ) {
return;
}

event.preventDefault();
event.currentTarget.classList.toggle(
'is-hovered',
event.type === 'mouseover'
);
}
import { useRefEffect, subscribeSharedListener } from '@wordpress/compose';

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

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

const unsubscribeOut = subscribeSharedListener(
node.ownerDocument,
'mouseout',
listener
);
const unsubscribeOver = subscribeSharedListener(
node.ownerDocument,
'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,7 @@
*/
import { ENTER } from '@wordpress/keycodes';
import { insert, remove } from '@wordpress/rich-text';
import { subscribeSharedListener } from '@wordpress/compose';

export default ( props ) => ( element ) => {
function onKeyDownDeprecated( event ) {
Expand Down Expand Up @@ -75,10 +76,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,6 +4,7 @@
import { pasteHandler } from '@wordpress/blocks';
import { isEmpty, insert, create } from '@wordpress/rich-text';
import { isURL } from '@wordpress/url';
import { subscribeSharedListener } from '@wordpress/compose';

/**
* Internal dependencies
Expand Down Expand Up @@ -141,8 +142,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 );
};
21 changes: 16 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,
subscribeSharedListener,
} 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 Down Expand Up @@ -69,6 +73,9 @@ function useEnter( props ) {
if ( event.defaultPrevented || event.keyCode !== ENTER ) {
return;
}
if ( event.target !== element ) {
return;
}
const { content, clientId } = propsRef.current;
if ( content.length ) {
return;
Expand Down Expand Up @@ -107,10 +114,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 subscribeSharedListener(
element.ownerDocument,
'keydown',
onKeyDown,
true
);
}, [] );
}

Expand Down
17 changes: 12 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,7 @@ import {
cloneBlock,
} from '@wordpress/blocks';
import { useRef } from '@wordpress/element';
import { useRefEffect } from '@wordpress/compose';
import { useRefEffect, subscribeSharedListener } from '@wordpress/compose';
import { ENTER } from '@wordpress/keycodes';
import { useSelect, useDispatch } from '@wordpress/data';
import { store as blockEditorStore } from '@wordpress/block-editor';
Expand All @@ -29,6 +29,9 @@ export default function useEnter( props ) {
if ( event.defaultPrevented || event.keyCode !== ENTER ) {
return;
}
if ( event.target !== element ) {
return;
}
const { content, clientId } = propsRef.current;
if ( content.length ) {
return;
Expand Down Expand Up @@ -82,9 +85,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 subscribeSharedListener(
element.ownerDocument,
'keydown',
onKeyDown,
true
);
}, [] );
}
18 changes: 13 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,7 @@
/**
* WordPress dependencies
*/
import { useRefEffect } from '@wordpress/compose';
import { useRefEffect, subscribeSharedListener } from '@wordpress/compose';
import { SPACE, TAB } from '@wordpress/keycodes';
import { store as blockEditorStore } from '@wordpress/block-editor';
import { useSelect } from '@wordpress/data';
Expand Down Expand Up @@ -34,6 +34,10 @@ export default function useSpace( clientId ) {
return;
}

if ( event.target !== element ) {
return;
}

const selectionStart = getSelectionStart();
const selectionEnd = getSelectionEnd();
if (
Expand All @@ -55,10 +59,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.ownerDocument,
'keydown',
onKeyDown,
true
);
},
[ clientId, indentListItem ]
);
Expand Down
20 changes: 15 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,7 @@
* WordPress dependencies
*/
import { useRef } from '@wordpress/element';
import { useRefEffect } from '@wordpress/compose';
import { useRefEffect, subscribeSharedListener } from '@wordpress/compose';
import { ENTER } from '@wordpress/keycodes';
import { useSelect, useDispatch, useRegistry } from '@wordpress/data';
import { store as blockEditorStore } from '@wordpress/block-editor';
Expand Down Expand Up @@ -37,6 +37,10 @@ export function useOnEnter( props ) {
return;
}

if ( event.target !== element ) {
return;
}

const { content, clientId } = propsRef.current;

// The paragraph should be empty.
Expand Down Expand Up @@ -119,9 +123,15 @@ export function useOnEnter( props ) {
} );
}

element.addEventListener( 'keydown', onKeyDown );
return () => {
element.removeEventListener( 'keydown', onKeyDown );
};
// Capture phase so we run before writing-flow's element-level
// keydown handlers on ancestor block-list nodes
// (`use-input.js`, `use-arrow-nav.js`) that gate on
// `event.defaultPrevented`.
return subscribeSharedListener(
element.ownerDocument,
'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
34 changes: 27 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,
subscribeSharedListener,
} from '@wordpress/compose';
import {
create,
slice,
Expand Down Expand Up @@ -390,13 +395,28 @@ export function useAutocompleteProps( options: UseAutocompleteProps ) {
const mergedRefs = useMergeRefs( [
ref,
useRefEffect( ( element: HTMLElement ) => {
function _onKeyDown( event: KeyboardEvent ) {
onKeyDownRef.current?.( event );
function _onKeyDown( event: Event ) {
// Document-scoped listener: bail when the keydown isn't
// for our element.
if ( event.target !== element ) {
return;
}
onKeyDownRef.current?.( event as KeyboardEvent );
}
element.addEventListener( 'keydown', _onKeyDown );
return () => {
element.removeEventListener( 'keydown', _onKeyDown );
};
return subscribeSharedListener(
element.ownerDocument,
'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.
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 @@ -153,6 +153,10 @@ _Related_

Given a component returns the enhanced component augmented with a component only re-rendering when its props/state change

### subscribeSharedListener

Undocumented declaration.

### throttle

A simplified and properly typed version of lodash's `throttle`, that always uses timers instead of sometimes using rAF.
Expand Down
2 changes: 2 additions & 0 deletions packages/compose/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export * from './utils/debounce';
export * from './utils/throttle';
// The `ObservableMap` data structure
export * from './utils/observable-map';
// The `subscribeSharedListener` helper for event delegation.
export { default as subscribeSharedListener } from './utils/subscribe-shared-listener';

// The `compose` and `pipe` helpers (inspired by `flowRight` and `flow` from Lodash).
export { default as compose } from './higher-order/compose';
Expand Down
Loading
Loading