diff --git a/src/types/YText.js b/src/types/YText.js index 22c8d6bd..539760f2 100644 --- a/src/types/YText.js +++ b/src/types/YText.js @@ -215,10 +215,10 @@ const insertAttributes = (transaction, parent, left, right, currentAttributes, a // insert format-start items for (let key in attributes) { const val = attributes[key] - const currentVal = currentAttributes.get(key) + const currentVal = currentAttributes.get(key) || null if (currentVal !== val) { // save negated attribute (set null if currentVal undefined) - negatedAttributes.set(key, currentVal || null) + negatedAttributes.set(key, currentVal) left = new ItemFormat(nextID(transaction), left, left === null ? null : left.lastId, right, right === null ? null : right.id, parent, null, key, val) left.integrate(transaction) } diff --git a/src/utils/Transaction.js b/src/utils/Transaction.js index 8061618d..8189142e 100644 --- a/src/utils/Transaction.js +++ b/src/utils/Transaction.js @@ -129,121 +129,128 @@ export const nextID = transaction => { * @function */ export const transact = (y, f) => { + const transactionCleanups = y._transactionCleanups let initialCall = false if (y._transaction === null) { initialCall = true y._transaction = new Transaction(y) + transactionCleanups.push(y._transaction) y.emit('beforeTransaction', [y._transaction, y]) } - const transaction = y._transaction try { - f(transaction) + f(y._transaction) } finally { - if (initialCall) { - y._transaction = null - y.emit('beforeObserverCalls', [transaction, y]) - // emit change events on changed types - transaction.changed.forEach((subs, itemtype) => { - itemtype._callObserver(transaction, subs) - }) - transaction.changedParentTypes.forEach((events, type) => { - events = events - .filter(event => - event.target._item === null || !event.target._item.deleted - ) - events - .forEach(event => { - event.currentTarget = type - }) - // we don't need to check for events.length - // because we know it has at least one element - callEventHandlerListeners(type._dEH, events, transaction) - }) - // only call afterTransaction listeners if anything changed - transaction.afterState = getStates(transaction.y.store) - // when all changes & events are processed, emit afterTransaction event - // transaction cleanup - const store = transaction.y.store - const ds = transaction.deleteSet - sortAndMergeDeleteSet(ds) - y.emit('afterTransaction', [transaction, y]) - // replace deleted items with ItemDeleted / GC - for (const [client, deleteItems] of ds.clients) { - /** - * @type {Array} - */ - // @ts-ignore - const structs = store.clients.get(client) - for (let di = 0; di < deleteItems.length; di++) { - const deleteItem = deleteItems[di] - for (let si = findIndexSS(structs, deleteItem.clock); si < structs.length; si++) { - const struct = structs[si] - if (deleteItem.clock + deleteItem.len <= struct.id.clock) { - break - } - if (struct.deleted && struct instanceof AbstractItem) { - if (struct.constructor !== ItemDeleted || (struct.parent._item !== null && struct.parent._item.deleted)) { - // check if we can GC - struct.gc(transaction, store) - } else { - // otherwise only gc children (if there are any) - struct.gcChildren(transaction, store) - } - } - } - } - } - /** - * @param {Array} structs - * @param {number} pos - */ - const tryToMergeWithLeft = (structs, pos) => { - const left = structs[pos - 1] - const right = structs[pos] - if (left.deleted === right.deleted && left.constructor === right.constructor) { - if (left.mergeWith(right)) { - structs.splice(pos, 1) - if (right instanceof AbstractItem && right.parentSub !== null && right.parent._map.get(right.parentSub) === right) { - // @ts-ignore we already did a constructor check above - right.parent._map.set(right.parentSub, left) - } - } - } - } - // on all affected store.clients props, try to merge - for (const [client, clock] of transaction.afterState) { - const beforeClock = transaction.beforeState.get(client) || 0 - if (beforeClock !== clock) { + if (initialCall && transactionCleanups[0] === y._transaction) { + // The first transaction ended, now process observer calls. + // Observer call may create new transactions for which we need to call the observers and do cleanup. + // We don't want to nest these calls, so we execute these calls one after another + for (let i = 0; i < transactionCleanups.length; i++) { + const transaction = transactionCleanups[i] + const store = transaction.y.store + const ds = transaction.deleteSet + sortAndMergeDeleteSet(ds) + transaction.afterState = getStates(transaction.y.store) + y._transaction = null + y.emit('beforeObserverCalls', [transaction, y]) + // emit change events on changed types + transaction.changed.forEach((subs, itemtype) => { + itemtype._callObserver(transaction, subs) + }) + transaction.changedParentTypes.forEach((events, type) => { + events = events + .filter(event => + event.target._item === null || !event.target._item.deleted + ) + events + .forEach(event => { + event.currentTarget = type + }) + // we don't need to check for events.length + // because we know it has at least one element + callEventHandlerListeners(type._dEH, events, transaction) + }) + y.emit('afterTransaction', [transaction, y]) + // replace deleted items with ItemDeleted / GC + for (const [client, deleteItems] of ds.clients) { /** * @type {Array} */ // @ts-ignore const structs = store.clients.get(client) - // we iterate from right to left so we can safely remove entries - const firstChangePos = math.max(findIndexSS(structs, beforeClock), 1) - for (let i = structs.length - 1; i >= firstChangePos; i--) { - tryToMergeWithLeft(structs, i) + for (let di = 0; di < deleteItems.length; di++) { + const deleteItem = deleteItems[di] + for (let si = findIndexSS(structs, deleteItem.clock); si < structs.length; si++) { + const struct = structs[si] + if (deleteItem.clock + deleteItem.len <= struct.id.clock) { + break + } + if (struct.deleted && struct instanceof AbstractItem) { + if (struct.constructor !== ItemDeleted || (struct.parent._item !== null && struct.parent._item.deleted)) { + // check if we can GC + struct.gc(transaction, store) + } else { + // otherwise only gc children (if there are any) + struct.gcChildren(transaction, store) + } + } + } } } - } - // try to merge mergeStructs - for (const mid of transaction._mergeStructs) { - const client = mid.client - const clock = mid.clock /** - * @type {Array} + * @param {Array} structs + * @param {number} pos */ - // @ts-ignore - const structs = store.clients.get(client) - const replacedStructPos = findIndexSS(structs, clock) - if (replacedStructPos + 1 < structs.length) { - tryToMergeWithLeft(structs, replacedStructPos + 1) + const tryToMergeWithLeft = (structs, pos) => { + const left = structs[pos - 1] + const right = structs[pos] + if (left.deleted === right.deleted && left.constructor === right.constructor) { + if (left.mergeWith(right)) { + structs.splice(pos, 1) + if (right instanceof AbstractItem && right.parentSub !== null && right.parent._map.get(right.parentSub) === right) { + // @ts-ignore we already did a constructor check above + right.parent._map.set(right.parentSub, left) + } + } + } } - if (replacedStructPos > 0) { - tryToMergeWithLeft(structs, replacedStructPos) + // on all affected store.clients props, try to merge + for (const [client, clock] of transaction.afterState) { + const beforeClock = transaction.beforeState.get(client) || 0 + if (beforeClock !== clock) { + /** + * @type {Array} + */ + // @ts-ignore + const structs = store.clients.get(client) + // we iterate from right to left so we can safely remove entries + const firstChangePos = math.max(findIndexSS(structs, beforeClock), 1) + for (let i = structs.length - 1; i >= firstChangePos; i--) { + tryToMergeWithLeft(structs, i) + } + } } + // try to merge mergeStructs + for (const mid of transaction._mergeStructs) { + const client = mid.client + const clock = mid.clock + /** + * @type {Array} + */ + // @ts-ignore + const structs = store.clients.get(client) + const replacedStructPos = findIndexSS(structs, clock) + if (replacedStructPos + 1 < structs.length) { + tryToMergeWithLeft(structs, replacedStructPos + 1) + } + if (replacedStructPos > 0) { + tryToMergeWithLeft(structs, replacedStructPos) + } + } + // @todo Merge all the transactions into one and provide send the data as a single update message + // @todo implement a dedicatet event that we can use to send updates to other peer + y.emit('afterTransactionCleanup', [transaction, y]) } - y.emit('afterTransactionCleanup', [transaction, y]) + y._transactionCleanups = [] } } } diff --git a/src/utils/Y.js b/src/utils/Y.js index ea8a7d6d..df3f28e3 100644 --- a/src/utils/Y.js +++ b/src/utils/Y.js @@ -39,6 +39,11 @@ export class Y extends Observable { * @private */ this._transaction = null + /** + * @type {Array} + * @private + */ + this._transactionCleanups = [] } /** * Changes that happen inside of a transaction are bundled. This means that diff --git a/tests/testHelper.js b/tests/testHelper.js index 23b44a79..17d1cffd 100644 --- a/tests/testHelper.js +++ b/tests/testHelper.js @@ -233,7 +233,7 @@ export class TestConnector { * @template T * @param {t.TestCase} tc * @param {{users?:number}} conf - * @param {InitTestObjectCallback} initTestObject + * @param {InitTestObjectCallback} [initTestObject] * @return {{testObjects:Array,testConnector:TestConnector,users:Array,array0:Y.Array,array1:Y.Array,array2:Y.Array,map0:Y.Map,map1:Y.Map,map2:Y.Map,map3:Y.Map,text0:Y.Text,text1:Y.Text,text2:Y.Text,xml0:Y.XmlElement,xml1:Y.XmlElement,xml2:Y.XmlElement}} */ export const init = (tc, { users = 5 } = {}, initTestObject) => { @@ -256,7 +256,7 @@ export const init = (tc, { users = 5 } = {}, initTestObject) => { result['text' + i] = y.get('text', Y.Text) } testConnector.syncAll() - result.testObjects = result.users.map(initTestObject) + result.testObjects = result.users.map(initTestObject || (() => null)) // @ts-ignore return result } diff --git a/tests/y-array.tests.js b/tests/y-array.tests.js index 3953a883..ec3d3f07 100644 --- a/tests/y-array.tests.js +++ b/tests/y-array.tests.js @@ -144,6 +144,32 @@ export const testInsertAndDeleteEvents = tc => { compare(users) } +/** + * @param {t.TestCase} tc + */ +export const testNestedObserverEvents = tc => { + const { array0, users } = init(tc, { users: 2 }) + /** + * @type {Array} + */ + const vals = [] + array0.observe(e => { + if (array0.length === 1) { + // inserting, will call this observer again + // we expect that this observer is called after this event handler finishedn + array0.insert(1, [1]) + vals.push(0) + } else { + // this should be called the second time an element is inserted (above case) + vals.push(1) + } + }) + array0.insert(0, [0]) + t.compareArrays(vals, [0, 1]) + t.compareArrays(array0.toArray(), [0, 1]) + compare(users) +} + /** * @param {t.TestCase} tc */