Skip to content

feat: replace dual input pipeline with unified hybrid input manager#2276

Open
christianhg wants to merge 3 commits intomainfrom
feat/hybrid-input-architecture
Open

feat: replace dual input pipeline with unified hybrid input manager#2276
christianhg wants to merge 3 commits intomainfrom
feat/hybrid-input-architecture

Conversation

@christianhg
Copy link
Member

@christianhg christianhg commented Mar 1, 2026

Previously, Android used a separate input pipeline that assumed all browser
input events were non-cancelable, requiring expensive DOM reconciliation for
every keystroke. Most Android input events are in fact cancelable - only IME
composition is not. The new hybrid input manager intercepts events directly
where possible and falls back to a ProseMirror-inspired DOM parse-and-diff path
for composition. This fixes autocorrect on Android and gives Behavior authors a
single code path that works on all platforms.

Background

We inherited our input handling from Slate, which uses beforeinput events with preventDefault() to intercept user input before the browser touches the DOM. This works well on desktop, but when Android support was added, we discovered that Android Chrome doesn't allow canceling most beforeinput events. The solution at the time was a completely separate input pipeline: the Android Input Manager (~950 lines), which lets the browser mutate the DOM first, then uses a MutationObserver to detect what changed and reconcile Slate's model after the fact.

This left us with two parallel input architectures. The desktop path intercepts events synchronously through the behavior system. The Android path defers everything, flushes changes later, and has its own set of timing-sensitive edge cases. Behavior authors had to reason about both paths, and bugs on one platform often didn't reproduce on the other.

What we learned from other editors

We did a source-level analysis of how ProseMirror and Lexical handle the same problem.

ProseMirror takes the most radical approach: it never calls preventDefault() on beforeinput. Instead, it relies entirely on MutationObserver and DOM parsing. The browser does whatever it wants, and ProseMirror parses the resulting DOM, diffs it against its document model, and generates a transaction. This means its Android path is essentially the same as its desktop path - one code path for all platforms. It detects intent (Enter, Backspace, etc.) from the shape of the DOM change rather than from the event type.

Lexical takes a hybrid approach closer to what we ended up building: it intercepts beforeinput where possible, but has fallback paths for non-cancelable events. Platform-specific branches exist within a single code path rather than in separate managers.

The key discovery

Testing on an actual Android phone showed that the original assumption was wrong. Most beforeinput events on modern Android Chrome ARE cancelable - insertText, deleteContentBackward, insertParagraph, and others all report cancelable: true. Only composition events (insertCompositionText, deleteCompositionText) are non-cancelable.

This means our Android Input Manager was doing expensive DOM parsing and reconciliation for events that could have just been intercepted directly, like on desktop. The entire 950-line manager existed because of an overly pessimistic assumption about platform capabilities.

This PR

Replaces both pipelines with a single Hybrid Input Manager. The fast path tries to intercept and cancel beforeinput events, routing them through the behavior system (same as the old desktop path). The slow path kicks in only when the browser has already mutated the DOM (composition, spellcheck) - it parses the DOM back to Portable Text, diffs against the previous state, and fires the appropriate behavior events. Both paths produce the same behavior events, so behavior authors write one definition that works everywhere.

The slow path's DOM parser and change detector are inspired by ProseMirror's architecture: parse the DOM using data-slate-* attributes, diff the resulting PT blocks, and map changes to behavior events. The difference is that ProseMirror uses this as its primary path, while we use it only as a fallback for the small set of events that can't be intercepted.

Beyond unifying the input handling, this also makes it easier to refactor Slate internals further. The old Android Input Manager relied heavily on Slate's pending state pipeline (pendingDiffs, pendingSelection, pendingAction) to reconcile DOM mutations. With the hybrid manager handling input through behavior events instead, we can start removing Slate operations that aren't patch-compliant (like split_node, merge_node, move_node) without having to untangle that pending state machinery at the same time.

Architecture

beforeinput event
    |
    +- cancelable? --yes--> preventDefault() + fire behavior event (fast path)
    |
    +- not cancelable (composition/IME)
          -> Browser mutates DOM
          -> MutationObserver captures changes
          -> parseDOMToPortableText() using data-slate-* attributes
          -> detectChange(before, after) - PT block diff
          -> ptChangeToBehaviorEvent(change)
          -> editorActor.send(behaviorEvent)  <-- same events as fast path

Modules

Module Lines Purpose
hybrid-input-manager.ts 793 Core manager - fast path + slow path routing
dom-parser.ts 763 Parse DOM back to Portable Text blocks
pt-change-detector.ts 709 Diff two PT block arrays, detect change type
pt-change-to-behavior-event.ts 375 Map detected changes to behavior events
use-hybrid-input-manager.ts 89 React hook wiring
pt-change-detector.test.ts 651 39 unit tests for the change detector

What's removed

  • android-input-manager.ts (931 lines) - the old Android-specific input pipeline
  • use-android-input-manager.ts (59 lines)
  • 455 lines from editable.tsx - the old desktop beforeinput handler

What's modified

  • editable.tsx - delegates to hybrid input manager instead of handling events directly
  • restore-dom-manager.ts - skip structural restoration during composition
  • restore-dom.tsx - composition awareness
  • use-mutation-observer.ts - minor tweak
  • dom-editor.ts - expose elementToNode for DOM parser

Bugs fixed

  • Autocorrect on Android: autocorrect fires deleteContentBackward + insertText (NOT insertReplacementText). The fast path checks getTargetRanges() for multi-character deletions to handle this correctly.
  • Composition on Android: synchronous DOM capture in handleCompositionEnd before the microtask that resets editor.composing, preventing RestoreDOM from reverting composed content.
  • Smart undo with input rules: getTargetRanges() returns expanded ranges even for single-character backspace. Added isMultiCharDelete check so normal backspace goes through the correct delete.backward path.

@changeset-bot
Copy link

changeset-bot bot commented Mar 1, 2026

🦋 Changeset detected

Latest commit: 16c9b8d

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

This PR includes changesets to release 11 packages
Name Type
@portabletext/editor Minor
@portabletext/plugin-character-pair-decorator Major
@portabletext/plugin-emoji-picker Patch
@portabletext/plugin-input-rule Patch
@portabletext/plugin-markdown-shortcuts Major
@portabletext/plugin-one-line Major
@portabletext/plugin-paste-link Major
@portabletext/plugin-sdk-value Major
@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
Copy link

vercel bot commented Mar 1, 2026

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

Project Deployment Actions Updated (UTC)
portable-text-editor-documentation Ready Ready Preview, Comment Mar 6, 2026 0:52am
portable-text-example-basic Ready Ready Preview, Comment Mar 6, 2026 0:52am
portable-text-playground Ready Ready Preview, Comment Mar 6, 2026 0:52am

Request Review

@christianhg christianhg force-pushed the feat/hybrid-input-architecture branch from 5a5864a to 2631963 Compare March 1, 2026 08:25
@christianhg christianhg force-pushed the feat/hybrid-input-architecture branch from 2631963 to f9926d7 Compare March 1, 2026 09:02
@christianhg christianhg force-pushed the feat/hybrid-input-architecture branch from f9926d7 to bbc4f65 Compare March 1, 2026 09:06
@christianhg christianhg force-pushed the feat/hybrid-input-architecture branch from 204d111 to 96c6b12 Compare March 1, 2026 10:01
@christianhg christianhg force-pushed the feat/hybrid-input-architecture branch from 96c6b12 to 8d97be3 Compare March 1, 2026 10:23
@christianhg christianhg changed the title feat: hybrid input architecture (draft) feat: replace dual input pipeline with unified hybrid input manager Mar 1, 2026
@christianhg christianhg force-pushed the feat/hybrid-input-architecture branch from e4e391a to ef08154 Compare March 1, 2026 12:26
@christianhg christianhg force-pushed the feat/hybrid-input-architecture branch from ef08154 to 4b671dc Compare March 1, 2026 12:34
@christianhg christianhg force-pushed the feat/hybrid-input-architecture branch from 4b671dc to 7c8473f Compare March 1, 2026 12:46
@christianhg christianhg force-pushed the feat/hybrid-input-architecture branch from 7c8473f to 8ce5bdc Compare March 1, 2026 12:58
@christianhg christianhg force-pushed the feat/hybrid-input-architecture branch from 8ce5bdc to c38d17c Compare March 1, 2026 13:07
@christianhg christianhg force-pushed the feat/hybrid-input-architecture branch from c38d17c to 44eabc5 Compare March 1, 2026 13:13
@christianhg christianhg force-pushed the feat/hybrid-input-architecture branch from 44eabc5 to 3b23134 Compare March 1, 2026 13:19
@github-actions
Copy link
Contributor

github-actions bot commented Mar 6, 2026

📦 Bundle Stats — @portabletext/editor

Compared against main (be4d3b81)

@portabletext/editor

Metric Value vs main (be4d3b8)
Internal (raw) 815.8 KB +5.1 KB, +0.6%
Internal (gzip) 153.0 KB +1.5 KB, +1.0%
Bundled (raw) 1.42 MB +5.0 KB, +0.3%
Bundled (gzip) 315.9 KB +1.5 KB, +0.5%
Import time 103ms +1ms, +1.3%

@portabletext/editor/behaviors

Metric Value vs main (be4d3b8)
Internal (raw) 467 B -
Internal (gzip) 207 B -
Bundled (raw) 424 B -
Bundled (gzip) 171 B -
Import time 6ms +0ms, +0.8%

@portabletext/editor/plugins

Metric Value vs main (be4d3b8)
Internal (raw) 2.5 KB -
Internal (gzip) 910 B -
Bundled (raw) 2.3 KB -
Bundled (gzip) 839 B -
Import time 12ms +0ms, +0.8%

@portabletext/editor/selectors

Metric Value vs main (be4d3b8)
Internal (raw) 60.2 KB -
Internal (gzip) 9.4 KB -
Bundled (raw) 56.7 KB -
Bundled (gzip) 8.6 KB -
Import time 10ms -0ms, -0.3%

@portabletext/editor/utils

Metric Value vs main (be4d3b8)
Internal (raw) 24.2 KB -
Internal (gzip) 4.7 KB -
Bundled (raw) 22.2 KB -
Bundled (gzip) 4.4 KB -
Import time 10ms +0ms, +1.0%
Details
  • Import time regressions over 10% are flagged with ⚠️
  • Treemap artifacts are attached to the CI run for detailed size analysis
  • Sizes shown as raw / gzip 🗜️. Internal bytes = own code only. Total bytes = with all dependencies. Import time = Node.js cold-start median.

christianhg and others added 3 commits March 6, 2026 12:47
Previously, Android used a separate input pipeline that assumed all browser
input events were non-cancelable, requiring expensive DOM reconciliation for
every keystroke. Most Android input events are in faccancelable - only IME
composition is not. The new hybrid input manager intercepts events directly
where possible and falls back to a ProseMirror-inspired DOM parse-and-diff path
for composition. This fixes autocorrect on Android and gives behavior authors a
single code path that works on all platforms.
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