Skip to content

fix(slate-react): keep the DOM in sync when a native insertText is a no-op - #6084

Open
a-y-ibrahim wants to merge 4 commits into
ianstormtaylor:mainfrom
a-y-ibrahim:main
Open

a-y-ibrahim wants to merge 4 commits into
ianstormtaylor:mainfrom
a-y-ibrahim:main

Conversation

@a-y-ibrahim

@a-y-ibrahim a-y-ibrahim commented Jul 19, 2026 •

Copy link
Copy Markdown

Description

When an insertText override rejects a native insertion, the browser can retain a character that never entered the Slate value. Remove that native insertion from its actual DOM text node when the model is unchanged. Capture the browser's target range, or its selection when no target range is available, so the correction reaches the right leaf at a decoration boundary.

The fix reuses the existing resolved DOM point, skips capture when the node map is dirty, and uses Node.getIf instead of exception handling. It checks the expected DOM edit before undoing it and preserves the caret when the Slate selection remains at the insertion point.

Consolidated the two regression pages and test files into one hidden decorated example, removed redundant callbacks and comments, and generated its JavaScript version. The original commits by @a-y-ibrahim are preserved.

Issue

Fixes #5010

Example

Verified behavior :

Case Before After
Override rejects X typed into abcdef DOM can show abcdefX while the model remains abcdef DOM and model both remain abcdef
Type at the start of the second decorated leaf Contributor's fix leaves abcXdef in Firefox The rejected character is removed and following text lands at the same caret
Node map is dirty Contributor's fix makes another DOM-point lookup Capture is skipped
Override substitutes a character or redirects insertion Allowed edits must remain intact Both behaviors are verified

Checks

  • Main f399a7ca fails both no-op DOM reconciliation tests. The contributor implementation fails the event-level boundary and dirty-map tests, plus the real Firefox boundary test. All three unit tests pass with the revision.
  • Focused browser run: 48 passed across Chromium, Firefox, and WebKit, with each case repeated twice and retries disabled.
  • The 16 mobile cases in that repeated run are skipped explicitly because Android uses a separate input manager.
  • Gated check.sh exited 0 on Node 24: 1,468 Mocha tests, 144 Jest tests, build, TypeScript, ESLint, and Prettier. Playwright TypeScript also passes separately.
  • Full Playwright run: 270 passed, 33 skipped, one Chromium Markdown-shortcuts failure. That unchanged test also failed against main 3242334b earlier in this session; all new browser regressions pass. The markdown-shortcuts failure is a test-timing flake (a click processed after the keys that follow it), fixed separately in Make the markdown-shortcuts and shadow-dom editing tests deterministic #6205.
  • Includes a slate-react patch changeset.

Android's pre-existing rejected-insertion reconciliation gap is outside this desktop native-input correction.

Copilot AI review requested due to automatic review settings July 19, 2026 03:07
@changeset-bot

changeset-bot Bot commented Jul 19, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e410a17

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

This PR includes changesets to release 1 package
Name Type
slate-react 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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@dylans

dylans commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

@12joan let me know if you have opinions on this one.

@12joan

12joan commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

@dylans Sure! I'll take a look later today.

@12joan 12joan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work! The fix itself seems pretty robust. I tried changing the example to insert a different character to that which was typed, or to insert the character elsewhere in the text, and everything I tried behaved correctly.

I've left a few suggestions, mainly to improve code quality.

Comment thread site/constants/examples.ts Outdated
Comment on lines +14 to +15
['Insert Text Noop', 'insert-text-noop'],
['Insert Text Noop Decorated', 'insert-text-noop-decorated'],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps we should add these to HIDDEN_EXAMPLES, since they might confusing to people seeing them in the sidebar without explanation?


const InsertTextNoopDecoratedExample = () => {
const editor = useMemo(() => withNoUppercase(withReact(createEditor())), [])
const decorateCallback = useCallback(decorate, [])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no need to use useCallback to wrap a constant function. It's only necessary for functions that are recreated as part of the render function.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it do any harm to combine these into a single example where decoration is always enabled? At the very least, it would be good to combine the two Playwright tests since they're so similar.

Comment on lines +203 to +214
// COMPAT: Tracks a text node that just received a native
// (non-preventDefault'd) character insertion, so we can verify - once
// the deferred `Editor.insertText` has been flushed - that Slate's
// document actually changed. If a custom `insertText` ignored the
// character (see https://github.com/ianstormtaylor/slate/issues/5152),
// no Slate operation is applied, so no re-render happens to correct the
// DOM via <TextString>'s layout effect; we correct it manually instead,
// by undoing the browser's own single-character insertion at the exact
// DOM position it happened. This is leaf-agnostic (works regardless of
// how many marks/decorations split the text node into separate spans),
// since `domNode`/`domOffset` (from `ReactEditor.toDOMPoint`) already
// resolve to the specific leaf span the insertion landed in.

@12joan 12joan Aug 19, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having some explanation of this is good since it's pretty unintuitive otherwise, but please could you make this comment a bit more concise so that it pertains only to the lastNativeInsertion ref itself?

Also, I'm not sure COMPAT is quite right. It looks like it's used in a few existing comments with no clear pattern, but ideally I would expect it to appear only on code addressing browser-specific quirks (compatibility issues).

Comment on lines +853 to +856
const { path } = selection.anchor
const [node] = Editor.node(editor, path)

if (Text.isText(node)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about const [node, path] = Editor.leaf(editor, selection.anchor)?

This removes the need to pull out the path and node separately, or to check that node is a text node. The path of a point should always refer to a text node, but if not (such as if the selection is invalid), the error thrown by Editor.leaf will be caught by the try...catch anyway.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or, if possible, it might be better to avoid the need for a try...catch entirely.

Comment on lines +857 to +860
const [domNode, domOffset] = ReactEditor.toDOMPoint(
editor,
selection.anchor
) as [DOMText, number]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding an if (domNode instanceof DOMText) might be safer than a type assertion here.

Comment on lines +1172 to +1182
// COMPAT: If a native insertion's deferred `Editor.insertText`
// turned out to be a no-op (e.g. a custom `insertText` ignored
// the character), the browser has already mutated the DOM, but
// since Slate's document didn't change, no re-render happens to
// correct it via <TextString>'s layout effect. Undo the
// browser's own single-character insertion directly, at the
// exact DOM position it happened - this works regardless of
// how many leaves (marks, decorations) the surrounding text
// node is split into, since `domNode`/`domOffset` already
// identify the specific leaf span the insertion landed in.
// https://github.com/ianstormtaylor/slate/issues/5152

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// COMPAT: If a native insertion's deferred `Editor.insertText`
// turned out to be a no-op (e.g. a custom `insertText` ignored
// the character), the browser has already mutated the DOM, but
// since Slate's document didn't change, no re-render happens to
// correct it via <TextString>'s layout effect. Undo the
// browser's own single-character insertion directly, at the
// exact DOM position it happened - this works regardless of
// how many leaves (marks, decorations) the surrounding text
// node is split into, since `domNode`/`domOffset` already
// identify the specific leaf span the insertion landed in.
// https://github.com/ianstormtaylor/slate/issues/5152
// If a native insertion's deferred `Editor.insertText` did
// nothing, undo the browser's native insertion to remove the
// character from the DOM.


if (nativeInsertion) {
try {
const [node] = Editor.node(editor, nativeInsertion.path)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Node.get (or Node.getIf to avoid throwing an error, or Node.leaf to check if it's a text node) returns a node rather than a node entry, which would avoid the need to destructure it here.

Comment on lines +1218 to +1222
} catch {
// The path may no longer point to a valid node (e.g.
// it was affected by some other operation) - nothing
// to correct in that case.
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might not need a try...catch if Node.getIf is used instead of Editor.node.

const [node] = Editor.node(editor, path)

if (Text.isText(node)) {
const [domNode, domOffset] = ReactEditor.toDOMPoint(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • this runs toDOMPoint even when IS_NODE_MAP_DIRTY is set
  • the gate above skips its own toDOMPoint in that case, since the anchor can't be trusted
  • the gate already computes [node, offset] for this same anchor when the map is clean
  • reuse that point, and skip the capture when the map is dirty, instead of a second lookup in a try/catch

}) => {
const textbox = page.getByRole('textbox')
await textbox.click()
await page.keyboard.press('End')

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • both tests type at End, inside the undecorated leaf
  • at offset 3, toDOMPoint returns the end of the highlighted leaf
  • the browser caret can sit at the start of the next leaf's text node instead
  • then the charAt(domOffset) check fails and the uppercase letter stays in the DOM
  • does it hold if you type right after the highlight?

a-y-ibrahim and others added 4 commits October 9, 2026 22:43
…rmtaylor#5152)

Single a-z/space character insertion is handled natively for
performance: preventDefault is skipped so the browser inserts the
character directly, and the corresponding Editor.insertText call is
deferred until the following input event.

If a custom insertText override ignores the character (e.g. to
disallow uppercase), no Slate operation gets applied, so no re-render
happens to reconcile the DOM via <TextString>'s layout effect - the
native insertion's character was left stranded in the DOM even though
the Slate document (correctly) didn't change.

Track the path of a native insertion at defer time, and after flushing
deferred operations, verify the affected text node's DOM content
against the model, correcting it if they've diverged. Restore the
caret afterward, since replacing textContent resets it to the start of
the (recreated) text node.

Extracted <TextString>'s inline text-content computation into a shared
getLeafDomText helper, reused by both the normal reconciliation path
and this new correction path, rather than duplicating the logic.

Added a demo (insert-text-noop) and Playwright regression test
reproducing the exact bug from the issue, verified fail-before/pass
after against the real built package. Full existing test suites
(playwright, jest) pass with no regressions.
The previous commit only corrected a text node split into a single
`[data-slate-string]` span, bailing out whenever marks or decorations
split it into multiple leaves. Decorations aren't checked by the
native-insertion gate at all (only `editor.marks` is), so a decorated
text node could still hit the native fast path and leave a stray
character stuck in the DOM with no correction applied.

Replace the whole-leaf textContent comparison with a surgical fix:
capture the exact DOM text node/offset the browser is about to insert
into, and if the deferred `Editor.insertText` left the Slate model
unchanged, delete just that one character at that exact position.
This is leaf-agnostic by construction, since `ReactEditor.toDOMPoint`
already resolves to the specific leaf span a given Slate point falls
into, decorations included. It also drops the need to reconstruct
each leaf's expected text, so `string.tsx` reverts to its original,
unexported form.

Also confirmed (by rebuilding from the pre-fix commit and re-testing)
that Android's separate AndroidInputManager input path bypasses this
correction entirely and has the same pre-existing gap independent of
this change - out of scope here, called out in the changeset.
…ianstormtaylor#5152

Verifies the single-slot lastNativeInsertion ref doesn't lose track of
an earlier swallowed character when several native insertions happen
back to back - each keystroke's beforeinput/input cycle completes
synchronously before the next one starts, so this isn't an actual race,
but it wasn't exercised by the existing tests.

This branch has not been deployed

No deployments
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.

Attempting to limit text insertion by overriding insertText causes content to be out of sync with node representation

4 participants