diff --git a/src/utils/YEvent.js b/src/utils/YEvent.js index 0ff7e078..43bb9636 100644 --- a/src/utils/YEvent.js +++ b/src/utils/YEvent.js @@ -131,7 +131,7 @@ export class YEvent { } modified = dchanged } - return /** @type {any} */ (this.target.toDelta({ renderer, itemsToRender, retainDeletes: true, insertedItems: insertSet, deletedItems: deleteSet, deep: !!deep, modified })) + return /** @type {any} */ (this.target.toDelta({ renderer, itemsToRender, retainDeletes: true, insertedItems: insertSet, deep: !!deep, modified })) } /** diff --git a/src/utils/delta-helpers.js b/src/utils/delta-helpers.js index 359aea76..82f42cbc 100644 --- a/src/utils/delta-helpers.js +++ b/src/utils/delta-helpers.js @@ -13,7 +13,6 @@ export const diffDocsToDelta = (v1, v2, { renderer = createDiffRenderer(v1, v2) const deleteDiff = diffIdSet(createDeleteSetFromStructStore(v2.store), createDeleteSetFromStructStore(v1.store)) // don't render items that have been inserted and then deleted const insertsOnly = diffIdSet(insertDiff, deleteDiff) - const deletesOnly = diffIdSet(deleteDiff, insertDiff) const itemsToRender = mergeIdSets([insertsOnly, deleteDiff]) /** * @type {Map>} @@ -24,7 +23,7 @@ export const diffDocsToDelta = (v1, v2, { renderer = createDiffRenderer(v1, v2) const typeConf = changedTypes.get(type) if (typeConf) { const shareDelta = type.toDelta({ - renderer, itemsToRender, retainDeletes: true, deletedItems: deletesOnly, modified: changedTypes, deep: true + renderer, itemsToRender, retainDeletes: true, modified: changedTypes, deep: true }) d.modifyAttr(typename, shareDelta) } diff --git a/src/ytype.js b/src/ytype.js index c2817d41..8f0c6650 100644 --- a/src/ytype.js +++ b/src/ytype.js @@ -416,21 +416,38 @@ export const deleteText = (transaction, currPos, length) => { */ const contents = [] currPos.renderer.readContent(contents, item.id.client, item.id.clock, true, item.content, 0) + let splitClock = -1 for (let i = 0; i < contents.length; i++) { const c = contents[i] if (c.content.isCountable() && c.attrs != null) { + if (length === 0) { + // the delete is exhausted but this item renders more content — split so the cursor + // advances only past the consumed part (mirrors formatText's renderer branch); + // otherwise every following op of the same delta targets too far right + splitClock = c.clock + break + } // deleting already deleted content. store that information in a meta property, but do // nothing - const contentLen = math.min(c.content.getLength(), length) + const pieceLen = c.content.getLength() + const contentLen = math.min(pieceLen, length) map.setIfUndefined(transaction.meta, 'attributedDeletes', createIdSet).add(item.id.client, c.clock, contentLen) length -= contentLen + if (contentLen < pieceLen) { + splitClock = c.clock + contentLen + break + } } } - const lastContent = contents.length > 0 ? contents[contents.length - 1] : null - const nextItemClock = item.id.clock + item.length - const nextContentClock = lastContent != null ? lastContent.clock + lastContent.content.getLength() : nextItemClock - if (nextContentClock < nextItemClock) { - getItemCleanStart(transaction, createID(item.id.client, nextContentClock)) + if (splitClock >= 0) { + getItemCleanStart(transaction, createID(item.id.client, splitClock)) + } else { + const lastContent = contents.length > 0 ? contents[contents.length - 1] : null + const nextItemClock = item.id.clock + item.length + const nextContentClock = lastContent != null ? lastContent.clock + lastContent.content.getLength() : nextItemClock + if (nextContentClock < nextItemClock) { + getItemCleanStart(transaction, createID(item.id.client, nextContentClock)) + } } } currPos.forward() @@ -999,7 +1016,6 @@ export class YType extends ObservableV2 { * @param {boolean} [opts.retainInserts] - if true, retain rendered inserts with attributions * @param {boolean} [opts.retainDeletes] - if true, retain rendered+attributed deletes only * @param {IdSet?} [opts.insertedItems] - ids inserted by the change being rendered; content that is both in `insertedItems` and deleted renders as a *fresh* insert even under `retainDeletes` (it was never part of the consuming state) - * @param {IdSet?} [opts.deletedItems] - used for computing prevItem in attributes * @param {Map>|null} [opts.modified] - set of types that should be rendered as modified children * @param {Deep} [opts.deep] - render child types as delta * @return {Deep extends true ? delta.Delta : delta.Delta>} The Delta representation of this type. @@ -1007,7 +1023,7 @@ export class YType extends ObservableV2 { * @public */ toDelta (opts = {}) { - const { renderer = this._renderer, itemsToRender = null, retainInserts = false, retainDeletes = false, insertedItems = null, deletedItems = null, deep = false } = opts + const { renderer = this._renderer, itemsToRender = null, retainInserts = false, retainDeletes = false, insertedItems = null, deep = false } = opts const { modified = (deep && itemsToRender) ? computeModifiedFromItems(/** @type {Doc} */ (this.doc).store, itemsToRender) : null } = opts const renderAttrs = modified?.get(this) || null const renderChildren = modified == null || !modified.has(this) || /** @type {Set} */ (modified.get(this)).has(null) @@ -1017,7 +1033,7 @@ export class YType extends ObservableV2 { const d = /** @type {any} */ (delta.create(this.name)) const optsAll = object.assign({}, opts, { renderer, modified }) // opts has been re-computed - do not use opts after this point! - typeMapGetDelta(d, /** @type {any} */ (this), renderAttrs, renderer, deep, modified, deletedItems, itemsToRender, optsAll, optsAll) + typeMapGetDelta(d, /** @type {any} */ (this), renderAttrs, renderer, deep, modified, itemsToRender, optsAll, optsAll) if (renderChildren) { /** * @type {delta.Formats} @@ -1105,20 +1121,20 @@ export class YType extends ObservableV2 { // (`c.fresh` content is exempt from `retainDeletes`: it was inserted *and* // deleted by this very change, so the consuming state holds nothing to retain — // it renders as an insert below, carrying its attribution.) - d.usedFormats = changedFormats + d.useFormats(changedFormats) usingChangedFormats = true // change render: a retained item with no attribution means its attribution was // removed → emit a clear rather than `{}` (skip). Present attribution merges. d.retain(/** @type {ContentString} */ (c.content).str.length, undefined, attribution ?? clearedOwnAttribution()) } else { - d.usedFormats = currentFormats + d.useFormats(currentFormats) usingCurrentFormats = true d.insert(/** @type {ContentString} */ (c.content).str, undefined, attribution) } } else if (renderDelete) { d.delete(c.content.getLength()) } else if (retainContent) { - d.usedFormats = changedFormats + d.useFormats(changedFormats) usingChangedFormats = true d.retain(c.content.getLength()) } @@ -1132,7 +1148,7 @@ export class YType extends ObservableV2 { if (renderContent) { if (c.deleted ? (retainDeletes && !c.fresh) : retainInserts) { // a retain expresses the format *diff* → use `changedFormats` (see ContentString) - d.usedFormats = changedFormats + d.useFormats(changedFormats) usingChangedFormats = true if (c.deleted && c.content.constructor === ContentType) { // @todo use current transaction instead @@ -1141,11 +1157,11 @@ export class YType extends ObservableV2 { d.retain(c.content.getLength(), undefined, attribution ?? clearedOwnAttribution()) } } else if (deep && c.content.constructor === ContentType) { - d.usedFormats = currentFormats + d.useFormats(currentFormats) usingCurrentFormats = true d.insert([/** @type {any} */(c.content).type.toDelta(optsAll)], undefined, attribution) } else { - d.usedFormats = currentFormats + d.useFormats(currentFormats) usingCurrentFormats = true d.insert(c.content.getContent(), undefined, attribution) } @@ -1156,7 +1172,7 @@ export class YType extends ObservableV2 { // @todo use current transaction instead d.modify(/** @type {any} */ (c.content).type.toDelta(optsAll)) } else { - d.usedFormats = changedFormats + d.useFormats(changedFormats) usingChangedFormats = true d.retain(1) } @@ -1350,12 +1366,12 @@ export class YType extends ObservableV2 { if (retainInserts) { // attribution-overlay render (e.g. `toDelta({ renderer, retainInserts: true })`): // existing content is retained, clearing any formerly cached own-attribution - d.usedFormats = changedFormats + d.useFormats(changedFormats) usingChangedFormats = true d.retain(content.getLength(), undefined, clearedOwnAttribution()) } else { // full render: a plain insert of the whole item - d.usedFormats = currentFormats + d.useFormats(currentFormats) usingCurrentFormats = true if (deep && content.constructor === ContentType) { d.insert([/** @type {any} */(content).type.toDelta(optsAll)]) @@ -1382,17 +1398,17 @@ export class YType extends ObservableV2 { // @todo use current transaction instead d.modify(/** @type {ContentType} */ (content).type.toDelta(optsAll)) } else { - d.usedFormats = changedFormats + d.useFormats(changedFormats) usingChangedFormats = true // mirror the piece-wise op sizes of the renderer path (see the delete branch) d.retain(content.constructor === ContentString ? idrange.len : 1) } } else if (retainInserts) { - d.usedFormats = changedFormats + d.useFormats(changedFormats) usingChangedFormats = true d.retain(idrange.len, undefined, clearedOwnAttribution()) } else { - d.usedFormats = currentFormats + d.useFormats(currentFormats) usingCurrentFormats = true if (deep && content.constructor === ContentType) { d.insert([/** @type {any} */(content).type.toDelta(optsAll)]) @@ -1477,8 +1493,13 @@ export class YType extends ObservableV2 { * recognize — and skip — changes they produced themselves; see the lib0 `RDT` spec). Defaults to `null`. * @param {Object} [opts] * @param {AbstractRenderer?} [opts.renderer] - renders the content (with attributions); defaults to this type's active renderer (see {@link YType#useRenderer}), i.e. `null` (render as-is) unless changed - * @return {null} The lib0 `RDT` "fix" of this apply — always `null`: a `YType` accepts every valid - * delta as-is and never needs to self-correct. + * @return {delta.DeltaBuilder?} The lib0 `RDT` "fix" of this apply — a change measured against the + * caller's expected state (`old.apply(d)`) that transforms it into the actual state, or `null` + * when `d` applied cleanly. A fix is produced when `d` (or a nested `modify`/`modifyAttr`) + * addresses a *deleted but rendered* node (e.g. a suggestion-deleted paragraph under a + * DiffRenderer): that part of the change is not applied to the document, and its inverse + * (`lib0/delta` `inverse` against the node's rendered state) is returned — the change is + * immediately reverted. * * @public */ @@ -1486,45 +1507,110 @@ export class YType extends ObservableV2 { if (d.isEmpty()) return null if (this.doc == null) { (this._prelim || (this._prelim = /** @type {any} */ (delta.create()))).apply(d) - } else if (this._item?.deleted !== true) { - // @todo this was moved here from ytext. Make this more generic - transact(this.doc, transaction => { - const currPos = new ItemTextListPosition(null, this._start, 0, new Map(), renderer) - for (const op of d.children) { - if (delta.$textOp.check(op)) { - insertContent(transaction, /** @type {any} */ (this), currPos, new ContentString(op.insert), op.format || {}) - } else if (delta.$insertOp.check(op)) { - insertContentHelper(transaction, this, currPos, op.insert, op.format || {}) - } else if (delta.$retainOp.check(op)) { - currPos.formatText(transaction, /** @type {any} */ (this), op.retain, op.format || {}) - } else if (delta.$deleteOp.check(op)) { - deleteText(transaction, currPos, op.delete) - } else if (delta.$modifyOp.check(op)) { - let item = currPos.right - while (item != null && (item.deleted || !item.countable)) { item = item.next } - if (item == null || item.content.constructor !== ContentType) { error.unexpectedCase() } - /** @type {ContentType} */ (item.content).type.applyDelta(op.value, origin, { renderer }) - currPos.formatText(transaction, /** @type {any} */ (this), 1, op.format || {}) + return null + } + const titem = this._item + if (titem !== null && titem.deleted) { + if (rendererContentLength(renderer, titem) > 0) { + // deleted, but still rendered (e.g. a suggestion-deleted node): apply nothing — revert the + // whole change and return its inverse (against the rendered state the caller addressed) + const inv = delta.inverse(d, /** @type {any} */ (this.toDeltaDeep({ renderer }))) + return inv.isEmpty() ? null : /** @type {any} */ (inv) + } + return null // invisible deleted type: the caller's view shows nothing here — silently drop + } + // @todo this was moved here from ytext. Make this more generic + return transact(this.doc, transaction => { + /** + * The accumulated fix. Its coordinates live in the caller's *expected* space, so they are + * tracked from `d`'s own ops (`expectedIndex`) — not `currPos.index`, which also counts + * content that a delete over attributed-deleted ranges leaves rendered. + * + * @type {delta.DeltaBuilder?} + */ + let fix = null + let fixLen = 0 + let expectedIndex = 0 + /** + * @param {delta.DeltaAny?} childFix + * @param {{ [k:string]: any }} [invFormat] + */ + const appendModifyFix = (childFix, invFormat) => { + const f = fix ?? (fix = /** @type {any} */ (delta.create())) + expectedIndex > fixLen && f.retain(expectedIndex - fixLen) + f.modify(/** @type {any} */ (childFix ?? delta.create().done(false)), invFormat) + fixLen = expectedIndex + 1 + } + const currPos = new ItemTextListPosition(null, this._start, 0, new Map(), renderer) + for (const op of d.children) { + if (delta.$textOp.check(op)) { + insertContent(transaction, /** @type {any} */ (this), currPos, new ContentString(op.insert), op.format || {}) + expectedIndex += op.length + } else if (delta.$insertOp.check(op)) { + insertContentHelper(transaction, this, currPos, op.insert, op.format || {}) + expectedIndex += op.length + } else if (delta.$retainOp.check(op)) { + currPos.formatText(transaction, /** @type {any} */ (this), op.retain, op.format || {}) + expectedIndex += op.length + } else if (delta.$deleteOp.check(op)) { + deleteText(transaction, currPos, op.delete) + } else if (delta.$modifyOp.check(op)) { + let item = currPos.right + while (item !== null && rendererContentLength(renderer, item) === 0) { item = item.right } + if (item == null || item.content.constructor !== ContentType) { error.unexpectedCase() } + if (item.deleted) { + // deleted but rendered: revert instead of apply. Advance the cursor first (populating + // `currentFormats` with any markers up to the node) without applying `op.format`, then + // recurse — the child's deleted-guard applies nothing and returns the inverse. + currPos.formatText(transaction, /** @type {any} */ (this), 1, {}) + /** @type {{ [k:string]: any }|undefined} */ + let invFormat + for (const k in op.format) { + (invFormat ?? (invFormat = {}))[k] = currPos.currentFormats.get(k) ?? null + } + const childFix = /** @type {ContentType} */ (item.content).type.applyDelta(op.value, origin, { renderer }) + if (childFix !== null || invFormat !== undefined) { + appendModifyFix(childFix, invFormat) + } } else { + const childFix = /** @type {ContentType} */ (item.content).type.applyDelta(op.value, origin, { renderer }) + currPos.formatText(transaction, /** @type {any} */ (this), 1, op.format || {}) + if (childFix !== null) { + appendModifyFix(childFix) + } + } + expectedIndex += 1 + } else { + error.unexpectedCase() + } + } + for (const op of d.attrs) { + if (delta.$setAttrOp.check(op)) { + typeMapSet(transaction, /** @type {any} */ (this), /** @type {any} */ (op.key), op.value) + } else if (delta.$deleteAttrOp.check(op)) { + typeMapDelete(transaction, /** @type {any} */ (this), /** @type {any} */ (op.key)) + } else { + // modifyAttr — locate the target renderer-aware: a deleted map value may still be rendered + const mapItem = this._map.get(/** @type {any} */ (op.key)) + const sub = mapItem === undefined + ? undefined + : (mapItem.deleted + ? (mapItem.content.constructor === ContentType && rendererContentLength(renderer, mapItem) > 0 + ? /** @type {ContentType} */ (mapItem.content).type + : undefined) + : mapItem.content.getContent()[mapItem.length - 1]) + if (!(sub instanceof YType)) { error.unexpectedCase() } - } - for (const op of d.attrs) { - if (delta.$setAttrOp.check(op)) { - typeMapSet(transaction, /** @type {any} */ (this), /** @type {any} */ (op.key), op.value) - } else if (delta.$deleteAttrOp.check(op)) { - typeMapDelete(transaction, /** @type {any} */ (this), /** @type {any} */ (op.key)) - } else { - const sub = typeMapGet(/** @type {any} */ (this), /** @type {any} */ (op.key)) - if (!(sub instanceof YType)) { - error.unexpectedCase() - } - sub.applyDelta(op.value, origin, { renderer }) + const subFix = sub.applyDelta(op.value, origin, { renderer }) + if (subFix !== null) { + const f = fix ?? (fix = /** @type {any} */ (delta.create())) + f.modifyAttr(/** @type {any} */ (op.key), /** @type {any} */ (subFix)) } } - }, origin) - } - return null + } + return fix !== null && !(/** @type {delta.DeltaBuilder} */ (fix).done(false).isEmpty()) ? fix : null + }, origin) } /** @@ -2339,7 +2425,6 @@ export const typeMapGetAll = (parent) => { * @param {AbstractRenderer?} renderer * @param {boolean} deep * @param {Set|Map|null} [modified] - set of types that should be rendered as modified children - * @param {IdSet?} [deletedItems] * @param {IdSet?} [itemsToRender] * @param {any} [opts] * @param {any} [optsAll] @@ -2347,7 +2432,7 @@ export const typeMapGetAll = (parent) => { * @private * @function */ -export const typeMapGetDelta = (d, parent, attrsToRender, renderer, deep, modified, deletedItems, itemsToRender, opts, optsAll) => { +export const typeMapGetDelta = (d, parent, attrsToRender, renderer, deep, modified, itemsToRender, opts, optsAll) => { // @todo support modified ops! /** * @param {Item} item @@ -2380,7 +2465,16 @@ export const typeMapGetDelta = (d, parent, attrsToRender, renderer, deep, modifi // emit a positive `SetAttrOp` carrying the attribution metadata - matching how content // children are rendered for the same case (positive `InsertOp` with attribution, never // `DeleteOp`). - if (itemsToRender == null || itemsToRender.hasId(item.lastId)) { + // also re-emit when the change happened *inside* the still-rendered value (`modified` + // contains the value type but the attr's own map item is not part of the change) — the + // deleted value has no modifyAttr path, and a full-state `setAttr` replace is idempotent + if (itemsToRender == null || itemsToRender.hasId(item.lastId) || (c instanceof YType && modified != null && modified.has(c))) { + if (deep && c instanceof YType) { + // full-state value render: a positive `setAttr` *replaces* the attr value on the + // consuming side, so the nested type must render as its full attributed state + // (change-scoped opts like `itemsToRender` would render bare retains here) + c = /** @type {any} */(c).toDelta({ renderer, deep: true }) + } d.setAttr(key, c, attribution) } } else if (itemsToRender != null && itemsToRender.hasId(item.lastId)) { @@ -2388,22 +2482,15 @@ export const typeMapGetDelta = (d, parent, attrsToRender, renderer, deep, modifi // `YEvent` delta, RDT bindings, the maintained `delta` cache) can apply the removal. In // full-state mode (`itemsToRender == null`) the attribute is simply omitted (above renders // run with `render === false` for such items, so nothing was emitted before either). - d.deleteAttr(key, attribution, c) + d.deleteAttr(key, attribution) } } else if (deep && c instanceof YType && modified?.has(c)) { d.modifyAttr(key, c.toDelta(opts)) } else { - // find prev content - let prevContentItem = item - // this algorithm is problematic. should check all previous content using renderer.readcontent - for (; prevContentItem.left !== null && deletedItems?.hasId(prevContentItem.left.lastId); prevContentItem = prevContentItem.left) { - // nop - } - const prevValue = (prevContentItem !== item && itemsToRender?.hasId(prevContentItem.lastId)) ? array.last(prevContentItem.content.getContent()) : undefined if (deep && c instanceof YType) { c = /** @type {any} */(c).toDelta(optsAll) } - d.setAttr(key, c, attribution, prevValue) + d.setAttr(key, c, attribution) } } if (attrsToRender == null) { diff --git a/tests/attribution.tests.js b/tests/attribution.tests.js index 079fa485..e3a64d8a 100644 --- a/tests/attribution.tests.js +++ b/tests/attribution.tests.js @@ -914,6 +914,361 @@ export const testRdtDeltaFreshRangeAfterItemMerge = () => { t.assert(cached.equals(fresh), 'maintained .delta must equal a fresh deep render') } +/** + * The lib0 `RDT` fix contract for a *fully reverted* apply: nothing landed on the doc, the + * maintained cache stays consistent, and `before.apply(d).apply(fix)` round-trips back to the + * actual (unchanged) rendered state — the fix is the inverse of the unapplied change. + * + * @param {any} ytype the type whose maintained `.delta` cache to check (must be materialized) + * @param {any} before deep render of `ytype` captured before the apply + * @param {any} d the change that was (not) applied + * @param {any} fix the fix `applyDelta` returned + */ +const assertRevertedApply = (ytype, before, d, fix) => { + const fresh = ytype.toDelta({ deep: true }) + t.assert(delta.diff(before, fresh).isEmpty(), 'nothing was applied to the doc') + t.assert(ytype.delta.equals(fresh), 'maintained .delta must equal a fresh deep render') + const roundTrip = delta.cloneDeep(before) + roundTrip.apply(delta.cloneDeep(d), { final: true, move: true }) + if (fix !== null) { + roundTrip.apply(delta.cloneDeep(fix), { final: true, move: true }) + } + t.assert(delta.diff(roundTrip, fresh).isEmpty(), 'the fix round-trips the expected state back to the actual state') +} + +/** + * Modifying a suggestion-deleted (rendered) node must not apply anything — `applyDelta` returns + * the reverted operation (the inverse of the nested change) as the RDT fix, emits no 'delta' + * event (nothing changed), and leaves doc + cache untouched. + */ +export const testRdtApplyDeltaModifyIntoTombstoneReturnsInverse = () => { + for (const [baseClientID, sdocClientID] of [[1, 2], [2, 1]]) { + const { ytype } = createSuggestionPair(baseClientID, sdocClientID) + t.assert(ytype.delta != null) // materialize the maintained cache + ytype.applyDelta(delta.create().delete(1).done()) + const before = ytype.toDelta({ deep: true }) + let fired = 0 + ytype.on('delta', () => { fired++ }) + const d = delta.create().modify(delta.create().retain(2).insert('XY')).done() + const fix = ytype.applyDelta(d) + t.assert(fix !== null, 'the reverted operation is returned') + t.compare(/** @type {any} */ (fix).toJSON(), delta.create().modify(delta.create().retain(2).delete(2)).done().toJSON()) + t.assert(fired === 0, 'a fully reverted apply emits no delta event') + t.assert(!JSON.stringify(ytype.toDelta({ deep: true }).toJSON()).includes('XY'), 'the insert was not applied') + assertRevertedApply(ytype, before, d, fix) + } +} + +/** + * Deleting content inside a tombstone: the fix re-inserts the deleted range from the rendered + * base state, restoring its stored attribution (the caller's view shows the content + * delete-attributed — the revert must bring exactly that back). + */ +export const testRdtApplyDeltaDeleteInsideTombstoneInverse = () => { + for (const [baseClientID, sdocClientID] of [[1, 2], [2, 1]]) { + const { ytype } = createSuggestionPair(baseClientID, sdocClientID) + t.assert(ytype.delta != null) + ytype.applyDelta(delta.create().delete(1).done()) + const before = ytype.toDelta({ deep: true }) + const d = delta.create().modify(delta.create().retain(2).delete(4)).done() + const fix = ytype.applyDelta(d) + t.assert(fix !== null) + const fixJson = JSON.stringify(/** @type {any} */ (fix).toJSON()) + t.assert(fixJson.includes('llo '), 'the fix re-inserts the deleted range') + t.assert(fixJson.includes('"delete"'), 'the re-insert restores the stored delete attribution') + assertRevertedApply(ytype, before, d, fix) + } +} + +/** + * Formatting content inside a tombstone: not applied (no markers created); the fix clears the + * format keys back to the base values (`{ bold: null }` for a previously unformatted range). + */ +export const testRdtApplyDeltaFormatInsideTombstoneInverse = () => { + for (const [baseClientID, sdocClientID] of [[1, 2], [2, 1]]) { + const { ytype } = createSuggestionPair(baseClientID, sdocClientID) + t.assert(ytype.delta != null) + ytype.applyDelta(delta.create().delete(1).done()) + const before = ytype.toDelta({ deep: true }) + const d = delta.create().modify(delta.create().retain(1).retain(4, { bold: {} })).done() + const fix = ytype.applyDelta(d) + t.assert(fix !== null) + t.compare(/** @type {any} */ (fix).toJSON(), delta.create().modify(delta.create().retain(1).retain(4, { bold: null })).done().toJSON()) + assertRevertedApply(ytype, before, d, fix) + } +} + +/** + * A `modify` carrying node formats on a tombstone: `op.format` is not applied either, and the fix + * restores the *previous* format value — read from the cursor's format context AFTER stepping to + * the node, so an alive format marker between the walk's start and the node is accounted for. + */ +export const testRdtApplyDeltaNodeFormatOnTombstoneInverse = () => { + for (const [baseClientID, sdocClientID] of [[1, 2], [2, 1]]) { + const doc = new Y.Doc({ gc: false }) + doc.clientID = baseClientID + const sdoc = new Y.Doc({ isSuggestionDoc: true, gc: false }) + sdoc.clientID = sdocClientID + const renderer = Y.createDiffRenderer(doc, sdoc, { attrs: new Y.Attributions() }) + doc.get('prosemirror').applyDelta( + delta.create().insert([delta.create('paragraph', {}, 'aa'), delta.create('paragraph', {}, 'bb')]).done() + ) + // base-doc node format over both paragraphs: an alive `align` marker sits before the first one + doc.get('prosemirror').applyDelta(delta.create().retain(2, { align: 'x' }).done()) + const ytype = sdoc.get('prosemirror') + ytype.useRenderer(renderer) + t.assert(ytype.delta != null) + // suggestion-delete the second paragraph + ytype.applyDelta(delta.create().retain(1).delete(1).done()) + const before = ytype.toDelta({ deep: true }) + const d = delta.create().retain(1).modify(delta.create(), { align: 'y' }).done() + const fix = ytype.applyDelta(d) + t.assert(fix !== null) + t.compare(/** @type {any} */ (fix).toJSON(), delta.create().retain(1).modify(delta.create(), { align: 'x' }).done().toJSON()) + assertRevertedApply(ytype, before, d, fix) + } +} + +/** + * Applied and reverted ops coexist in one delta: ops on live content land (one 'delta' event), + * only the tombstone-targeting modify reverts — and the fix's retain pad is measured in the + * caller's *expected* space (including `d`'s own earlier insert). + */ +export const testRdtApplyDeltaMixedFixCoordinates = () => { + for (const [baseClientID, sdocClientID] of [[1, 2], [2, 1]]) { + const doc = new Y.Doc({ gc: false }) + doc.clientID = baseClientID + const sdoc = new Y.Doc({ isSuggestionDoc: true, gc: false }) + sdoc.clientID = sdocClientID + const renderer = Y.createDiffRenderer(doc, sdoc, { attrs: new Y.Attributions() }) + doc.get('prosemirror').applyDelta( + delta.create().insert([ + delta.create('paragraph', {}, 'aa'), delta.create('paragraph', {}, 'hello world'), delta.create('paragraph', {}, 'cc') + ]).done() + ) + const ytype = sdoc.get('prosemirror') + ytype.useRenderer(renderer) + t.assert(ytype.delta != null) + // suggestion-delete the middle paragraph + ytype.applyDelta(delta.create().retain(1).delete(1).done()) + let fired = 0 + ytype.on('delta', () => { fired++ }) + const innerXY = /** @type {any} */ (delta.create().retain(2).insert('XY')) + const innerZZ = /** @type {any} */ (delta.create().retain(2).insert('ZZ')) + const pNew = /** @type {any} */ (delta.create('paragraph', {}, 'nn')) + const d = /** @type {any} */ (delta.create()).insert([pNew]).retain(1).modify(innerXY).modify(innerZZ).done() + const fix = ytype.applyDelta(d) + t.assert(fired === 1, 'the applied part of the change emits exactly one delta event') + t.assert(fix !== null) + t.compare(/** @type {any} */ (fix).toJSON(), delta.create().retain(2).modify(delta.create().retain(2).delete(2)).done().toJSON()) + const fresh = JSON.stringify(ytype.toDelta({ deep: true }).toJSON()) + t.assert(fresh.includes('nn') && fresh.includes('ZZ'), 'ops on live content were applied') + t.assert(!fresh.includes('XY'), 'the tombstone-targeting modify was not applied') + t.assert(ytype.delta.equals(ytype.toDelta({ deep: true })), 'maintained .delta must equal a fresh deep render') + } +} + +/** + * `modifyAttr` addressing a suggestion-deleted (still rendered) map value: the renderer-aware + * lookup finds the tombstone, nothing is applied, and the fix wraps the inverse in a + * `modifyAttr` — previously this hit `unexpectedCase` (`typeMapGet` is blind to deleted items). + */ +export const testRdtApplyDeltaModifyAttrOnDeletedMapValue = () => { + for (const [baseClientID, sdocClientID] of [[1, 2], [2, 1]]) { + const doc = new Y.Doc({ gc: false }) + doc.clientID = baseClientID + const sdoc = new Y.Doc({ isSuggestionDoc: true, gc: false }) + sdoc.clientID = sdocClientID + const renderer = Y.createDiffRenderer(doc, sdoc, { attrs: new Y.Attributions() }) + const title = doc.get('m').setAttr('title', new Y.Type()) + title.insert(0, 'hi') + const m = sdoc.get('m') + m.useRenderer(renderer) + t.assert(m.delta != null) + // suggestion-delete the attribute (stays rendered, delete-attributed) + m.applyDelta(delta.create().deleteAttr('title').done()) + const before = m.toDelta({ deep: true }) + const d = delta.create().modifyAttr('title', delta.create().insert('X')).done() + const fix = m.applyDelta(d) + t.assert(fix !== null, 'no throw — the reverted operation is returned') + t.compare(/** @type {any} */ (fix).toJSON(), delta.create().modifyAttr('title', delta.create().delete(1)).done().toJSON()) + assertRevertedApply(m, before, d, fix) + } +} + +/** + * A plainly deleted (invisible — no renderer claims it) type keeps today's semantics: the apply + * is silently dropped and `applyDelta` returns `null` (the caller's view shows nothing there, so + * there is nothing to revert). + */ +export const testRdtApplyDeltaInvisibleDeletedSilentNull = () => { + const doc = new Y.Doc({ gc: false }) + doc.clientID = 1 + const root = doc.get('prosemirror') + root.applyDelta(delta.create().insert([delta.create('paragraph', {}, 'hello')]).done()) + const par = /** @type {Y.Type} */ (root.get(0)) + root.applyDelta(delta.create().delete(1).done()) + const res = par.applyDelta(delta.create().retain(2).insert('XY').done()) + t.assert(res === null, 'invisible deleted type: silent drop, no fix') + t.assert(root.toDelta({ deep: true }).isEmpty(), 'nothing was applied') +} + +/** + * `applyDelta` called directly on a deleted-but-rendered type (the top-level guard): nothing is + * applied and the inverse against the rendered state is returned. Without a renderer the same + * call stays a silent `null` drop. + */ +export const testRdtApplyDeltaDirectGuardOnDeletedType = () => { + for (const [baseClientID, sdocClientID] of [[1, 2], [2, 1]]) { + const { ytype, renderer } = createSuggestionPair(baseClientID, sdocClientID) + t.assert(ytype.delta != null) + const par = /** @type {Y.Type} */ (ytype.get(0)) + ytype.applyDelta(delta.create().delete(1).done()) + const rootBefore = ytype.toDelta({ deep: true }) + const d = /** @type {any} */ (delta.create().retain(2).insert('XY').done()) + // children do not inherit the root's renderer — without one the node is invisible + t.assert(par.applyDelta(d) === null, 'no renderer: silent drop') + const fix = par.applyDelta(d, null, { renderer }) + t.assert(fix !== null) + t.compare(/** @type {any} */ (fix).toJSON(), delta.create().retain(2).delete(2).done().toJSON()) + t.assert(delta.diff(rootBefore, ytype.toDelta({ deep: true })).isEmpty(), 'nothing was applied to the doc') + t.assert(ytype.delta.equals(ytype.toDelta({ deep: true })), 'maintained .delta must equal a fresh deep render') + // fix round-trip at the node level, against its rendered state + const parBefore = /** @type {any} */ (par.toDelta({ deep: true, renderer })) + const roundTrip = /** @type {any} */ (delta.cloneDeep(parBefore)) + roundTrip.apply(delta.cloneDeep(d), { final: true, move: true }) + roundTrip.apply(delta.cloneDeep(/** @type {any} */ (fix)), { final: true, move: true }) + t.assert(delta.diff(roundTrip, par.toDelta({ deep: true, renderer })).isEmpty(), 'the fix round-trips at the node level') + } +} + +/** + * A tombstone grandchild behind an alive child: the alive child's applyDelta bubbles the nested + * fix, and the parent wraps it positionally — `modify(modify(inverse))`. + */ +export const testRdtApplyDeltaNestedTombstoneFixBubbles = () => { + for (const [baseClientID, sdocClientID] of [[1, 2], [2, 1]]) { + const doc = new Y.Doc({ gc: false }) + doc.clientID = baseClientID + const sdoc = new Y.Doc({ isSuggestionDoc: true, gc: false }) + sdoc.clientID = sdocClientID + const renderer = Y.createDiffRenderer(doc, sdoc, { attrs: new Y.Attributions() }) + doc.get('prosemirror').applyDelta( + delta.create().insert([delta.create('paragraph', {}, [delta.create('nested', {}, 'ww')])]).done() + ) + const ytype = sdoc.get('prosemirror') + ytype.useRenderer(renderer) + t.assert(ytype.delta != null) + // suggestion-delete only the nested node inside the (alive) paragraph + const par = /** @type {Y.Type} */ (ytype.get(0)) + par.applyDelta(delta.create().delete(1).done(), null, { renderer }) + const before = ytype.toDelta({ deep: true }) + const innerX = /** @type {any} */ (delta.create().insert('X')) + const midModify = /** @type {any} */ (delta.create().modify(innerX)) + const d = delta.create().modify(midModify).done() + const fix = ytype.applyDelta(d) + t.assert(fix !== null) + const innerDel = /** @type {any} */ (delta.create().delete(1)) + const midModifyDel = /** @type {any} */ (delta.create().modify(innerDel)) + t.compare(/** @type {any} */ (fix).toJSON(), delta.create().modify(midModifyDel).done().toJSON()) + assertRevertedApply(ytype, before, d, fix) + } +} + +/** + * A plain `delete` op spanning a tombstone keeps its existing semantics (suggestion-deletes the + * alive content, records the attributed range) and returns no fix — the known, downstream-healed + * gap; pinned here so a change of behavior is a conscious one. + */ +export const testRdtApplyDeltaPureDeleteOverTombstoneNoFix = () => { + for (const [baseClientID, sdocClientID] of [[1, 2], [2, 1]]) { + const doc = new Y.Doc({ gc: false }) + doc.clientID = baseClientID + const sdoc = new Y.Doc({ isSuggestionDoc: true, gc: false }) + sdoc.clientID = sdocClientID + const renderer = Y.createDiffRenderer(doc, sdoc, { attrs: new Y.Attributions() }) + doc.get('prosemirror').applyDelta( + delta.create().insert([ + delta.create('paragraph', {}, 'aa'), delta.create('paragraph', {}, 'hello world'), delta.create('paragraph', {}, 'cc') + ]).done() + ) + const ytype = sdoc.get('prosemirror') + ytype.useRenderer(renderer) + t.assert(ytype.delta != null) + ytype.applyDelta(delta.create().retain(1).delete(1).done()) + const res = ytype.applyDelta(delta.create().delete(3).done()) + t.assert(res === null, 'a plain delete over a tombstone range returns no fix') + const fresh = ytype.toDelta({ deep: true }) + t.assert(ytype.delta.equals(fresh), 'maintained .delta must equal a fresh deep render') + } +} + +/** + * A `delete` op ending mid-way through a struck (attributed-deleted) chunk must split the item at + * the consumption boundary — otherwise the cursor advances past the whole chunk and every + * following op of the same delta targets too far right (the modify walk then reverts the WRONG + * node, returning a fix that carries another node's content). + */ +export const testRdtApplyDeltaDeleteMidStruckChunkKeepsCursorSync = () => { + for (const [baseClientID, sdocClientID] of [[1, 2], [2, 1]]) { + const doc = new Y.Doc({ gc: false }) + doc.clientID = baseClientID + const sdoc = new Y.Doc({ isSuggestionDoc: true, gc: false }) + sdoc.clientID = sdocClientID + const renderer = Y.createDiffRenderer(doc, sdoc, { attrs: new Y.Attributions() }) + doc.get('t').applyDelta(delta.create().insert('abc').done()) + doc.get('t').applyDelta(delta.create().retain(3).insert([delta.create('nA', {}, 'kk'), delta.create('nB', {}, 'qqq')]).done()) + const ytype = sdoc.get('t') + ytype.useRenderer(renderer) + t.assert(ytype.delta != null) + // strike 'bc' (one 2-unit chunk) and both nodes + ytype.applyDelta(delta.create().retain(1).delete(2).done()) + ytype.applyDelta(delta.create().retain(3).delete(2).done()) + const before = ytype.toDelta({ deep: true }) + // delete struck 'b' (ends MID-chunk), retain struck 'c', revert-modify tombstone nA + const inner = /** @type {any} */ (delta.create().delete(2)) + const d = delta.create().retain(1).delete(1).retain(1).modify(inner).done() + const fix = ytype.applyDelta(d) + t.assert(fix !== null) + const fixJson = JSON.stringify(/** @type {any} */ (fix).toJSON()) + t.assert(fixJson.includes('kk') && !fixJson.includes('qq'), "the fix restores nA's content, not nB's") + const fresh = ytype.toDelta({ deep: true }) + t.assert(delta.diff(before, fresh).isEmpty(), 'nothing was applied (struck delete is meta-only, modify reverted)') + t.assert(ytype.delta.equals(fresh), 'maintained .delta must equal a fresh deep render') + } +} + +/** + * A change *inside* a suggestion-deleted (still rendered) attr value must re-emit: the deleted + * value has no modifyAttr path, so the change render re-emits the full-state `setAttr` (an + * idempotent replace) whenever the value type is in `modified` — else the maintained cache and + * every RDT consumer go permanently stale. + */ +export const testRdtDeltaThroughDeletedAttrValue = () => { + for (const [baseClientID, sdocClientID] of [[1, 2], [2, 1]]) { + const doc = new Y.Doc({ gc: false }) + doc.clientID = baseClientID + const sdoc = new Y.Doc({ isSuggestionDoc: true, gc: false }) + sdoc.clientID = sdocClientID + const renderer = Y.createDiffRenderer(doc, sdoc, { attrs: new Y.Attributions() }) + const title = doc.get('m').setAttr('title', new Y.Type()) + title.insert(0, 'hi') + const m = sdoc.get('m') + m.useRenderer(renderer) + t.assert(m.delta != null) + m.applyDelta(delta.create().deleteAttr('title').done()) + t.assert(m.delta.equals(m.toDelta({ deep: true })), 'cache consistent after the suggestion deleteAttr') + let fired = 0 + m.on('delta', () => { fired++ }) + // base-doc edit INSIDE the tombstone attr value + doc.get('m').getAttr('title').insert(2, 'XY') + t.assert(fired === 1, "'delta' fires for a change inside the tombstone attr value") + const fresh = m.toDelta({ deep: true }) + t.assert(JSON.stringify(fresh.toJSON()).includes('XY'), 'fresh render shows the edit') + t.assert(m.delta.equals(fresh), 'maintained .delta must equal a fresh deep render') + } +} + /** * A remote base-doc format inside a suggestion-deleted paragraph: full contract — the root's * 'delta' fires and the maintained cache equals a fresh deep render (fresh-deleted format markers @@ -1034,3 +1389,95 @@ export const testRdtDeletedSubtreeUndoScope = () => { Y.applyUpdate(docQ, Y.encodeStateAsUpdate(docP), 'remote-origin') t.assert(um.undoStack.length === 1, 'untracked-origin remote change is not captured') } + +/** + * Currently-failing repro: an *accepting* (suggestionMode=false) child-node insert with no + * suggested content anywhere drifts the maintained `.delta` cache. The inserted node reaches the + * base doc (asserted below — it is committed content, NOT a suggestion, so the + * "inserted-adjacent-to-suggested-is-suggested" rule does not apply), and a fresh deep render + * correctly shows no attribution — but the cache keeps the change render's transient + * `{insert: []}` attribution. Suggestion-mode inserts and accepting inserts adjacent to suggested + * content are consistent (both sides attributed); only this committed-insert case drifts. + * Delete-tail + insert-node is the CRDT shape of a ProseMirror block split, so editor workloads + * hit this constantly. + */ +export const testRdtAcceptingNodeInsertCacheDrift = () => { + const doc = new Y.Doc({ gc: false }) + const suggestionDoc = new Y.Doc({ isSuggestionDoc: true, gc: false }) + const renderer = Y.createDiffRenderer(doc, suggestionDoc, { attrs: new Y.Attributions() }) + renderer.suggestionMode = false + doc.get('prosemirror').applyDelta( + delta.create().insert([delta.create('paragraph', {}, 'base para')]).done() + ) + const ytype = suggestionDoc.get('prosemirror') + ytype.useRenderer(renderer) + t.assert(ytype.delta != null) // materialize the maintained cache + ytype.applyDelta(delta.create().retain(1).insert([delta.create('paragraph', {}, 'plain')]).done()) + // the insert is committed content: it reached the base doc + t.assert(JSON.stringify(doc.get('prosemirror').toDeltaDeep().toJSON()).includes('plain'), 'the insert committed to base') + const cached = ytype.delta + const fresh = ytype.toDelta({ deep: true }) + t.assert(!JSON.stringify(fresh.toJSON()).includes('"attribution"'), 'fresh render shows committed (unattributed) content') + if (!cached.equals(fresh)) { + console.error('cached:', JSON.stringify(cached.toJSON())) + console.error('fresh :', JSON.stringify(fresh.toJSON())) + } + t.assert(cached.equals(fresh), 'maintained .delta must equal a fresh deep render') + // same class, second entry point: ACCEPTING a suggested node-insert. The suggested insert + // itself is consistent (both sides attributed), but the accept's de-attribution correction + // does not descend into the nested node's content — the cache keeps `{insert: []}`. + { + const doc2 = new Y.Doc({ gc: false }) + const suggestionDoc2 = new Y.Doc({ isSuggestionDoc: true, gc: false }) + const renderer2 = Y.createDiffRenderer(doc2, suggestionDoc2, { attrs: new Y.Attributions() }) + doc2.get('prosemirror').applyDelta(delta.create().insert([delta.create('paragraph', {}, 'base para')]).done()) + const ytype2 = suggestionDoc2.get('prosemirror') + ytype2.useRenderer(renderer2) + t.assert(ytype2.delta != null) // materialize the maintained cache + renderer2.suggestionMode = true + ytype2.applyDelta(delta.create().retain(1).insert([delta.create('paragraph', {}, 'sugg')]).done()) + t.assert(ytype2.delta.equals(ytype2.toDelta({ deep: true })), 'suggested insert itself is consistent') + renderer2.acceptAllChanges() + t.assert(ytype2.delta.equals(ytype2.toDelta({ deep: true })), 'maintained .delta must equal a fresh render after accepting the node insert') + } +} + +/** + * Currently-failing repro — the consumer-visible framing of the cache drift above: inserting a + * node through an *accepting* renderer (`suggestionMode = false`) must not leave it presented as + * a suggestion. The write currently emits two `'delta'` events: first the insert render, fully + * attributed (`{insert: []}` on the inserted node AND its nested content), then a de-attribution + * correction `retain(1).retain(1, {attribution: null})` once the content commits to base — but + * the correction only clears the attribution on the node itself and never descends into the + * node's children. Composing the event stream (which is exactly how the maintained `.delta` + * cache is built) therefore leaves the nested text attributed as a suggested insert forever, + * while ground truth (a fresh deep render) shows committed, unattributed content. The test does + * not prescribe the fix: it passes if the insert render arrives unattributed OR if the + * correction descends — it only requires the settled event stream to converge to the truth. + */ +export const testRdtAcceptingNodeInsertRenderedAsSuggestion = () => { + const doc = new Y.Doc({ gc: false }) + const suggestionDoc = new Y.Doc({ isSuggestionDoc: true, gc: false }) + const renderer = Y.createDiffRenderer(doc, suggestionDoc, { attrs: new Y.Attributions() }) + renderer.suggestionMode = false + doc.get('prosemirror').applyDelta( + delta.create().insert([delta.create('paragraph', {}, 'base para')]).done() + ) + const ytype = suggestionDoc.get('prosemirror') + ytype.useRenderer(renderer) + // composed = pre-write state + every emitted change: what any consumer of the `'delta'` + // channel (a remote binding, the maintained cache) believes the document looks like + const composed = delta.cloneDeep(/** @type {any} */ (ytype.toDelta({ deep: true }))) + ytype.on('delta', d => { + composed.apply(/** @type {any} */ (delta.cloneDeep(/** @type {any} */ (d))), { final: true, move: true }) + }) + ytype.applyDelta(delta.create().retain(1).insert([delta.create('paragraph', {}, 'plain')]).done()) + const fresh = ytype.toDelta({ deep: true }) + t.assert(!JSON.stringify(fresh.toJSON()).includes('"attribution"'), 'ground truth: the insert committed to base, nothing is suggested') + if (!composed.equals(fresh)) { + console.error('composed:', JSON.stringify(composed.toJSON())) + console.error('fresh :', JSON.stringify(fresh.toJSON())) + } + t.assert(!JSON.stringify(composed.toJSON()).includes('"attribution"'), 'the settled event stream must not present the committed insert as a suggestion') + t.assert(composed.equals(fresh), 'composing the emitted changes converges to a fresh render') +} diff --git a/tests/y-map.tests.js b/tests/y-map.tests.js index d180c3f1..d084d45e 100644 --- a/tests/y-map.tests.js +++ b/tests/y-map.tests.js @@ -431,22 +431,22 @@ export const testChangeEvent = tc => { }) map0.setAttr('a', 1) let keyChange = changes.attrs.a - t.assert(delta.$setAttrOpWith(s.$number).check(keyChange) && keyChange.prevValue === undefined) + t.assert(delta.$setAttrOpWith(s.$number).check(keyChange) && keyChange.value === 1) map0.setAttr('a', 2) keyChange = changes.attrs.a - t.assert(delta.$setAttrOpWith(s.$number).check(keyChange) && keyChange.prevValue === 1) + t.assert(delta.$setAttrOpWith(s.$number).check(keyChange) && keyChange.value === 2) users[0].transact(() => { map0.setAttr('a', 3) map0.setAttr('a', 4) }) keyChange = changes.attrs.a - t.assert(delta.$setAttrOpWith(s.$number).check(keyChange) && keyChange.prevValue === 2) + t.assert(delta.$setAttrOpWith(s.$number).check(keyChange) && keyChange.value === 4) users[0].transact(() => { map0.setAttr('b', 1) map0.setAttr('b', 2) }) keyChange = changes.attrs.b - t.assert(delta.$setAttrOpWith(s.$number).check(keyChange) && keyChange.prevValue === undefined) + t.assert(delta.$setAttrOpWith(s.$number).check(keyChange) && keyChange.value === 2) users[0].transact(() => { map0.setAttr('c', 1) map0.deleteAttr('c') @@ -457,7 +457,7 @@ export const testChangeEvent = tc => { map0.setAttr('d', 2) }) keyChange = changes.attrs.d - t.assert(delta.$setAttrOpWith(s.$number).check(keyChange) && keyChange.prevValue === undefined) + t.assert(delta.$setAttrOpWith(s.$number).check(keyChange) && keyChange.value === 2) compare(users) }