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 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()