feat: replace dual input pipeline with unified input manager#2276
Open
christianhg wants to merge 2 commits intomainfrom
Open
feat: replace dual input pipeline with unified input manager#2276christianhg wants to merge 2 commits intomainfrom
christianhg wants to merge 2 commits intomainfrom
Conversation
🦋 Changeset detectedLatest commit: 15e0e46 The changes in this PR will be included in the next version bump. This PR includes changesets to release 11 packages
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 |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
5a5864a to
2631963
Compare
2631963 to
f9926d7
Compare
f9926d7 to
bbc4f65
Compare
204d111 to
96c6b12
Compare
96c6b12 to
8d97be3
Compare
e4e391a to
ef08154
Compare
ef08154 to
4b671dc
Compare
4b671dc to
7c8473f
Compare
7c8473f to
8ce5bdc
Compare
8ce5bdc to
c38d17c
Compare
c38d17c to
44eabc5
Compare
44eabc5 to
3b23134
Compare
Contributor
📦 Bundle Stats —
|
| 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
beforeinputevents withpreventDefault()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 mostbeforeinputevents. 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()onbeforeinput. 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
beforeinputwhere 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
beforeinputevents on modern Android Chrome ARE cancelable -insertText,deleteContentBackward,insertParagraph, and others all reportcancelable: 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 cancelablebeforeinputevents, callspreventDefault(), 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.tsreads exactly that: block keys and text content viadata-block-keyanddata-block-typeattributes. 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:
insertTextarriving during composition cancels the timeout and uses the browser's final text directlyRestoreDOM 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
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.