Skip to content

fix(mobile): Android Chrome keyboard collapse, Enter handling, and focus API - #3025

Draft
YousefED wants to merge 21 commits into
mobile-toolbar-demofrom
mobile/android-keyboard-fixes
Draft

fix(mobile): Android Chrome keyboard collapse, Enter handling, and focus API#3025
YousefED wants to merge 21 commits into
mobile-toolbar-demofrom
mobile/android-keyboard-fixes

Conversation

@YousefED

@YousefED YousefED commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Fixes the Android Chrome keyboard/popover collapse at its root, plus the editing and focus bugs found while chasing it, and adds the mobile test infrastructure that keeps them fixed.

Branched off mobile-toolbar-demo, and supersedes #2982.

#2982 approached the collapse from the symptom end — autoFocus={false} on the link URL input, plus an onClickCapture handler that scrolls the input into view and restores the editor scroll a second later, with the onClickCapture prop plumbed through ComponentsContext and all three UI packages' TextInputs. This PR fixes the root cause instead (see below), which makes all of that unnecessary: it costs autofocus on desktop, and adds public API surface for a workaround nothing needs any more.

The one part of #2982 that is not superseded — the pointer: coarse rule that keeps .bn-form-popover inputs at 16px so iOS Safari doesn't auto-zoom on focus — is cherry-picked here with Matthew's authorship intact, and now has the test coverage it previously lacked. So #2982 can be closed once this lands.

Fixes

Mantine popover collapse (the original bug). Mantine's hideDetached (default true) reacts to the viewport resize the on-screen keyboard causes by setting display: none on the still-mounted dropdown. A display: none ancestor can't hold focus, so the popover's input blurred, the toolbar unmounted, and the keyboard dismissed — the whole collapse cascade. Disabling hideDetached for portalled (mobile) popovers fixes it at the source.

Enter on Android (#3001). prosemirror-view ignores Enter keydowns on Android Chrome and falls back to parsing the browser's native DOM split, which misparses BlockNote's nested block DOM and corrupts the document — Enter inserting a space, doing nothing, or breaking tables. BlockNote now intercepts the beforeinput instead and dispatches through the keymap chain, restoring the domObserver.forceFlush() parity prosemirror's Android bail skips. Verified as not a recent regression: the same race exists in every version pairing back to BlockNote 0.29 / prosemirror-view 1.38.1, bisected with era-correct overrides.

Public focus API. editor.isFocused({ includeFloatingUI }) and editor.onFocusChange(cb, { includeFloatingUI }) answer "is the user still interacting with this editor" — content focus or focus inside the editor's own floating UI. Replaces reaching into editor._tiptapEditor.on("focus"/"blur"), which can't see focus moving into a portalled popover. Events are settled (a rAF re-check, because document.activeElement is transiently <body> mid-handoff and relatedTarget is unreliable on mobile). The React callback hooks move to the latest-ref pattern, so unmemoized callbacks no longer resubscribe on every render.

iOS input auto-zoom (cherry-picked from #2982). Safari zooms the page when an input with a computed font-size below 16px takes focus, and that zoom perturbs the visual viewport the mobile toolbar positions itself from.

Selected link URL. Reading the URL from a single boundary position is fragile twice over: marks() excludes a link at its left edge, and engines disagree by one position on where a selection over a link starts. Now scans the selected range for the first link mark, which fixes the link popover opening empty for a fully selected link on WebKit.

Test infrastructure

Android-emulated browser instance. A fourth e2e instance (Android UA, touch, phone viewport) so isTouchDevice() is genuinely true and prosemirror takes its Android code paths — no stubs. It runs the new end-to-end/mobile/ suites plus the behavioral suites where Android genuinely differs. Note the per-instance contextOptions are silently ignored by vitest; the instance needs its own provider: playwright({ contextOptions }).

Real-device suite on BrowserStack (tests/device/, pnpm run test:device). A dependency-free WebDriver REST client, a gesture layer holding every platform quirk, and suites for the toolbar lifecycle and basic editing including soft-keyboard Enter. Self-skips without credentials, so it's safe to invoke anywhere. Nightly workflow included (needs BROWSERSTACK_* secrets).

Verification

Every fix has an e2e test proven to fail without it — each was verified by reverting the fix and confirming the test reports the original symptom. Android instance: 45 passed, 4 expected skips. Also verified on real devices (Samsung Galaxy S22, iPhone 16e) for the Enter and popover fixes.

The iOS auto-zoom fix is covered at two levels, since the rule and the behaviour it prevents are testable in different places: the emulated suite asserts the input's computed font-size stays at/above 16px (the CSS contract), and the real-device suite asserts visualViewport.scale doesn't increase when the URL input takes focus (the actual behaviour).

Draft: a follow-up branch stacks ~27 more commits on top of this one (comments/toolbar handoff, IME composition coverage, visual baselines, accessibility scanning, a tablet device target).

…esizes

Mantine Popover's hideDetached (default true) reacts to the viewport resize
that the on-screen keyboard causes whenever focus moves into an input by
setting display:none on the still-mounted dropdown. A display:none ancestor
cannot hold focus, so the popover's input blurred, the toolbar unmounted, and
the keyboard dismissed — the Android Chrome collapse this branch set out to
fix. Disabling hideDetached for portalled (mobile) popovers fixes the
collapse at its root, so the previous workaround (autoFocus={false} plus a
scrollIntoView on click, which regressed desktop and never prevented the
collapse) is reverted, restoring autofocus and removing the onClickCapture
plumbing.

Also closes the create-link popover explicitly on submit: the desktop toolbar
unmounts on the store update (taking the popover with it), but the mobile
toolbar stays mounted, so without this the popover lingered over the toolbar
and swallowed taps on its buttons.
prosemirror-view ignores Enter keydowns entirely on Android Chrome and falls
back to parsing the browser's native DOM split, whose recognition heuristic
fails against BlockNote's nested block DOM: depending on timing a single
Enter did nothing, split twice, or corrupted the document (#3001 — Enter
inserting a space, breaking tables; the stuck down-arrow was collateral of
the corrupted state). This is not a regression: every published pairing back
to 0.29.0 with its contemporary prosemirror-view races the same way.

Intercept the beforeinput (insertParagraph/insertLineBreak) the IME emits,
force-flush pending DOM observations (parity prosemirror-view skips on its
Android bail; without it the synthesized Enter can run against a stale
selection), and dispatch Enter through the full keymap chain — bypassing the
fragile DOM diffing entirely. Hardware and IME Enter both converge on this
beforeinput on Android, so one interception covers both.
Adds editor.onFocusChange(cb, { includeFloatingUI }) and the matching
isFocused({ includeFloatingUI }) option, plus a useEditorFocusChange hook.
The default reports raw content-area focus/blur (tiptap events, now exposed
through EventManager like onChange/onSelectionChange instead of reaching
into _tiptapEditor). With includeFloatingUI, document-level tracking treats
everything portalled into editor.portalElement as focused and emits only
settled, deduplicated transitions — covering the case tiptap events cannot
(focus leaving from the floating UI), with a next-frame re-check because
activeElement is transiently <body> during focus handoffs and relatedTarget
is unreliable on mobile.

MobileFormattingToolbarController now consumes this API (holding only the
settled values — reading focus live during a render can observe a
mid-handoff frame). The callback hooks use the latest-ref pattern so
unmemoized callbacks no longer resubscribe every render.
getSelectedLinkUrl looked up the link mark exactly at selection.from, which
misses the mark at the link's left boundary — selecting a whole link made
the link popover's URL field come up empty. Use from + 1, the same boundary
correction editLink already applies.
Adds a fourth browser instance, "android" (chromium with an Android UA,
touch, and a phone viewport via a per-instance provider override — instance
contextOptions are silently ignored), running the mobile-specific tests in
end-to-end/mobile/ plus the screenshot-free behavioral suites where Android
genuinely differs: keyboardhandlers, emojipicker, copypaste. In this context
isTouchDevice() and prosemirror-view's Android detection are genuinely true,
so the mobile tests need no platform stubs.

The mobile tests cover the keyboard-lifecycle contract (toolbar shows with
the keyboard, hides when it closes), the link popover surviving IME resizes
through creation and URL pre-fill, and the Android Enter beforeinput path —
each proven to fail with its fix reverted.

Tests driving selection/resizing with positional mouse idioms carry
skipIf(onAndroid) guards with the reason in place. Iframe-element screenshots
permanently drop the context's touch emulation (plain element shots and
viewport calls are fine), so screenshot suites stay out of this instance and
ensureTouchEmulation() self-heals detection if that ever regresses.
Adds tests/device/: a dependency-free WebDriver REST client, a gesture layer
holding every platform quirk (iOS only honors Appium native taps for
focus/keyboard, with Safari-chrome screen offsets and verify-and-recover
ladders; Android element clicks and key events suffice, with soft Enter
converging on the same beforeinput path as the IME), BlockNote page helpers,
a BrowserStackLocal tunnel + host-rewriting proxy as global setup, and a
two-device matrix (Galaxy S22, iPhone 16e).

Run with pnpm run test:device against a local dev server; suites self-skip
without BROWSERSTACK_* credentials. Covers the mobile toolbar lifecycle,
button reliability, the full link flow, typing, and soft-keyboard Enter
(fails-without-fix verified on device for the Enter and popover fixes).
Includes a nightly workflow (requires the BROWSERSTACK_* repo secrets).
@vercel

vercel Bot commented Aug 31, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
blocknote Ready Ready Preview Aug 31, 2026 10:03am
blocknote-website Ready Ready Preview Aug 31, 2026 10:03am

Request Review

@coderabbitai

coderabbitai Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4

- uses: pnpm/action-setup@v4

- uses: actions/setup-node@v4

- name: Upload screenshots
if: always()
uses: actions/upload-artifact@v4
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
@pkg-pr-new

pkg-pr-new Bot commented Aug 31, 2026

Copy link
Copy Markdown

Open in StackBlitz

@blocknote/ariakit

npm i https://pkg.pr.new/@blocknote/ariakit@3025

@blocknote/code-block

npm i https://pkg.pr.new/@blocknote/code-block@3025

@blocknote/core

npm i https://pkg.pr.new/@blocknote/core@3025

@blocknote/diagram-block

npm i https://pkg.pr.new/@blocknote/diagram-block@3025

@blocknote/mantine

npm i https://pkg.pr.new/@blocknote/mantine@3025

@blocknote/math-block

npm i https://pkg.pr.new/@blocknote/math-block@3025

@blocknote/react

npm i https://pkg.pr.new/@blocknote/react@3025

@blocknote/server-util

npm i https://pkg.pr.new/@blocknote/server-util@3025

@blocknote/shadcn

npm i https://pkg.pr.new/@blocknote/shadcn@3025

@blocknote/xl-ai

npm i https://pkg.pr.new/@blocknote/xl-ai@3025

@blocknote/xl-docx-exporter

npm i https://pkg.pr.new/@blocknote/xl-docx-exporter@3025

@blocknote/xl-email-exporter

npm i https://pkg.pr.new/@blocknote/xl-email-exporter@3025

@blocknote/xl-multi-column

npm i https://pkg.pr.new/@blocknote/xl-multi-column@3025

@blocknote/xl-odt-exporter

npm i https://pkg.pr.new/@blocknote/xl-odt-exporter@3025

@blocknote/xl-pdf-exporter

npm i https://pkg.pr.new/@blocknote/xl-pdf-exporter@3025

commit: 12a8a4a

@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://TypeCellOS.github.io/BlockNote/pr-preview/pr-3025/

Built to branch gh-pages at 2026-08-31 10:06 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

matthewlipski and others added 2 commits August 31, 2026 09:15
The pointer:coarse rule cherry-picked in the previous commit had no
test. Two layers, because the rule and the behaviour it prevents are
testable in different places:

- mobileToolbar (android instance): assert the URL input's computed
  font-size is at least 16px while the popover is open. Emulation can't
  reproduce the zoom itself — it's device behaviour, not engine
  behaviour — so this guards the CSS contract. Proven red with the rule
  removed (reports 12px and names the rule).
- formattingToolbar.device (real iPhone): capture
  visualViewport.scale before opening the popover and assert focusing
  the URL input doesn't increase it. That pins the actual behaviour on
  the only place it happens.
Review follow-ups on the focus-within-UI tracking added earlier in this
PR:

- `includeFloatingUI` -> `includeEditorUI`. The old name read as the
  floating-ui library (which is in fact what these popovers are built
  on), when the option really means "count the editor's own UI —
  toolbars, menus, popovers — as focused, not just the content area".

- Replace requestAnimationFrame with setTimeout for the blur-side
  settle. Both defer past the current task, which is what's actually
  needed; a frame buys nothing here (nothing is being painted) and
  doesn't run in a background tab. Measured rather than assumed:
  settling synchronously fails, and a microtask fails too — the ariakit
  and shadcn link popovers restore focus a task later — while
  setTimeout passes.

- Drop the cancel-on-focusin. It only avoided a redundant re-check: the
  emit is already deduplicated, so the trailing settle is a no-op.

What stays, and why it isn't further reducible: document-level
listeners (portalled UI sits outside the content area, so tiptap's
focus/blur can't see it), the deferral above, the dedupe (these
listeners see every focus change on the page, most unrelated to the
editor), and `editor.isFocused()` alongside the activeElement check —
the latter alone would break in shadow DOM, where document.activeElement
is the host rather than the editor.

Android instance: 45 passed, 4 expected skips.
The focus API had no direct coverage — only indirect exercise through
the mobile toolbar's behaviour. These run in the browser suite (all
three engines) because every interesting case is DOM semantics that
jsdom can't reproduce.

Two of them pin the platform contract the tracker is designed around,
so a future engine change surfaces here rather than as a mystery in the
mobile UI: the documented focus event order (blur, focusout, focus,
focusin — verified identical on chromium, firefox and webkit), and the
fact that `document.activeElement` reads as `<body>` during focusout,
which is precisely why that side has to be deferred.

The rest cover the edge cases: content focus vs. focus in portalled UI,
the content -> popover handoff that must not report a blur, focus
moving between two UI elements, focus changes elsewhere on the page
that must not reach subscribers at all (these are document-level
listeners), two editors on one page staying independent, unsubscribing,
and two subscribers sharing the reference-counted listeners.

Note the harness mounts each editor inside a container, mirroring
BlockNoteView. That is load-bearing: `isWithinEditor` treats the mount
element's *parent* as the editor boundary (pre-existing, #2591), so an
editor mounted directly into <body> would treat the whole page as its
own UI. Worth tightening separately — it also drives SideMenu's
click-outside detection.
`editor.mount(element)` turns `element` itself into the content area, so
the check's boundary — deliberately the content area's *parent*, so that
sibling (non-portalled) UI counts — is whatever that element's parent
happens to be.

With BlockNoteView the mount element always sits inside the
`bn-container` wrapper, so the boundary is that wrapper. But mounting a
bare element that is a direct child of `<body>`, which is exactly what
the vanilla-JS setup in the docs does, makes the boundary the whole
page: every element then counts as "within the editor", so
`isFocused({ includeEditorUI: true })` is always true, focus events with
that option never report a blur, and the side menu's click-outside check
never matches.

Comment only — fixing it means identifying the editor's UI without
relying on the parent, which changes behaviour for all three callers
(focus tracking, side menu, UniqueID) and deserves its own change.
`useEditorFocusChange` only offered a callback, so anything that wanted
to *render* based on focus had to wire up useState + useEffect itself —
which MobileFormattingToolbarController did, including a manual re-sync
for focus that changed before the subscription attached.

`useEditorFocus(options?, editor?)` returns that as state. The two hooks
now mirror the split the codebase already has between `useEditorState`
and `useEditorChange`: state for rendering, callback for side effects.

Built on useSyncExternalStore, so there's no extra render on mount and
no tearing. The snapshot is deliberately the last *settled* value rather
than a live `isFocused()` read: reading focus during an arbitrary render
can catch a mid-handoff frame where document.activeElement is
transiently <body> and the editor looks unfocused.

The controller now reads as one line, and no longer needs the editor
instance at all.

Covered by browser tests for the behaviours that justify the hook:
content focus/blur, staying focused across a handoff into portalled UI,
not re-rendering for unrelated focus changes elsewhere on the page
(these are document-level listeners, so it sees them all), and not
re-rendering while typing.
Adds a regression test for the latest-ref pattern in
`useEditorFocusChange`: an inline callback (a new identity every render)
must not cause the editor subscription to be torn down and re-attached.

Measured both ways before writing it — the naive implementation, with
the callback in the dependency array, resubscribes once per render (6
after 5 re-renders); the latest-ref version stays at 1. That churn is
worse than it looks with `includeEditorUI`, where the subscription is
reference-counted: each cycle detaches and re-attaches the document
focus listeners and resets the settled baseline.

Also corrects a comment in useEditorFocus: React re-checks the snapshot
*after* subscribing (its subscribe effect is registered before the
consistency-check effect — verified in react-dom's source), so the
re-sync inside subscribe is what makes the "focus changed between
render and subscription" case work.
… for

The previous wording said it covers "UI rendered as a sibling of the
content" without saying which UI that is — and BlockNote's own default
UI is entirely portalled (a mounted editor's container has exactly two
children: the content element and the portal), so on its own that
justification doesn't hold up.

The real case is UI the host app passes as `BlockNoteView` children,
which React renders as siblings of the content element. The shipped
"Static Formatting Toolbar" example is exactly that: `<FormattingToolbar />`
rendered inline rather than through a controller, putting eleven
focusable buttons next to the content. Without the hop, focusing one of
them reads as "outside the editor", which would dismiss the mobile
toolbar mid-interaction and trip the side menu's click-outside check.
Opening a toolbar popover reset the page scroll to the top, taking the
block being edited off screen entirely. Measured on the
mobile-formatting-toolbar example: scrollTop 451 -> 0, putting the
edited editor at y=800 in a 427px viewport.

Cause: the input's native `autofocus` fires while floating-ui has not
positioned the popover yet, so the browser scrolls to the popover's
pre-positioned spot — the top of the container — instead of where it
ends up. Focusing through a ref with `{ preventScroll: true }` keeps
the page still; floating-ui positions the popover regardless, so it
still appears in the right place.

Applied in all three UI packages' form TextInput, since they all take
`autoFocus` for popover inputs (link, file embed, rename, caption).

Covered by a mobile e2e test asserting neither the scroll position nor
the edited editor moves when the popover opens — proven red without the
fix (451 -> 0). Reported from a real device.
Reported from a device: select text in the first of two editors, open
the link popover, type a URL, press Enter — no link is created and
focus jumps to the *second* editor.

Android's IME chooses the Enter key's action itself. With no
surrounding <form> and no enterkeyhint, Chrome picks IME_ACTION_NEXT
whenever another focusable element follows, and Next advances focus
rather than dispatching a key event — so the keydown handler that
creates the link never runs. That also explains the asymmetry in the
report: from the last editor on the page nothing focusable follows, so
Chrome picks Done instead, Enter is dispatched, and the link is created
as expected.

Setting enterkeyhint="done" on the popover inputs makes the key report
itself as Enter everywhere. Applied in all three UI packages, so it
covers the link, file embed, rename and caption popovers.

The mobile toolbar test now asserts the attribute — a DOM-contract
check rather than a behavioural one, since emulation always dispatches
a real Enter and so cannot reproduce the IME's action choice.
Replaces the enterkeyhint attribute assertion with a test of what the
user actually reported: opening the link popover from the first of two
editors, submitting, and expecting the link in *that* editor with focus
still there — rather than the link missing and focus in the second one.

The attribute assertion stays, but as one line inside that behavioural
test rather than as the test itself, because it is the only part of the
IME contract a test can hold onto. The device-only half — which action
Android assigns to the Enter key — is not reachable from any automated
environment we have: emulation always dispatches a real Enter, and on
BrowserStack no input channel reaches the on-screen keyboard. That half
is now a release-checklist item in the device README instead of being
silently uncovered.
Follow-up to the enterkeyhint fix, which had no red-first test: nothing
in an emulated browser can reproduce an IME choosing to advance focus
instead of dispatching Enter, so the only thing a test could assert was
the attribute itself.

The deeper problem is that these popovers had no submission path at all
besides a keydown listener. `Form.Root` rendered a plain `<div>`, so
the `onSubmit` prop the TextInputs already accepted could never fire —
`submit` is dispatched on forms, not inputs. When a platform reports
Enter-to-submit as a form submission rather than a key event, nothing
happened.

`Form.Root` now renders a real `<form>` and takes `onSubmit`, wired up
in all four callers (link, file rename, file caption, AI prompt). It
always preventDefaults, so a caller that doesn't pass a handler can
never navigate away. The dead per-input `onSubmit` props are removed.
This also gives the browser proper form context, which is what it uses
to decide the IME's action key in the first place.

Now testable without any key event: fill the popover, call
`form.requestSubmit()` — exactly what the browser does when the IME
action means submit — and assert the link is created. Proven red with
`Form.Root` back to a `<div>` ("the popover must be a real <form>").
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.

3 participants