Skip to content

fix: use targetRange from beforeinput to place text replacements#2595

Draft
christianhg wants to merge 2 commits into
editor-v6.xfrom
fix/grammarly-replacement-target-range
Draft

fix: use targetRange from beforeinput to place text replacements#2595
christianhg wants to merge 2 commits into
editor-v6.xfrom
fix/grammarly-replacement-target-range

Conversation

@christianhg
Copy link
Copy Markdown
Member

@christianhg christianhg commented May 5, 2026

When an extension or IME (Grammarly, browser autocorrect, macOS substitution panel, etc.) fires beforeinput with inputType: 'insertReplacementText', the browser provides a target range via event.getTargetRanges() indicating where the replacement should land. The editor was bailing out of that path when its node-map dirty flag was set, falling back to editor.selection — which is whatever the last keystroke left it at, not the replacement's target. After applying the replacement, the caret also jumped back to the user's pre-replacement position instead of following the inserted text.

Reported as: typing a character and then accepting a suggestion places the replacement at the post-typing caret position instead of at the underlined word. For cross-block replacements (caret in one list item, suggestion accepted in another), the replacement landed in the wrong block AND the caret stayed in the original block.

The dirty-flag guard was inherited from upstream Slate, where it prevents crashes when NODE_TO_INDEX and NODE_TO_PARENT WeakMaps are stale relative to the DOM. PTE doesn't use those WeakMaps; toSlateRange walks data-slate-* DOM attributes directly via getDomNodePath, so the original failure mode the guard was protecting against doesn't exist here. Removing the guard lets the target range take effect; switching toSlateRange to suppressThrow: true and gating the editor.select(range) call on a non-null result keeps things safe if a path can't be resolved for any other reason.

The caret-jumps-back issue was a separate latent bug in the same path: the targetRange handler also stashed the pre-targetRange selection in userSelection, which the bottom-of-handler restore would then put back. Dropping that stash leaves the caret at the targetRange (where the replacement landed) — which is what users expect.

Includes regression tests for two scenarios:

  • A same-block replacement that triggers the dirty-flag bail (recent typing, then accept a suggestion in the same block). Without the fix the replacement appends to the post-typing caret instead of replacing the target word.
  • A cross-block replacement (caret in one list item, suggestion accepted in a different list item). Without the fix the replacement lands in the caret's block, not the target's block. Both scenarios assert the resulting caret position.

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 5, 2026

🦋 Changeset detected

Latest commit: 1e34d62

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 Patch
@portabletext/plugin-character-pair-decorator Patch
@portabletext/plugin-emoji-picker Patch
@portabletext/plugin-input-rule Patch
@portabletext/plugin-markdown-shortcuts Patch
@portabletext/plugin-one-line Patch
@portabletext/plugin-paste-link Patch
@portabletext/plugin-sdk-value Patch
@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
Copy Markdown

vercel Bot commented May 5, 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 May 5, 2026 7:27am
portable-text-example-basic Ready Ready Preview, Comment May 5, 2026 7:27am
portable-text-playground Ready Ready Preview, Comment May 5, 2026 7:27am

Request Review

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 5, 2026

📦 Bundle Stats — @portabletext/editor

Compared against editor-v6.x (ec6940c7)

@portabletext/editor

Metric Value vs editor-v6.x (ec6940c)
Internal (raw) 747.2 KB -222 B, -0.0%
Internal (gzip) 140.7 KB -46 B, -0.0%
Bundled (raw) 1.34 MB -223 B, -0.0%
Bundled (gzip) 301.1 KB -54 B, -0.0%
Import time 94ms -1ms, -0.9%

@portabletext/editor/behaviors

Metric Value vs editor-v6.x (ec6940c)
Internal (raw) 467 B -
Internal (gzip) 207 B -
Bundled (raw) 424 B -
Bundled (gzip) 171 B -
Import time 2ms -0ms, -1.7%

@portabletext/editor/plugins

Metric Value vs editor-v6.x (ec6940c)
Internal (raw) 2.5 KB -
Internal (gzip) 910 B -
Bundled (raw) 2.3 KB -
Bundled (gzip) 839 B -
Import time 8ms -0ms, -1.5%

@portabletext/editor/selectors

Metric Value vs editor-v6.x (ec6940c)
Internal (raw) 60.5 KB -
Internal (gzip) 9.5 KB -
Bundled (raw) 56.9 KB -
Bundled (gzip) 8.7 KB -
Import time 6ms -0ms, -0.9%

@portabletext/editor/utils

Metric Value vs editor-v6.x (ec6940c)
Internal (raw) 24.2 KB -
Internal (gzip) 4.7 KB -
Bundled (raw) 22.2 KB -
Bundled (gzip) 4.4 KB -
Import time 6ms -0ms, -0.9%

🗺️ . · ./behaviors · ./plugins · ./selectors · ./utils · Artifacts

Details
  • Import time regressions over 10% are flagged with ⚠️
  • Sizes shown as raw / gzip 🗜️. Internal bytes = own code only. Total bytes = with all dependencies. Import time = Node.js cold-start median.

@christianhg christianhg force-pushed the fix/grammarly-replacement-target-range branch from 19f9979 to 3ab969a Compare May 5, 2026 05:58
@christianhg christianhg force-pushed the fix/grammarly-replacement-target-range branch from 3ab969a to 6f5c797 Compare May 5, 2026 06:21
@christianhg christianhg force-pushed the fix/grammarly-replacement-target-range branch from 6f5c797 to fd94ec1 Compare May 5, 2026 06:24
@christianhg christianhg force-pushed the fix/grammarly-replacement-target-range branch from fd94ec1 to f235410 Compare May 5, 2026 06:51
When an extension or IME uses `insertReplacementText` (Grammarly, browser
autocorrect, macOS substitution panel, etc.), the replacement now lands
at the range the browser indicates via `getTargetRanges()` instead of
at the editor selection, and the caret lands right after the inserted
text instead of jumping back to where the user was typing.

The previous behavior bailed out when the node-map dirty flag was set
from a recent edit, falling back to `editor.selection`. Reported as:
typing a character and then accepting a suggestion placed the
replacement at the post-typing caret instead of at the underlined word.
For cross-block replacements, the caret also stayed in the original
block instead of following the replacement.

The dirty-flag guard came from upstream Slate to prevent crashes when
`NODE_TO_INDEX` and `NODE_TO_PARENT` WeakMaps were stale. PTE's
`toSlateRange` walks `data-slate-*` DOM attributes directly via
`getDomNodePath` and does not depend on those WeakMaps, so the guard
prevents a real fix without guarding against any failure mode in PTE.

The selection issue was caused by the targetRange path also stashing
the pre-targetRange selection in `userSelection` for later restoration.
That restore put the caret back where the user was typing instead of
leaving it at the targetRange where the replacement landed. The
targetRange is the user's intent for the new selection.
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