Skip to content

fix: keep leading no-op operations from consuming undo-step boundaries#2983

Draft
christianhg wants to merge 1 commit into
mainfrom
fix-undo-step-boundaries
Draft

fix: keep leading no-op operations from consuming undo-step boundaries#2983
christianhg wants to merge 1 commit into
mainfrom
fix-undo-step-boundaries

Conversation

@christianhg

@christianhg christianhg commented Jul 20, 2026

Copy link
Copy Markdown
Member

A single undo can revert more than the most recent edit. The history subscriber's isNoOp early-return (operations without an inverse, zero-length text operations) advanced previousUndoStepId before bailing, so a no-op leading a new undo step consumed the step boundary: the next history-affecting operation saw currentUndoStepId === previousUndoStepId and merged into the previous step. One undo then reverted two edits' worth of changes. previousUndoStepId now only advances for operations that reach createUndoSteps, since its job is to track the last operation that affected history.

This is reachable on main through public API: child.set replacing a span's text leads with a remove.text of the old text, which is zero-length when the span was empty. The new scenario "Undoing a child.set that replaces an empty span's text" pins it: two edits through execute actions, one undo, only the second edit reverts. On main the same test fails with both edits reverted. The producer isn't unique to child.set: applySplitNode emits an unguarded zero-length remove.text when splitting a span at its end, which the annotation and block-merge flows reach today, masked only because an unconditionally applied whole-array markDefs set happens to hit the history subscriber first. #2982 removes that masking operation, which is how both defects surfaced; it stacks on this.

The same masking hid a second defect: a step's pre-state selection was captured from the first history-affecting operation's beforeSelection, but operation implementations can park the selection on transient nodes (created and removed within the same step) before that operation applies. Undo then restored a selection that no longer resolves and the cursor disappeared. The engine now snapshots its selection as undoStepSelection at the moment editor.undoStepId is minted, and the history subscriber uses that snapshot as the pre-state selection whenever an operation opens a new step. This half is exercised without the mask by the custom-event undo scenarios in #2982; on main no unmasked flow reaches it, so its pin lives there.

Net behavior unchanged for flows whose steps begin with a history-affecting operation; the full unit and browser suites pass without modification.

@changeset-bot

changeset-bot Bot commented Jul 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c7dbf1b

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

This PR includes changesets to release 14 packages
Name Type
@portabletext/editor Patch
@portabletext/plugin-character-pair-decorator Patch
@portabletext/plugin-dnd Patch
@portabletext/plugin-emoji-picker Patch
@portabletext/plugin-input-rule Patch
@portabletext/plugin-list-index Patch
@portabletext/plugin-markdown-shortcuts Patch
@portabletext/plugin-one-line Patch
@portabletext/plugin-paste-link Patch
@portabletext/plugin-sdk-value Patch
@portabletext/plugin-table 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

vercel Bot commented Jul 20, 2026

Copy link
Copy Markdown

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 Jul 21, 2026 8:33am
portable-text-example-basic Ready Ready Preview, Comment Jul 21, 2026 8:33am
portable-text-playground Ready Ready Preview, Comment Jul 21, 2026 8:33am

Request Review

@github-actions

github-actions Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

📦 Bundle Stats — @portabletext/editor

Compared against main (04b79737)

@portabletext/editor

Metric Value vs main (04b7973)
Internal (raw) 804.7 KB +631 B, +0.1%
Internal (gzip) 154.5 KB +173 B, +0.1%
Bundled (raw) 1.42 MB +631 B, +0.0%
Bundled (gzip) 318.8 KB +169 B, +0.1%
Import time 96ms +0ms, +0.3%

@portabletext/editor/behaviors

Metric Value vs main (04b7973)
Internal (raw) 467 B -
Internal (gzip) 207 B -
Bundled (raw) 424 B -
Bundled (gzip) 171 B -
Import time 2ms +0ms, +0.1%

@portabletext/editor/plugins

Metric Value vs main (04b7973)
Internal (raw) 2.7 KB -
Internal (gzip) 894 B -
Bundled (raw) 2.5 KB -
Bundled (gzip) 827 B -
Import time 7ms +0ms, +1.9%

@portabletext/editor/selectors

Metric Value vs main (04b7973)
Internal (raw) 81.8 KB -
Internal (gzip) 15.2 KB -
Bundled (raw) 77.6 KB -
Bundled (gzip) 14.1 KB -
Import time 8ms +0ms, +0.9%

@portabletext/editor/traversal

Metric Value vs main (04b7973)
Internal (raw) 28.1 KB -
Internal (gzip) 5.6 KB -
Bundled (raw) 28.1 KB -
Bundled (gzip) 5.5 KB -
Import time 6ms -0ms, -1.1%

@portabletext/editor/utils

Metric Value vs main (04b7973)
Internal (raw) 29.7 KB -
Internal (gzip) 6.2 KB -
Bundled (raw) 27.4 KB -
Bundled (gzip) 5.9 KB -
Import time 6ms -0ms, -1.1%

🗺️ . · ./behaviors · ./plugins · ./selectors · ./traversal · ./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.

📦 Bundle Stats — @portabletext/markdown

Compared against main (04b79737)

Metric Value vs main (04b7973)
Internal (raw) 53.8 KB -
Internal (gzip) 9.8 KB -
Bundled (raw) 348.9 KB -
Bundled (gzip) 96.3 KB -
Import time 38ms -1ms, -2.9%

🗺️ View treemap · 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.

The history subscriber's `isNoOp` early-return advanced
`previousUndoStepId` before bailing. A no-op (an operation without an
inverse, or a zero-length text operation) leading a new undo step then
consumed the step boundary: the next history-affecting operation saw
`currentUndoStepId === previousUndoStepId` and merged into the
previous step, so a single undo reverted more than the last step's
worth of changes. `previousUndoStepId` now only advances for
operations that reach `createUndoSteps`, since it tracks the last
operation that affected history.

Relatedly, a step's pre-state selection was captured from the first
history-affecting operation's `beforeSelection`. Operation
implementations may park the selection on transient nodes (created and
removed within the same step) before that operation applies, and undo
then restored a selection that no longer resolves, dropping the cursor.
The engine now snapshots its selection as `undoStepSelection` when
`editor.undoStepId` is minted, and the history subscriber uses that
snapshot as the step's pre-state selection when an operation opens a
new step.

Both defects are masked on `main` in the annotation and block-merge
flows by an unconditionally applied whole-array `markDefs` set
operation that reached `createUndoSteps` first, creating the boundary
and capturing the selection before it moved. Emitting item-level
`markDefs` operations removes that accidental mask, which is what
surfaced both.
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