Skip to content

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

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

feat: replace dual input pipeline with unified input manager#2276
christianhg wants to merge 2 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 in Slate back in the day, it was done under the assumption 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 InputManager. The fast path intercepts cancelable beforeinput events, calls preventDefault(), and routes them through the behavior system. The slow path kicks in only when the browser has already mutated the DOM (composition, non-cancelable spellcheck) - it reads block keys and text content from the DOM, 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 reading is intentionally minimal. The change detector only needs to know which blocks changed and how their text differs, so dom-text-reader.ts reads exactly that: block keys and text content via data-block-key and data-block-type attributes. Everything else - styles, marks, list items - lives in the Slate model and doesn't need to be parsed from the DOM.

Composition handling has three paths depending on timing:

  • Same-span composition flushes synchronously with the composition data
  • Cross-boundary composition (e.g. across an annotation) captures DOM snapshots eagerly and defers to a 25ms timeout
  • insertText arriving during composition cancels the timeout and uses the browser's final text directly

RestoreDOM now runs on all platforms, not just Android. Since the slow path can let the browser mutate the DOM on any platform (e.g. spellcheck on desktop), RestoreDOM needs to be active everywhere to revert structural mutations that the slow path doesn't account for.

Architecture

beforeinput event
    |
    +- cancelable? --yes--> preventDefault() + fire behavior event (fast path)
    |
    +- not cancelable (composition/IME)
          -> Browser mutates DOM
          -> readBlockTexts() via data-block-key attributes
          -> detectChange(oldBlocks, newSnapshots) - asymmetric diff
          -> portableTextChangeToBehaviorEvent(change)
          -> editorActor.send(behaviorEvent)  <-- same events as fast path

Known limitation: structural DOM changes during composition are not reflected until composition completes.

The slow path lets the browser mutate the DOM freely during composition, but RestoreDOM reverts any structural mutations (node additions/removals) to keep the DOM consistent with React's VDOM. Only characterData mutations (text changes within existing nodes) are preserved. This means any composition that causes the browser to restructure the DOM - merging blocks, splitting nodes, moving content between elements - will show a stale visual state until composition ends and the change is processed through the behavior system. The final state is always correct.

We can't fix this without removing RestoreDOM during composition, which would leave the DOM out of sync with React's virtual DOM. React would then attempt to reconcile against a DOM tree it doesn't expect, which can cause crashes or corrupted rendering. The tradeoff - a brief visual artifact vs runtime stability - favors keeping RestoreDOM active.

@changeset-bot
Copy link

changeset-bot bot commented Mar 1, 2026

🦋 Changeset detected

Latest commit: 15e0e46

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 9, 2026 0:07am
portable-text-example-basic Ready Ready Preview, Comment Mar 9, 2026 0:07am
portable-text-playground Ready Ready Preview, Comment Mar 9, 2026 0:07am

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 (42f3db5f)

@portabletext/editor

Metric Value vs main (42f3db5)
Internal (raw) 805.8 KB -4.9 KB, -0.6%
Internal (gzip) 151.0 KB -449 B, -0.3%
Bundled (raw) 1.41 MB -4.9 KB, -0.3%
Bundled (gzip) 313.9 KB -488 B, -0.2%
Import time 97ms +1ms, +1.1%

@portabletext/editor/behaviors

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

@portabletext/editor/plugins

Metric Value vs main (42f3db5)
Internal (raw) 2.5 KB -
Internal (gzip) 910 B -
Bundled (raw) 2.3 KB -
Bundled (gzip) 839 B -
Import time 12ms +1ms, +4.4%

@portabletext/editor/selectors

Metric Value vs main (42f3db5)
Internal (raw) 60.2 KB -
Internal (gzip) 9.4 KB -
Bundled (raw) 56.7 KB -
Bundled (gzip) 8.6 KB -
Import time 11ms +1ms, +6.5%

@portabletext/editor/utils

Metric Value vs main (42f3db5)
Internal (raw) 24.2 KB -
Internal (gzip) 4.7 KB -
Bundled (raw) 22.2 KB -
Bundled (gzip) 4.4 KB -
Import time 10ms +0ms, +3.1%
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.

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