diff --git a/packages/bridge/src/connection/connection.spec.ts b/packages/bridge/src/connection/connection.spec.ts index 387610b..10f45a8 100644 --- a/packages/bridge/src/connection/connection.spec.ts +++ b/packages/bridge/src/connection/connection.spec.ts @@ -5,7 +5,6 @@ interface TestDoc { status: string } -// TODO: delete this? describe('old state error replication', () => { const clientDocSet = new Automerge.DocSet() const serverDocSet = new Automerge.DocSet() diff --git a/packages/bridge/src/convert/set.ts b/packages/bridge/src/convert/set.ts index 87620cd..a7172c6 100644 --- a/packages/bridge/src/convert/set.ts +++ b/packages/bridge/src/convert/set.ts @@ -23,11 +23,36 @@ const opSet = (op: Automerge.Diff, [map, ops]: any, doc: any) => { const { link, value, path, obj, key } = op try { - // no slate op needed for root key cursor updates + // We can ignore any root level cursor updates since those + // will not correspond to any slate operations if (obj === rootKey && key === 'cursors') { return [map, ops] } + // Handle updates received for the root children array + if (obj === rootKey && key === 'children' && map[value]) { + // First remove all existing child nodes + for (let i = doc.children.length - 1; i >= 0; i--) { + ops.push((map: any) => ({ + type: 'remove_node', + path: [i], + node: doc.children[i] + })) + } + + // Then add all the newly defined nodes + const newChildren: Node[] = map[value] + newChildren.forEach((child, index) => { + ops.push((map: any) => ({ + type: 'insert_node', + path: [index], + node: child + })) + }) + + return [map, ops] + } + if (path && path[0] !== 'cursors') { ops.push(setDataOp(op, doc)) } else if (map[obj]) { diff --git a/packages/client/src/automerge-connector.ts b/packages/client/src/automerge-connector.ts index ad47def..97f59d3 100644 --- a/packages/client/src/automerge-connector.ts +++ b/packages/client/src/automerge-connector.ts @@ -137,19 +137,30 @@ export const AutomergeConnector = { e.handleError(err, { type: 'applyOperation - toSlateOp', operations, - current: Automerge.save(current) + current: Automerge.save(current), + updated: Automerge.save(updated) }) } e.isRemote = true Editor.withoutNormalizing(e, () => { - if (HistoryEditor.isHistoryEditor(e) && !preserveExternalHistory) { - HistoryEditor.withoutSaving(e, () => { + try { + if (HistoryEditor.isHistoryEditor(e) && !preserveExternalHistory) { + HistoryEditor.withoutSaving(e, () => { + slateOps.forEach((o: Operation) => e.apply(o)) + }) + } else { slateOps.forEach((o: Operation) => e.apply(o)) + } + } catch (err) { + e.handleError(err, { + type: 'applyOperation - slateOps apply', + operations, + slateOps, + current: Automerge.save(current), + updated: Automerge.save(updated) }) - } else { - slateOps.forEach((o: Operation) => e.apply(o)) } e.onCursor && e.onCursor(updated.cursors) @@ -180,7 +191,7 @@ export const AutomergeConnector = { if (!doc) return const changed = Automerge.change(doc, (d: any) => { - delete d.cursors + d.cursors = {} }) e.docSet.setDoc(docId, changed as any) diff --git a/packages/client/src/client.spec.ts b/packages/client/src/client.spec.ts index 736da09..bb2a300 100644 --- a/packages/client/src/client.spec.ts +++ b/packages/client/src/client.spec.ts @@ -1,4 +1,4 @@ -import Automerge, { Frontend } from 'automerge' +import Automerge from 'automerge' import { createServer } from 'http' import fs from 'fs' import isEqual from 'lodash/isEqual' @@ -60,9 +60,17 @@ describe('automerge editor client tests', () => { }) const createCollabEditor = async ( - editorOptions: AutomergeOptions & SocketIOPluginOptions = options + editorOptions?: Partial & Partial ) => { - const editor = withIOCollaboration(createEditor(), editorOptions) + // Given a docId we an generate the collab url + if (editorOptions?.docId) { + editorOptions.url = `http://localhost:5000${editorOptions?.docId}` + } + + const editor = withIOCollaboration(createEditor(), { + ...options, + ...editorOptions + }) const oldReceiveDocument = editor.receiveDocument const promise = new Promise(resolve => { @@ -188,8 +196,8 @@ describe('automerge editor client tests', () => { editor2.destroy() }) - it('deep nested tree error', () => { - // Ready from our test json file for the deep tree error + it('should not throw deep nested tree error', () => { + // Read from our test json file for the deep tree error // This allows us to easily reproduce real production errors // and create test cases that resolve those errors const rawData = fs.readFileSync( @@ -205,6 +213,26 @@ describe('automerge editor client tests', () => { toSlateOp(operations, currentDoc) }) + it('should update children for a root level children operation', async () => { + const editor = await createCollabEditor() + + const oldDoc = collabBackend.backend.documentSetMap[docId].getDoc(docId) + const newDoc = Automerge.change(oldDoc, changed => { + // @ts-ignore + changed.children = [ + { type: 'paragraph', children: [{ text: 'new' }] }, + { type: 'paragraph', children: [{ text: 'nodes' }] } + ] + }) + collabBackend.backend.documentSetMap[docId].setDoc(docId, newDoc) + + await waitForCondition(() => editor.children.length === 2) + + expect(editor.children.length).toEqual(2) + expect(Node.string(editor.children[0])).toEqual('new') + expect(Node.string(editor.children[1])).toEqual('nodes') + }) + afterAll(() => { collabBackend.destroy() server.close() diff --git a/packages/client/src/useCursor.ts b/packages/client/src/useCursor.ts index b29e073..a6a12a6 100644 --- a/packages/client/src/useCursor.ts +++ b/packages/client/src/useCursor.ts @@ -16,19 +16,34 @@ const useCursor = ( useEffect(() => { e.onCursor = (data: Cursors) => { if (!mountedRef.current) return + const ranges: Cursor[] = [] - const cursors = toJS(data) - - for (let cursor in cursors) { - if (cursor !== e.clientId && cursors[cursor]) { - ranges.push(JSON.parse(cursors[cursor])) - } + // If the cursor data is null or undefined, unset all active cursors + if (!data) { + setCursorData(ranges) + return } - // only update state if this component is still mounted - if (mountedRef.current) { - setCursorData(ranges) + try { + const cursors = toJS(data) + + for (let cursor in cursors) { + if (cursor !== e.clientId && cursors[cursor]) { + ranges.push(JSON.parse(cursors[cursor])) + } + } + + // only update state if this component is still mounted + if (mountedRef.current) { + setCursorData(ranges) + } + } catch (err) { + e.handleError(err, { + type: 'onCursor', + data, + ranges + }) } } }, [])