Rich Text: Restore selection when focus returns from a popover#77435
Rich Text: Restore selection when focus returns from a popover#77435kraftbj wants to merge 6 commits intoWordPress:trunkfrom
Conversation
When a popover (e.g. the link UI opened via Ctrl+K) closes and
programmatically returns focus to a contenteditable element, the
browser may reset the selection to position 0. This is especially
visible in standalone BlockEditorProvider setups (like Press This)
where there is no iframe to independently preserve selection state.
The onFocus handler already calls applyRecord with { domOnly: true }
to sync the DOM without overwriting the browser's selection — a
deliberate choice from 2019 to prevent selection fighting on normal
click-to-focus. However, this means a lost selection is never
corrected.
Add a `restoreSelectionOnFocus` flag that is set in onFocus and
checked in the deferred handleSelectionChange microtask. When the
flag is set and the DOM selection doesn't match the record, restore
the record's selection instead of accepting the browser's bogus
position. The flag is cleared on every handleSelectionChange call
so it only affects the first selection sync after focus.
Add a slim-editor E2E test plugin that mirrors how Press This uses
BlockEditorProvider directly (no iframe, no full editor chrome) to
reproduce the bug and verify the fix.
See WordPress/press-this#116
|
Warning: Type of PR label mismatch To merge this PR, it requires exactly 1 label indicating the type of PR. Other labels are optional and not being checked here.
Read more about Type labels in Gutenberg. Don't worry if you don't have the required permissions to add labels; the PR reviewer should be able to help with the task. |
1 similar comment
|
Warning: Type of PR label mismatch To merge this PR, it requires exactly 1 label indicating the type of PR. Other labels are optional and not being checked here.
Read more about Type labels in Gutenberg. Don't worry if you don't have the required permissions to add labels; the PR reviewer should be able to help with the task. |
|
The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message. To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. |
|
So technically, this could happen in core when the iframed editor is disabled. I think similar cases are already covered in |
|
Thanks for the review, @Mamaduka! You're right that this could happen in core when the iframe is off — I dug into that to see if we could swap the test plugin for something simpler. TL;DR: We actually do need a truly-standalone What I triedI suspected the existing await admin.createNewPost();
await editor.switchToLegacyCanvas();
// ...same Ctrl+K → Enter → Escape → type MARKER flowThen I ran it in two states:
So the legacy-canvas variant of the post editor doesn't actually exercise this code path — the bug only shows up when you go one layer deeper. Why I think that isThe post editor wraps the block editor in additional providers ( Press This and similar integrations skip all of that and render Is the test plugin worth it?I went back and forth on this. The plugin is ~100 lines of JS plus a small PHP admin page, so it's not free. But:
Happy to revisit if you'd prefer a different approach — e.g. a Jest-level unit test against the |
What?
Fixes a bug where the cursor jumps to position 0 after inserting a link via Ctrl+K and pressing Escape to close the popover. This is the upstream fix for WordPress/press-this#116. A workaround in Press This is at WordPress/press-this#118.
Why?
When a popover closes and programmatically returns focus to a contenteditable element via
.focus(), the browser may reset the selection to position 0. This bug lives in@wordpress/rich-textbut is masked in the full Gutenberg editor by two layers of protection:Iframe isolation: The editor canvas runs inside an
[name="editor-canvas"]iframe. When focus moves to the link popover (rendered in the parent frame), the iframe maintains its own selection state independently. On refocus, the browser restores the iframe's selection automatically.Store-driven correction: The full editor's
onSelectionChangedispatch ->useSelectsubscription ->useLayoutEffectcycle inuseRichText(hook/index.js:193-205) re-applies the correct selection from the block editor store, even if the browser momentarily loses it.Standalone
BlockEditorProvidersetups -- like Press This, which rendersBlockEditorProvider>WritingFlow>ObserveTyping>BlockListdirectly in the admin page with no iframe -- lack both of these safety nets. The popover and the contenteditable share the same document, so focusing the popover's URL input clears the contenteditable's selection. When Escape returns focus, the browser places the cursor at position 0.The
onFocushandler ininput-and-selection.jscallsapplyRecordwith{ domOnly: true }to sync the DOM without overwriting the browser's selection -- a deliberate choice from 2019 (PR #13896) to prevent selection fighting on normal click-to-focus. However, this also means a selection that the browser lost is never corrected. The subsequenthandleSelectionChangemicrotask then reads position 0 from the DOM and overwrites the correct position in the record, completing the corruption.How?
Adds a
restoreSelectionOnFocusflag in the rich-text event listener module (input-and-selection.js):onFocus: When the element was already selected (theisSelectedbranch), the flag is set afterapplyRecord({ domOnly: true }).handleSelectionChange: The deferred microtask reads the DOM selection as usual. If the flag is set and the DOM selection doesn't match the record, it restores the record's selection instead of accepting the browser's bogus position.handleSelectionChangecall, so it only affects the first selection sync after focus.This avoids a synchronous
createRecord()call during the focus event (where the selection may not yet be settled) and doesn't affect normal click-to-focus behavior -- in that case the browser's selection is correct, sohandleSelectionChangefinds a match and the flag is simply cleared.Slim editor test plugin
This PR includes a test plugin (
slim-editor-block-editor) that provides a minimal admin page at/wp-admin/admin.php?page=slim-editor-testwith a standaloneBlockEditorProvidersetup matching the Press This architecture:SlotFillProvider>BlockEditorProvider>WritingFlow>ObserveTyping>BlockList, rendered directly in the page with no iframe. This reliably reproduces the bug and serves as a regression test for standalone editor integrations.Testing Instructions
/wp-admin/admin.php?page=slim-editor-test).Before link text after.https://example.comin the URL field and press Enter.MARKER.Before MARKER after(MARKER replaces the selected link text).MARKERBefore link text after(cursor jumped to position 0).Note: This bug does not reproduce in the normal post editor because the iframe preserves the selection. You must use the slim editor test page or a standalone
BlockEditorProvidersetup like Press This.Testing Instructions for Keyboard
Same as above -- the entire flow is keyboard-driven.
Use of AI Tools
AI tooling (Claude) was used to investigate the root cause, design the fix, and write the E2E test plugin. All code was reviewed and verified manually.