Skip to content

fix: report focus honestly when something else holds the focus - #3206

Open
christianhg wants to merge 1 commit into
mainfrom
fix/verify-dom-focus
Open

fix: report focus honestly when something else holds the focus#3206
christianhg wants to merge 1 commit into
mainfrom
fix/verify-dom-focus

Conversation

@christianhg

@christianhg christianhg commented Sep 1, 2026

Copy link
Copy Markdown
Member

Closing a toolbar dialog and handing focus back to the editor silently fails: DOMEditor.focus sets editor.focused = true before el.focus() and never verifies the focus took. A focus-containing overlay (a react-aria modal's containment, or inert outside content, where el.focus() is a complete no-op) wins silently, and the editor is left claiming focus it does not have; only incidental side effects (a real blur, a selectionchange) corrected the flag. In the playground, adding a comment through a dialog left focus parked on the toolbar button, while an identical annotation dialog appeared to work purely through timing luck (its document edit keeps the machine's busy loop alive past the modal's teardown).

The fix makes focus one honest, verified attempt: after el.focus(), check root.activeElement === el; a failed take resets the flag and gives up with a debug diagnostic. Deliberately no retry against a focus holder: none of the studied editors (Wordgard, CodeMirror, ProseMirror, Lexical, slate-react) competes with an external focus owner, and the DOM cannot distinguish a closing overlay's restore from a user's deliberate focus move. Recovery is an explicit re-request, which now works because the flag is honest; the early-return guard verifies reality too, and an already-focused editor gets the flag corrected without rewriting the DOM selection. The pre-existing retry that waits out the editor's own pending operations stays (renderer readiness, slate-react ancestry); its exhaustion gives up silently instead of throwing inside a setTimeout callback where nothing could catch it, and a pending retry is cancelled by a newer focus or a blur.

The focus trap recovery suite pins the contract: a trap steals exactly once with the editor reporting itself unfocused (red both on the pre-fix unverified flag and on a retry-loop implementation, expected 19 to be 1), a later send after release recovers, the inert case fires no events and recovers on re-request, and focus never rewrites an already-correct DOM selection. The blur-cancellation of a pending operations-retry is kept but unpinned; the window requires pending operations the public test surface cannot hold open. WebKit could not be launched in this environment; Chromium and Firefox are green.

Dialog-close focus hand-off is the integration layer's job (Radix ships it as onCloseAutoFocus, Floating UI as returnFocus; react-aria lacks the seam, tracked in react-spectrum#9876) and lands with the dialog owner separately.

@changeset-bot

changeset-bot Bot commented Sep 1, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 893ee6e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 14 packages
Name Type
@portabletext/editor Patch
@portabletext/plugin-character-pair-decorator Patch
@portabletext/plugin-dnd Patch
@portabletext/plugin-emoji-picker Patch
@portabletext/plugin-input-rule Patch
@portabletext/plugin-list-index Patch
@portabletext/plugin-markdown-shortcuts Patch
@portabletext/plugin-one-line Patch
@portabletext/plugin-paste-link Patch
@portabletext/plugin-sdk-value Patch
@portabletext/plugin-table Patch
@portabletext/plugin-typeahead-picker Patch
@portabletext/plugin-typography Patch
@portabletext/toolbar Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Sep 1, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
portable-text-editor-documentation Ready Ready Preview Sep 1, 2026 9:04am UTC
portable-text-example-basic Ready Ready Preview Sep 1, 2026 9:04am UTC
portable-text-playground Ready Ready Preview Sep 1, 2026 9:04am UTC

Request Review

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Bundle Stats

✅ No significant changes.

All scenario measurements (7)

🗺️ @portabletext/editor / @portabletext/editor · @portabletext/editor / @portabletext/editor/behaviors · @portabletext/editor / @portabletext/editor/plugins · @portabletext/editor / @portabletext/editor/selectors · @portabletext/editor / @portabletext/editor/traversal · @portabletext/editor / @portabletext/editor/utils · @portabletext/markdown / @portabletext/markdown · Artifacts

Scenario Kind Bundle (raw / gzip) Gzip change Import time Import change
⚪ @portabletext/editor / @portabletext/editor export 1.09 MB / 253.8 KB +122 B, +0.0% 68 ms +2 ms, +2.3%
⚪ @portabletext/editor / @portabletext/editor/behaviors export 4.0 KB / 1.4 KB None 2 ms +0 ms, +1.0%
⚪ @portabletext/editor / @portabletext/editor/plugins export 5.1 KB / 1.8 KB None 7 ms +0 ms, +2.6%
⚪ @portabletext/editor / @portabletext/editor/selectors export 93.7 KB / 21.3 KB None 8 ms +0 ms, +0.5%
⚪ @portabletext/editor / @portabletext/editor/traversal export 41.7 KB / 10.8 KB None 6 ms +0 ms, +0.9%
⚪ @portabletext/editor / @portabletext/editor/utils export 33.1 KB / 8.7 KB None 6 ms +0 ms, +3.6%
⚪ @portabletext/markdown / @portabletext/markdown export 272.2 KB / 79.6 KB None 40 ms +2 ms, +4.8%

Significant means at least 1.0 KB and 1% gzip, or at least 5 ms and 10% import time.

`DOMEditor.focus` set `editor.focused = true` before `el.focus()` and
never verified the focus took. When a focus-containing overlay held
focus (a react-aria modal's containment, or `inert` on surrounding
content, where `el.focus()` is a complete no-op), the flag was left
claiming focus the editor did not have, and only incidental paths (a
real blur firing `onBlur`, the layout effect's `selectionchange`)
corrected it. A `focus` sent while such an overlay was open was
silently swallowed, and the throw on exhausted operation retries fired
inside a `setTimeout` callback: uncatchable, unpinned, unreachable
through the only caller.

`focus` now makes one verified attempt: after `el.focus()`, it checks
`root.activeElement === el`, and a failed take resets `editor.focused`
and gives up with a `debug` diagnostic. There is deliberately no retry
against a focus holder: no editor in the studied prior art (Wordgard,
CodeMirror, ProseMirror, Lexical, slate-react) competes with an
external focus owner, and the DOM cannot distinguish a closing
overlay's restore from a user's deliberate focus move. Recovery is the
consumer's explicit re-request, which now works because the flag is
honest: the `if (editor.focused)` early-return also verifies reality,
and an already-DOM-focused editor gets the flag corrected without
rewriting the DOM selection. The retry loop that waits out the
editor's own pending operations stays (renderer readiness, slate-react
ancestry, budget 5); its exhaustion now gives up silently through the
same diagnostic, its callbacks are wrapped so a selection invalidated
between ticks cannot surface as an unhandled window error, and a
pending retry is cancelled by a newer `focus` request and by `blur`
(kept unpinned: the window needs pending operations, which the public
test surface cannot hold open).

Pinned by the `focus trap recovery` suite: a trap steals exactly once
and the editor reports itself unfocused (red on a retry-loop
implementation: `expected 19 to be 1`; red on the pre-fix code's
unverified flag), a later send after the trap releases recovers, the
`inert` case fires no events and recovers on re-request, and a `focus`
send never rewrites an already-correct DOM selection.

Focus hand-off from closing dialogs is the integration layer's job
(the pattern Radix ships as `onCloseAutoFocus` and Floating UI as
`returnFocus`; react-aria lacks the seam, tracked upstream in
react-spectrum#9876) and lives with the dialog owner, not here.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant