Skip to content

fix: remove the local placeholder on a remote root insert after a self-emitted unset - #3257

Merged
christianhg merged 2 commits into
mainfrom
remote-insert-placeholder-removal
Sep 9, 2026
Merged

fix: remove the local placeholder on a remote root insert after a self-emitted unset#3257
christianhg merged 2 commits into
mainfrom
remote-insert-placeholder-removal

Conversation

@christianhg

@christianhg christianhg commented Sep 9, 2026

Copy link
Copy Markdown
Member

After clearing a Portable Text field under a host that mirrors mutation.value back into update value, a collaborator's block arriving through patches left a phantom block behind: the editor showed its local placeholder next to the inserted block while the document held only the block, and text typed into the phantom emitted patches against a span no store has, so it never persisted.

The cause is the same poisoned recording #3253's sibling fix guards against in patch generation. A lagging mirror echo of the cleared state records the editor's own placeholder as the last synced value, and insertPatch's root-insert handling decided whether to remove the placeholder from that equality alone, reading the poison as proof of persistence. The predicate now mirrors patch generation's: while this editor's own stream has unset the field (valueUnsetEmitted), a value-equal placeholder is treated as unpersisted and removed. The remote write deliberately does not clear the flag; only the editor's own emitted rebuild does.

One additional change, deliberate and pinned by the test: a lone empty block that a host genuinely persisted inside this window is indistinguishable by value from the placeholder, so it is now also removed when a remote root insert arrives, until the next value sync restores it. Telling the two apart takes provenance metadata, which is follow-up work. Behavior is unchanged whenever the editor has not unset the field itself: pristine blocks synced in without a preceding local clear keep suppressing the removal through the existing equality, pinned by the existing scenarios.


Note

Medium Risk
Changes collaborative patch application and empty-editor placeholder logic; incorrect handling could drop blocks or show duplicates, but scope is narrow and covered by new tests.

Overview
Fixes a collaboration bug where, after the local editor clears the field (unset) under a host that mirrors mutation.value back, a remote root insert could leave a phantom empty block beside the collaborator’s content.

insertPatch now uses the same editorWasEmptyBefore rule as patch generation: when valueUnsetEmitted is set, a placeholder that still matches lastSyncedValue (including a stale mirror echo) is treated as unpersisted, so the empty block is removed when applying the remote insert. Docs on valueUnsetEmitted are updated to note this applies to remote root inserts too.

Trade-off: a genuinely persisted empty block after a local unset may also be dropped until the next value sync; separating that from the editor placeholder needs provenance metadata (called out as follow-up). Behavior when the editor has not locally unset is unchanged.

Adds a regression test for the mirror-echo + remote insert scenario and a changeset for @portabletext/editor.

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

…emitted `unset`

After this editor's stream ends with a became-empty `unset([])`, a
lagging mirrored `update value` echo can record the editor's own
placeholder as `lastSyncedValue` (the same settled poisoned state the
character-by-character retype scenario builds). A collaborator's block
then arriving through `patches` (`setIfMissing([], [])` plus a root
`insert`) must leave the engine holding exactly that block: keeping the
placeholder would show a block the document does not have, and text
typed into it would emit patches against a span no store holds.

The scenario asserts the removal even though the recorded value equals
the placeholder: while the editor's own stream has destroyed the field,
a value-equal placeholder is deliberately treated as unpersisted. That
pins the accepted trade; narrowing the predicate back to the
`lastSyncedValue` equality alone turns this red.

Red before the fix, three-for-three: the engine kept two blocks (the
collaborator's block plus the phantom placeholder) against the expected
one.
…elf-emitted `unset`

`insertPatch` in `applyPatch.ts` decided whether the editor was empty
before a remote root insert (and hence whether to remove the local
placeholder after applying it) from the `lastSyncedValue` equality
alone. After a character-by-character clear under a value-mirroring
host, a lagging echo records the placeholder as `lastSyncedValue`, the
equality reads as proof of persistence, and the placeholder survives
next to the inserted block: the editor shows a block the document does
not have, and edits into it emit patches no store can apply.

The predicate now mirrors `editorWasEmpty` in
`subscriber.patch-generation.ts`: while `valueUnsetEmitted` is set,
this editor's own stream has destroyed the field, so a value-equal
placeholder is treated as unpersisted and removed. The flag is
deliberately not cleared by the remote write; only the editor's own
emitted rebuild clears it.

One accepted trade, disclosed in the changeset: a lone empty block
that a host genuinely persisted inside this window is indistinguishable
by value from the placeholder and is now removed when a remote root
insert arrives, until the next value sync restores it. Distinguishing
the two takes provenance metadata, tracked as follow-up work. Behavior
is unchanged whenever `valueUnsetEmitted` is unset: pristine blocks
synced in without a preceding local clear keep suppressing the removal
via the existing `lastSyncedValue` equality.
@changeset-bot

changeset-bot Bot commented Sep 9, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 4591bb6

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 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 8:24am UTC
portable-text-example-basic Ready Ready Preview Sep 9, 2026 8:24am UTC
portable-text-playground Ready Ready Preview Sep 9, 2026 8:24am UTC

Request Review

@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 None 69 ms +0 ms, +0.4%
⚪ @portabletext/editor / @portabletext/editor/behaviors export 4.0 KB / 1.4 KB None 2 ms +0 ms, +6.8%
⚪ @portabletext/editor / @portabletext/editor/plugins export 5.1 KB / 1.8 KB None 7 ms +0 ms, +4.6%
⚪ @portabletext/editor / @portabletext/editor/selectors export 94.7 KB / 21.7 KB None 8 ms +0 ms, +0.7%
⚪ @portabletext/editor / @portabletext/editor/traversal export 42.8 KB / 11.2 KB None 6 ms -0 ms, -5.6%
⚪ @portabletext/editor / @portabletext/editor/utils export 33.8 KB / 9.1 KB None 6 ms +0 ms, +3.0%
⚪ @portabletext/markdown / @portabletext/markdown export 312.3 KB / 90.7 KB None 42 ms +1 ms, +2.3%

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 08:24
@christianhg
christianhg merged commit df4b4ac into main Sep 9, 2026
20 checks passed
@christianhg
christianhg deleted the remote-insert-placeholder-removal branch September 9, 2026 09:36
@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