feat: replace dual input pipeline with unified hybrid input manager#2276
Open
christianhg wants to merge 3 commits intomainfrom
Open
feat: replace dual input pipeline with unified hybrid input manager#2276christianhg wants to merge 3 commits intomainfrom
christianhg wants to merge 3 commits intomainfrom
Conversation
🦋 Changeset detectedLatest commit: 16c9b8d 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 (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.
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, we discovered 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 Hybrid Input Manager. The fast path tries to intercept and cancel
beforeinputevents, 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
Modules
hybrid-input-manager.tsdom-parser.tspt-change-detector.tspt-change-to-behavior-event.tsuse-hybrid-input-manager.tspt-change-detector.test.tsWhat's removed
android-input-manager.ts(931 lines) - the old Android-specific input pipelineuse-android-input-manager.ts(59 lines)editable.tsx- the old desktopbeforeinputhandlerWhat's modified
editable.tsx- delegates to hybrid input manager instead of handling events directlyrestore-dom-manager.ts- skip structural restoration during compositionrestore-dom.tsx- composition awarenessuse-mutation-observer.ts- minor tweakdom-editor.ts- exposeelementToNodefor DOM parserBugs fixed
deleteContentBackward+insertText(NOTinsertReplacementText). The fast path checksgetTargetRanges()for multi-character deletions to handle this correctly.handleCompositionEndbefore the microtask that resetseditor.composing, preventing RestoreDOM from reverting composed content.getTargetRanges()returns expanded ranges even for single-character backspace. AddedisMultiCharDeletecheck so normal backspace goes through the correctdelete.backwardpath.