Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/remove-placeholder-on-remote-insert-after-unset.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 9 additions & 5 deletions packages/editor/src/internal-utils/applyPatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
6 changes: 3 additions & 3 deletions packages/editor/src/types/editor-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
102 changes: 102 additions & 0 deletions packages/editor/tests/event.patches.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Patch> = []
const {editor, locator} = await createTestEditor({
keyGenerator: createTestKeyGenerator(),
schemaDefinition: defineSchema({}),
children: (
<EventListenerPlugin
on={(event) => {
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<Patch> = []
const keyGenerator = createTestKeyGenerator()
Expand Down
Loading