From 18987f0b186cdafb522115b0097196375f814d6c Mon Sep 17 00:00:00 2001 From: ella Date: Thu, 14 May 2026 17:51:21 +0200 Subject: [PATCH] Autocomplete: Skip work for unselected blocks via isEnabled flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The block-editor `RichText` calls `useBlockEditorAutocompleteProps` on every render, for every block. The hook chain (`useBlockEditorAutocompleteProps` → `useCompleters` → `useAutocompleteProps` → `useAutocomplete`) sets up the autocomplete state machine, attaches a `keydown` listener, runs match scanning, and filters the completer list — work that's only meaningful for the *selected* block, since autocomplete only triggers from user typing. In a representative post-editor first-block trace, the cluster accounts for ~30 ms self time during the React commit, split across 1000+ RichText instances. After #78303 (getter-export fix) lands the package-getter portion of that goes away, leaving ~10 ms of genuinely conditional work. Add an `isEnabled` option (default `true`) to `useAutocomplete` and `useAutocompleteProps` in `@wordpress/components` and to `useCompleters` / `useBlockEditorAutocompleteProps` in `@wordpress/block-editor`. When `false`: - The `textContent` `useMemo` returns `''` without slicing the record. - The match-scanning `useEffect` bails out. - The keydown listener is not attached. - The completer filter loop short-circuits to `EMPTY_ARRAY`. - The hook still returns a stable `ref`, so the host attaches it unconditionally and the listener wires up naturally on the next render once the block is selected. `RichText` passes `isEnabled: isSelected` so only the selected block runs the full machinery. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/components/autocomplete/index.js | 7 +++- .../src/components/rich-text/index.js | 1 + .../components/src/autocomplete/index.tsx | 38 +++++++++++++------ packages/components/src/autocomplete/types.ts | 7 ++++ 4 files changed, 39 insertions(+), 14 deletions(-) diff --git a/packages/block-editor/src/components/autocomplete/index.js b/packages/block-editor/src/components/autocomplete/index.js index 9584f658e0a448..f6c0ff0b3d6b63 100644 --- a/packages/block-editor/src/components/autocomplete/index.js +++ b/packages/block-editor/src/components/autocomplete/index.js @@ -23,9 +23,12 @@ import blockAutocompleter from '../../autocompleters/block'; */ const EMPTY_ARRAY = []; -function useCompleters( { completers = EMPTY_ARRAY } ) { +function useCompleters( { completers = EMPTY_ARRAY, isEnabled = true } ) { const { name } = useBlockEditContext(); return useMemo( () => { + if ( ! isEnabled ) { + return EMPTY_ARRAY; + } let filteredCompleters = [ ...completers ]; if ( @@ -51,7 +54,7 @@ function useCompleters( { completers = EMPTY_ARRAY } ) { } return filteredCompleters; - }, [ completers, name ] ); + }, [ completers, name, isEnabled ] ); } export function useBlockEditorAutocompleteProps( props ) { diff --git a/packages/block-editor/src/components/rich-text/index.js b/packages/block-editor/src/components/rich-text/index.js index 2d3c724f7c4d70..4ab89aded8ec95 100644 --- a/packages/block-editor/src/components/rich-text/index.js +++ b/packages/block-editor/src/components/rich-text/index.js @@ -371,6 +371,7 @@ export function RichTextWrapper( completers: autocompleters, record: value, onChange, + isEnabled: isSelected, } ); useMarkPersistent( { html: adjustedValue, value } ); diff --git a/packages/components/src/autocomplete/index.tsx b/packages/components/src/autocomplete/index.tsx index f33608440a4c6f..da3eac6e9465ab 100644 --- a/packages/components/src/autocomplete/index.tsx +++ b/packages/components/src/autocomplete/index.tsx @@ -101,6 +101,7 @@ export function useAutocomplete( { onReplace, completers, contentRef, + isEnabled = true, }: UseAutocompleteProps ) { const instanceId = useInstanceId( AUTOCOMPLETE_HOOK_REFERENCE ); const [ state, dispatch ] = useReducer( autocompleteReducer, initialState ); @@ -241,13 +242,19 @@ export function useAutocomplete( { // but this is a preemptive performance improvement, since the autocompleter // is a potential bottleneck for the editor type metric. const textContent = useMemo( () => { + if ( ! isEnabled ) { + return ''; + } if ( isCollapsed( record ) ) { return getTextContent( slice( record, 0 ) ); } return ''; - }, [ record ] ); + }, [ record, isEnabled ] ); useEffect( () => { + if ( ! isEnabled ) { + return; + } const isTextChange = record.text !== prevRecordTextRef.current; prevRecordTextRef.current = record.text; @@ -302,7 +309,7 @@ export function useAutocomplete( { dispatch( { type: 'MATCH', completer, query } ); // We want to avoid introducing unexpected side effects. // See https://github.com/WordPress/gutenberg/pull/41820 - }, [ textContent ] ); + }, [ textContent, isEnabled ] ); const { key: selectedKey = '' } = filteredOptions[ selectedIndex ] || {}; const { className } = autocompleter || {}; @@ -376,6 +383,7 @@ export function useLastDifferentValue( } export function useAutocompleteProps( options: UseAutocompleteProps ) { + const { isEnabled = true } = options; const ref = useRef< HTMLElement >( null ); const onKeyDownRef = useRef< ( event: KeyboardEvent ) => void >( undefined ); @@ -389,19 +397,25 @@ export function useAutocompleteProps( options: UseAutocompleteProps ) { const mergedRefs = useMergeRefs( [ ref, - useRefEffect( ( element: HTMLElement ) => { - function _onKeyDown( event: KeyboardEvent ) { - onKeyDownRef.current?.( event ); - } - element.addEventListener( 'keydown', _onKeyDown ); - return () => { - element.removeEventListener( 'keydown', _onKeyDown ); - }; - }, [] ), + useRefEffect( + ( element: HTMLElement ) => { + if ( ! isEnabled ) { + return; + } + function _onKeyDown( event: KeyboardEvent ) { + onKeyDownRef.current?.( event ); + } + element.addEventListener( 'keydown', _onKeyDown ); + return () => { + element.removeEventListener( 'keydown', _onKeyDown ); + }; + }, + [ isEnabled ] + ), ] ); // We only want to show the popover if the user has typed something. - const didUserInput = record.text !== previousRecord?.text; + const didUserInput = isEnabled && record.text !== previousRecord?.text; if ( ! didUserInput ) { return { ref: mergedRefs }; diff --git a/packages/components/src/autocomplete/types.ts b/packages/components/src/autocomplete/types.ts index 52e9ed7ecbba35..4e975c178072cf 100644 --- a/packages/components/src/autocomplete/types.ts +++ b/packages/components/src/autocomplete/types.ts @@ -184,6 +184,13 @@ export type UseAutocompleteProps = { * `Autocomplete`'s `Popover`. */ contentRef: ContentRef; + /** + * Whether the autocompleter should be active. Defaults to `true`. When + * `false`, the heavy bits — match scanning, keydown listener attachment, + * popover state — are skipped while the hook still returns a stable ref so + * the host can attach it unconditionally. + */ + isEnabled?: boolean; }; export type AutocompleteState = {