Skip to content

fix: re-materialize the field before patches emitted while it is unset - #3253

Merged
christianhg merged 2 commits into
mainfrom
root-unset-rematerialization
Sep 9, 2026
Merged

fix: re-materialize the field before patches emitted while it is unset#3253
christianhg merged 2 commits into
mainfrom
root-unset-rematerialization

Conversation

@christianhg

@christianhg christianhg commented Sep 9, 2026

Copy link
Copy Markdown
Member

A behavior that replaces the whole value in one action set (raise a root unset, then insert.block) emitted a patch stream no patch-applying store could apply: the insert targeted a field the preceding unset([]) had just removed, and applying it throws Cannot apply deep operations on primitive values. The editor itself ended in the intended state; only the emitted stream was broken.

The became-empty bookkeeping (valueUnsetEmitted) was written from two ad-hoc sites, both keyed to the length-1 placeholder transition. A grouped action set takes the engine through a genuine zero-block state that shape never matches, so the unset([]) went out without arming the flag and the insert went out naked. The flag is now derived from the emitted stream itself: after an event's patches are composed, one walk arms it on a root unset and disarms it on a root setIfMissing/set, and patches emitted while the field is destroyed get setIfMissing([], []) prepended. Deriving instead of hand-writing also fixes the orders the ad-hoc writes got wrong, each pinned by its own scenario: a double root unset keeps the flag armed between the unsets, and a replace-all raised from the empty placeholder ends armed even though its event began with the placeholder rebuild. A redo-replay scenario covers the history path.

One narrow fix rides along, disclosed in the changeset: behavior-raised root unsets now arm the same re-materialization guard that keyboard clearing already had, so a retype after such a clear rebuilds the field under a value-mirroring host. Emitted patches are unchanged for every path that does not pass through a destroyed field.


Note

Medium Risk
Changes core patch emission and empty-field bookkeeping; incorrect logic could alter patch streams for clear/replace flows, though scope is narrow and heavily tested.

Overview
Fixes broken patch streams when behaviors replace the whole document in one action set (root unset then insert.block). Stores could not apply the emitted insert after unset([]), causing Cannot apply deep operations on primitive values; the editor state was correct, only the outbound patches were wrong.

Patch generation now tracks whether the root field is “destroyed” via valueUnsetEmitted, derived by scanning each batch of emitted patches (root unset arms it; root setIfMissing/set disarms it) instead of toggling the flag at two placeholder-transition sites. When the stream is still unset and the next patches are not themselves destroy/rebuild, it prepends setIfMissing([], []) so downstream inserts apply cleanly. Double root unsets, replace-all from an empty placeholder, and history redo are covered by new tests.

A related fix: clearing via a behavior-raised root unset now matches keyboard clear—retyping under a value-mirroring host re-materializes the field before content patches.

Reviewed by Cursor Bugbot for commit db5b02a. Bugbot is set up for automated code reviews on this repo. Configure here.

…placement

A behavior can raise a root `unset` followed by `insert.block` in one
action set, taking the engine through a zero-block state between the
operations. The emitted patch stream must stay applicable to a
patch-applying store throughout: after `unset([])` destroys the field,
`setIfMissing([], [])` must precede the `insert`, or the store throws
`Cannot apply deep operations on primitive values`.

Four scenarios: the plain replace-all (root `unset` then `insert.block`),
the same raised twice (`unset`, `unset`, `insert`: no rebuild between
the unsets, one before the insert), replace-all raised while the editor
is the empty placeholder (the placeholder rebuild triple precedes the
`unset`), and a `history.redo` replay of the replace-all group. Each
scenario closes by applying the collected patches with `applyAll` and
asserting equality with the editor's final value.

Red before the fix: the streams carried a naked `insert` after the
`unset([])` (and the double-unset order emitted a pointless
`setIfMissing` between the unsets), so `applyAll` threw. The redo
scenario is coverage for the replay path, green on both sides.
`valueUnsetEmitted` tracked the destroyed-field state from two ad-hoc
write sites, both keyed to the length-1 placeholder transition
(`editorWasEmpty`/`editorIsEmpty` in `subscriber.patch-generation.ts`).
A behavior raising a root `unset` mid action set takes the engine
through a genuine zero-block state those predicates never match: the
`unset([])` went out without arming the flag, the following `insert`
went out naked, and a patch-applying store threw
`Cannot apply deep operations on primitive values`.

The flag is now derived from the emitted stream itself. After an
event's patch list is fully composed, a single walk sets the flag on a
root `unset` and clears it on a root `setIfMissing` or `set`,
replacing every ad-hoc write. Patches generated while the previous
value has zero blocks and the flag is armed get `setIfMissing([], [])`
prepended, unless the first patch is itself a root unset (a second
destroy needs no rebuild) or already root-materializing. The walk
placement makes the flag truthful in orders the ad-hoc writes got
wrong: double root unset keeps it armed between the unsets, and a
replace-all raised from the empty placeholder ends armed even though
the event started with the placeholder rebuild.

One narrow behavioral fix rides along, disclosed in the changeset:
behavior-raised root unsets now arm the same stale-echo
re-materialization guard that keyboard clearing already had, so a
retype after such a clear rebuilds the field under a value-mirroring
host. Emitted patches are otherwise unchanged for every path that does
not pass through a destroyed field.
@vercel

vercel Bot commented Sep 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
portable-text-editor-documentation Ready Ready Preview Sep 9, 2026 7:50am UTC
portable-text-example-basic Ready Ready Preview Sep 9, 2026 7:50am UTC
portable-text-playground Ready Ready Preview Sep 9, 2026 7:50am UTC

Request Review

@changeset-bot

changeset-bot Bot commented Sep 9, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: db5b02a

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

@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Bundle Stats

✅ No significant changes.

All scenario measurements (7)

🗺️ @portabletext/editor / @portabletext/editor · @portabletext/editor / @portabletext/editor/behaviors · @portabletext/editor / @portabletext/editor/plugins · @portabletext/editor / @portabletext/editor/selectors · @portabletext/editor / @portabletext/editor/traversal · @portabletext/editor / @portabletext/editor/utils · @portabletext/markdown / @portabletext/markdown · Artifacts

Scenario Kind Bundle (raw / gzip) Gzip change Import time Import change
⚪ @portabletext/editor / @portabletext/editor export 1.09 MB / 254.3 KB +99 B, +0.0% 53 ms -2 ms, -3.2%
⚪ @portabletext/editor / @portabletext/editor/behaviors export 4.0 KB / 1.4 KB None 2 ms -0 ms, -9.6%
⚪ @portabletext/editor / @portabletext/editor/plugins export 5.1 KB / 1.8 KB None 6 ms +0 ms, +4.8%
⚪ @portabletext/editor / @portabletext/editor/selectors export 94.7 KB / 21.7 KB None 6 ms -0 ms, -0.6%
⚪ @portabletext/editor / @portabletext/editor/traversal export 42.8 KB / 11.2 KB None 5 ms -0 ms, -2.6%
⚪ @portabletext/editor / @portabletext/editor/utils export 33.8 KB / 9.1 KB None 5 ms -0 ms, -0.7%
⚪ @portabletext/markdown / @portabletext/markdown export 312.3 KB / 90.7 KB None 33 ms +3 ms, +11.6%

Significant means at least 1.0 KB and 1% gzip, or at least 5 ms and 10% import time.

@christianhg
christianhg marked this pull request as ready for review September 9, 2026 07:55
@christianhg
christianhg merged commit a4bea35 into main Sep 9, 2026
20 checks passed
@christianhg
christianhg deleted the root-unset-rematerialization branch September 9, 2026 08:17
@ecoscript ecoscript Bot mentioned this pull request Sep 9, 2026
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