From 848e194e30e1f1643cfbfc68478a4fff79100fd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Hamburger=20Gr=C3=B8ngaard?= Date: Wed, 9 Sep 2026 10:19:44 +0200 Subject: [PATCH 1/2] test: pin placeholder removal on a remote root `insert` after a self-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. --- packages/editor/tests/event.patches.test.tsx | 102 +++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/packages/editor/tests/event.patches.test.tsx b/packages/editor/tests/event.patches.test.tsx index a12435b27..6f251f267 100644 --- a/packages/editor/tests/event.patches.test.tsx +++ b/packages/editor/tests/event.patches.test.tsx @@ -5679,6 +5679,108 @@ describe('event.patches', () => { }) }) + test('Scenario: A remote root `insert` after a self-emitted `unset` removes even a recorded placeholder', async () => { + const remoteBazBlock = { + _type: 'block', + _key: 'c0', + style: 'normal', + markDefs: [], + children: [{_type: 'span', _key: 'c1', text: 'baz', marks: []}], + } + const patches: Array = [] + const {editor, locator} = await createTestEditor({ + keyGenerator: createTestKeyGenerator(), + schemaDefinition: defineSchema({}), + children: ( + { + if (event.type === 'patch') { + patches.push(event.patch) + } + }} + /> + ), + }) + + // Build the same poisoned recording as 'Retyping after a + // character-by-character clear when the host mirrors values': a + // stale mirrored echo lands after the editor's own `unset([])` and + // records the placeholder as the last synced value. A remote root + // `insert` must override that recording the same way the local + // retype does. + editor.on('mutation', (event) => { + editor.send({type: 'update value', value: event.value}) + }) + let remoteOperations = 0 + editor.on('operation', (event) => { + if (event.origin === 'remote') { + remoteOperations++ + } + }) + + await userEvent.click(locator) + await userEvent.type(locator, 'foo') + + await vi.waitFor(() => { + expect(patches.at(-1)).toEqual({ + type: 'diffMatchPatch', + path: [{_key: 'k0'}, 'children', {_key: 'k1'}, 'text'], + value: stringifyPatches(makePatches(makeDiff('fo', 'foo'))), + origin: 'local', + }) + }) + + await userEvent.keyboard('{Backspace}{Backspace}{Backspace}') + + await vi.waitFor(() => { + expect(patches.at(-1)).toEqual({ + type: 'unset', + path: [], + origin: 'local', + }) + }) + + await vi.waitFor(() => { + expect(remoteOperations).toBeGreaterThan(0) + expect(editor.getSnapshot().context.value).toEqual([ + { + _type: 'block', + _key: 'k0', + style: 'normal', + markDefs: [], + children: [{_type: 'span', _key: 'k1', text: '', marks: []}], + }, + ]) + }) + + editor.send({ + type: 'patches', + patches: [ + {type: 'setIfMissing', path: [], value: [], origin: 'remote'}, + { + type: 'insert', + path: [0], + position: 'before', + items: [remoteBazBlock], + origin: 'remote', + }, + ], + snapshot: [remoteBazBlock], + }) + + await vi.waitFor(() => { + expect(editor.getSnapshot().context.value).toEqual([ + { + _type: 'block', + _key: 'c0', + style: 'normal', + markDefs: [], + children: [{_type: 'span', _key: 'c1', text: 'baz', marks: []}], + }, + ]) + }) + }) + test('Scenario: a behavior raising root `unset` then `insert.block` in one action set emits an applicable patch stream', async () => { const patches: Array = [] const keyGenerator = createTestKeyGenerator() From 4591bb6081e0fe2c3b0b87623a47c8c0a7c667a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Hamburger=20Gr=C3=B8ngaard?= Date: Wed, 9 Sep 2026 10:19:45 +0200 Subject: [PATCH 2/2] fix: remove the local placeholder on a remote root `insert` after a self-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. --- ...ove-placeholder-on-remote-insert-after-unset.md | 7 +++++++ packages/editor/src/internal-utils/applyPatch.ts | 14 +++++++++----- packages/editor/src/types/editor-engine.ts | 6 +++--- 3 files changed, 19 insertions(+), 8 deletions(-) create mode 100644 .changeset/remove-placeholder-on-remote-insert-after-unset.md diff --git a/.changeset/remove-placeholder-on-remote-insert-after-unset.md b/.changeset/remove-placeholder-on-remote-insert-after-unset.md new file mode 100644 index 000000000..44581d8ac --- /dev/null +++ b/.changeset/remove-placeholder-on-remote-insert-after-unset.md @@ -0,0 +1,7 @@ +--- +'@portabletext/editor': patch +--- + +fix: remove the local placeholder on a remote root `insert` after a self-emitted `unset` + +After clearing the field under a value-mirroring host, a collaborator's block arriving through `patches` no longer leaves an extra empty block in the editor; the editor shows only the collaborator's content. One additional change: an empty block that a host genuinely persisted in the window after the editor's own `unset` is now removed from the editor when a remote root `insert` arrives, until the next value sync restores it; distinguishing it from the editor's own placeholder takes provenance metadata, which is follow-up work. diff --git a/packages/editor/src/internal-utils/applyPatch.ts b/packages/editor/src/internal-utils/applyPatch.ts index 96dd4a105..fc5b844c4 100644 --- a/packages/editor/src/internal-utils/applyPatch.ts +++ b/packages/editor/src/internal-utils/applyPatch.ts @@ -151,11 +151,15 @@ function insertPatch( editor.snapshot.context.value, context.schema, ) && - !isEqualValues( - {schema: context.schema}, - editor.lastSyncedValue, - editor.snapshot.context.value, - ) + (editor.valueUnsetEmitted || + // Mirrors `editorWasEmpty` in `subscriber.patch-generation.ts`: once + // this editor's stream has unset the field, a value-equal recording + // is not proof of persistence. + !isEqualValues( + {schema: context.schema}, + editor.lastSyncedValue, + editor.snapshot.context.value, + )) const arrayFieldPath = patch.path.slice(0, -1) diff --git a/packages/editor/src/types/editor-engine.ts b/packages/editor/src/types/editor-engine.ts index 1b290f98b..368cdf2b9 100644 --- a/packages/editor/src/types/editor-engine.ts +++ b/packages/editor/src/types/editor-engine.ts @@ -93,9 +93,9 @@ export interface PortableTextEditorEngine extends DOMEditor { * True while this editor's own emitted patch stream has destroyed the * field (a root `unset([])` went out) and not yet re-materialized it (a * root `setIfMissing` or `set` went out since). While true, patch - * generation rebuilds the field before targeting it again and treats a - * placeholder equal to `lastSyncedValue` as unpersisted rather than as - * proof the field survived. + * generation rebuilds the field before targeting it again, and both it + * and remote root inserts treat a placeholder equal to `lastSyncedValue` + * as unpersisted rather than as proof the field survived. */ valueUnsetEmitted: boolean isPatching: boolean