Skip to content

fix: emit post-normalize snapshot in mutation events#2533

Open
christianhg wants to merge 1 commit into
mainfrom
fix/normalize-without-history
Open

fix: emit post-normalize snapshot in mutation events#2533
christianhg wants to merge 1 commit into
mainfrom
fix/normalize-without-history

Conversation

@christianhg
Copy link
Copy Markdown
Member

@christianhg christianhg commented Apr 22, 2026

When the editor became empty (for example by deleting the only block), normalization synthesized a fresh placeholder block to preserve the empty-tree invariant. The synth runs under withoutPatching, so it does not emit a patch. Meanwhile mutation bulks captured their value snapshot per-patch at emit time, so the final bulk recorded editor.children as [] from the last user unset and never saw the synthesized placeholder.

Consumers receiving that mutation event saw value: [], would call clearEditor or broadcast the empty value back to the editor, and subsequent undo attempts failed because the history was wiped or the round-trip inserted a differently-keyed placeholder that did not match the inverse of the original delete.

The fix is to capture the final bulk's snapshot from live editor.children at flush time rather than per-patch at emit time. By the time the mutation-machine flushes, any in-flight withoutNormalizing transaction has unwound and normalization has settled, so the live children accurately reflect the post-normalize state. Earlier pending bulks keep their in-sequence snapshots; only the final bulk is updated, because that is where a patch-less synth lands.

Regression tests exercise the scenario end-to-end: delete the only block, wait for the mutation debounce, assert the emitted mutation carries a post-normalize value, feed that value back in as an update value, undo, and expect the original content back.

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 22, 2026

🦋 Changeset detected

Latest commit: 5832724

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 Apr 22, 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 Apr 23, 2026 7:13am
portable-text-example-basic Ready Ready Preview, Comment Apr 23, 2026 7:13am
portable-text-playground Ready Ready Preview, Comment Apr 23, 2026 7:13am

Request Review

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 22, 2026

📦 Bundle Stats — @portabletext/editor

Compared against main (9fd84fd4)

@portabletext/editor

Metric Value vs main (9fd84fd)
Internal (raw) 729.1 KB +183 B, +0.0%
Internal (gzip) 138.2 KB +25 B, +0.0%
Bundled (raw) 1.32 MB +183 B, +0.0%
Bundled (gzip) 299.1 KB +25 B, +0.0%
Import time 94ms -0ms, -0.1%

@portabletext/editor/behaviors

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

@portabletext/editor/plugins

Metric Value vs main (9fd84fd)
Internal (raw) 3.1 KB -
Internal (gzip) 967 B -
Bundled (raw) 2.9 KB -
Bundled (gzip) 899 B -
Import time 8ms -0ms, -1.0%

@portabletext/editor/selectors

Metric Value vs main (9fd84fd)
Internal (raw) 60.5 KB -
Internal (gzip) 9.6 KB -
Bundled (raw) 57.0 KB -
Bundled (gzip) 8.7 KB -
Import time 6ms -0ms, -0.0%

@portabletext/editor/utils

Metric Value vs main (9fd84fd)
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.6%

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

When the editor became empty (for example by deleting the only block),
normalization synthesized a fresh placeholder block to preserve the
empty-tree invariant. The synth runs under `withoutPatching`, so it
does not emit a patch. Meanwhile, mutation bulks captured their
`value` snapshot per-patch at emit time, meaning the final bulk
recorded `editor.children` as `[]` from the last user unset and never
saw the synthesized placeholder.

Consumers receiving that mutation event saw `value: []`, would call
`clearEditor` or broadcast the empty value back to the editor, and
subsequent undo attempts failed because the history was wiped or the
round-trip inserted a differently-keyed placeholder that did not
match the inverse of the original delete.

The fix is to capture the final bulk's snapshot from live
`editor.children` at flush time rather than per-patch at emit time.
By the time the mutation-machine flushes, any in-flight
`withoutNormalizing` transaction has unwound and normalization has
settled, so the live children accurately reflect the post-normalize
state. Earlier pending bulks keep their in-sequence snapshots; only
the final bulk is updated, because that is where a patch-less synth
lands.
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