fix undoing setAttribute combined with delete corrupts remote state - closes #757

When an attribute change and a delete on the same XmlText are bundled in
a single undo stack item, the redone attribute item kept an `origin`
pointing at an item under the now-deleted original parent. The encoder
then omitted parent info (origin was non-null), and the remote derived
parent from origin -- attaching the attribute to the wrong, deleted
type. The restored attribute went missing on sync.

In redoItem, after determining `left` for parentSub items, drop it if
it refers to a different parent than the redo target. This forces the
encoder to write parent info directly, so the remote attaches the
attribute to the correct (redone) parent.
This commit is contained in:
Pawel Piotrowicz
2026-05-08 11:09:26 +02:00
parent 676cc334ed
commit 67c809ee6b
2 changed files with 36 additions and 0 deletions

View File

@@ -223,6 +223,10 @@ export const redoItem = (transaction, item, redoitems, itemsToDelete, ignoreRemo
} else {
left = parentType._map.get(item.parentSub) || null
}
// drop cross-parent left so origin doesn't mislead the remote (#757)
if (left !== null && left.parent !== parentType) {
left = null
}
}
const nextClock = getState(store, ownClientID)
const nextId = createID(ownClientID, nextClock)

View File

@@ -811,3 +811,35 @@ export const testUndoDoingStackItem = async (_tc) => {
t.compare(metaRedo, '42', 'currStackItem is accessible while redoing')
t.compare(undoManager.currStackItem, null, 'currStackItem is null after observe/transaction')
}
/**
* @see https://github.com/yjs/yjs/issues/757
* @param {t.TestCase} _tc
*/
export const testUndoSetAttributeAndDeleteSyncsAttributes = _tc => {
const doc = new Y.Doc()
const root = /** @type {Y.XmlText} */ (doc.get('sharedRoot', Y.XmlText))
const button = new Y.XmlText()
button.setAttribute('type', 'button')
button.setAttribute('test', true)
button.insert(0, 'Click me')
root.insertEmbed(0, button)
const undoManager = new Y.UndoManager(root)
undoManager.stopCapturing()
button.setAttribute('type', 'paragraph')
root.delete(0, 1)
undoManager.undo()
const expectedAttrs = { type: 'button', test: true }
const expectedText = 'Click me'
t.compare(root.toDelta()[0].insert.getAttributes(), expectedAttrs)
t.compare(root.toDelta()[0].insert.toString(), expectedText)
const remoteDoc = new Y.Doc()
Y.applyUpdateV2(remoteDoc, Y.encodeStateAsUpdateV2(doc))
const remoteRoot = /** @type {Y.XmlText} */ (remoteDoc.get('sharedRoot', Y.XmlText))
t.compare(remoteRoot.toDelta()[0].insert.getAttributes(), expectedAttrs)
t.compare(remoteRoot.toDelta()[0].insert.toString(), expectedText)
}