From a2931314971fc1c4625e148447680d996d651b59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julius=20H=C3=A4rtl?= Date: Sat, 30 Mar 2024 22:49:26 +0100 Subject: [PATCH 1/6] fix: Create idempotent y.js doc for initial content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Julius Härtl --- package-lock.json | 3 +-- package.json | 1 + src/EditorFactory.js | 2 +- src/components/Editor.vue | 8 ++------ src/mixins/setContent.js | 38 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 43 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 598fe8491e..71a13d938b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -82,6 +82,7 @@ "vue-click-outside": "^1.1.0", "vue-material-design-icons": "^5.3.0", "vuex": "^3.6.2", + "y-prosemirror": "^1.0.20", "y-protocols": "^1.0.6", "y-websocket": "^2.0.1", "yjs": "^13.6.14" @@ -30732,7 +30733,6 @@ "version": "1.0.20", "resolved": "https://registry.npmjs.org/y-prosemirror/-/y-prosemirror-1.0.20.tgz", "integrity": "sha512-LVMtu3qWo0emeYiP+0jgNcvZkqhzE/otOoro+87q0iVKxy/sMKuiJZnokfJdR4cn9qKx0Un5fIxXqbAlR2bFkA==", - "peer": true, "dependencies": { "lib0": "^0.2.42" }, @@ -52580,7 +52580,6 @@ "version": "1.0.20", "resolved": "https://registry.npmjs.org/y-prosemirror/-/y-prosemirror-1.0.20.tgz", "integrity": "sha512-LVMtu3qWo0emeYiP+0jgNcvZkqhzE/otOoro+87q0iVKxy/sMKuiJZnokfJdR4cn9qKx0Un5fIxXqbAlR2bFkA==", - "peer": true, "requires": { "lib0": "^0.2.42" } diff --git a/package.json b/package.json index 9a380ac52f..bfa3018372 100644 --- a/package.json +++ b/package.json @@ -108,6 +108,7 @@ "vue-click-outside": "^1.1.0", "vue-material-design-icons": "^5.3.0", "vuex": "^3.6.2", + "y-prosemirror": "^1.0.20", "y-protocols": "^1.0.6", "y-websocket": "^2.0.1", "yjs": "^13.6.14" diff --git a/src/EditorFactory.js b/src/EditorFactory.js index c30371c22c..feeb13e123 100644 --- a/src/EditorFactory.js +++ b/src/EditorFactory.js @@ -49,7 +49,7 @@ const loadSyntaxHighlight = async (language) => { } } -const createEditor = ({ language, onCreate, onUpdate = () => {}, extensions, enableRichEditing, session, relativePath, isEmbedded = false }) => { +const createEditor = ({ language, onCreate = () => {}, onUpdate = () => {}, extensions, enableRichEditing, session, relativePath, isEmbedded = false }) => { let defaultExtensions if (enableRichEditing) { defaultExtensions = [ diff --git a/src/components/Editor.vue b/src/components/Editor.vue index ddd0b12235..c60dd33dda 100644 --- a/src/components/Editor.vue +++ b/src/components/Editor.vue @@ -497,6 +497,8 @@ export default { logger.debug('onLoaded: Pushing local changes to server') this.$queue.push(updateMessage) } + } else { + this.setInitialYjsState(documentSource, { isRich: this.isRichEditor }) } this.hasConnectionIssue = false @@ -542,12 +544,6 @@ export default { isEmbedded: this.isEmbedded, }) this.hasEditor = true - if (!documentState && documentSource) { - this.setContent(documentSource, { - isRich: this.isRichEditor, - addToHistory: false, - }) - } this.listenEditorEvents() } else { // $editor already existed. So this is a reconnect. diff --git a/src/mixins/setContent.js b/src/mixins/setContent.js index 1ecc28535e..39e0a1091b 100644 --- a/src/mixins/setContent.js +++ b/src/mixins/setContent.js @@ -22,6 +22,11 @@ import escapeHtml from 'escape-html' import markdownit from './../markdownit/index.js' +import { Doc, encodeStateAsUpdate, XmlFragment, applyUpdate } from 'yjs' +import { generateJSON } from '@tiptap/core' +import { prosemirrorToYXmlFragment } from 'y-prosemirror' +import { Node } from '@tiptap/pm/model' +import { createEditor } from '../EditorFactory.js' export default { methods: { @@ -38,5 +43,38 @@ export default { .run() }, + setInitialYjsState(content, { isRich }) { + const html = isRich + ? markdownit.render(content) + '

' + : `

${escapeHtml(content)}
` + + const editor = createEditor({ + enableRichEditing: isRich, + }) + const json = generateJSON(html, editor.extensionManager.extensions) + + const doc = Node.fromJSON(editor.schema, json) + const getBaseDoc = (doc) => { + const ydoc = new Doc() + // In order to make the initial document state idempotent, we need to reset the clientID + // While this is not recommended, we cannot avoid it here as we lack another mechanism + // generate the initial state on the server side + // The only other option to avoid this could be to generate the initial state once and push + // it to the server immediately, however this would require read only sessions to be able + // to still push a state + ydoc.clientID = 0 + const type = /** @type {XmlFragment} */ (ydoc.get('default', XmlFragment)) + if (!type.doc) { + prosemirrorToYXmlFragment(doc, ydoc) + return ydoc + } + + prosemirrorToYXmlFragment(doc, type) + return ydoc + } + + const baseUpdate = encodeStateAsUpdate(getBaseDoc(doc)) + applyUpdate(this.$ydoc, baseUpdate) + }, }, } From e16e6a2a6ee544d48383f53a654f9827d376fc23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julius=20H=C3=A4rtl?= Date: Sat, 30 Mar 2024 23:18:19 +0100 Subject: [PATCH 2/6] tests: Add tests for loading documents from different preconditions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Julius Härtl --- cypress/e2e/initial.spec.js | 157 ++++++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 cypress/e2e/initial.spec.js diff --git a/cypress/e2e/initial.spec.js b/cypress/e2e/initial.spec.js new file mode 100644 index 0000000000..d4e10ef8cd --- /dev/null +++ b/cypress/e2e/initial.spec.js @@ -0,0 +1,157 @@ +/** + * @copyright Copyright (c) 2020 Julius Härtl + * + * @author Julius Härtl + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +import { randUser } from '../utils/index.js' + +const user = randUser() + +describe('Test state loading of documents', function() { + before(function() { + // Init user + cy.createUser(user) + cy.login(user) + cy.uploadFile('test.md', 'text/markdown') + }) + beforeEach(function() { + cy.login(user) + }) + + it('Initial content can not be undone', function() { + cy.shareFile('/test.md', { edit: true }) + .then((token) => { + cy.visit(`/s/${token}`) + }) + .then(() => { + cy.getEditor().should('be.visible') + cy.getContent() + .should('contain', 'Hello world') + .find('h2').should('contain', 'Hello world') + + cy.getMenu().should('be.visible') + cy.getActionEntry('undo').should('be.visible').click() + cy.getContent() + .should('contain', 'Hello world') + .find('h2').should('contain', 'Hello world') + }) + }) + + it('Consecutive sessions work properly', function() { + cy.intercept({ method: 'POST', url: '**/session/*/push' }).as('push') + cy.intercept({ method: 'POST', url: '**/session/*/sync' }).as('sync') + + let readToken = null + let writeToken = null + cy.shareFile('/test.md') + .then((token) => { + readToken = token + cy.logout() + cy.visit(`/s/${readToken}`) + }) + .then(() => { + // Open read only for the first time + cy.getEditor().should('be.visible') + cy.getContent() + .should('contain', 'Hello world') + .find('h2').should('contain', 'Hello world') + + // Open read only for the second time + cy.reload() + cy.getEditor().should('be.visible') + cy.getContent() + .should('contain', 'Hello world') + .find('h2').should('contain', 'Hello world') + + cy.login(user) + cy.shareFile('/test.md', { edit: true }) + .then((token) => { + writeToken = token + // Open write link and edit something + cy.visit(`/s/${writeToken}`) + cy.getEditor().should('be.visible') + cy.getContent() + .should('contain', 'Hello world') + .find('h2').should('contain', 'Hello world') + cy.getContent() + .type('Something new {end}') + cy.wait('@push') + cy.wait('@sync') + + // Reopen read only link and check if changes are there + cy.visit(`/s/${readToken}`) + cy.getEditor().should('be.visible') + cy.getContent() + .find('h2').should('contain', 'Something new Hello world') + + }) + }) + }) + + it('Load after state has been saved', function() { + cy.uploadFile('test.md', 'text/markdown') + cy.intercept({ method: 'POST', url: '**/session/*/push' }).as('push') + cy.intercept({ method: 'POST', url: '**/session/*/sync' }).as('sync') + cy.intercept({ method: 'POST', url: '**/session/*/save' }).as('save') + + let readToken = null + let writeToken = null + cy.shareFile('/test.md', { edit: true }) + .then((token) => { + writeToken = token + cy.logout() + cy.visit(`/s/${writeToken}`) + }) + .then(() => { + // Open read only for the first time + cy.getEditor().should('be.visible') + cy.getContent() + .should('contain', 'Hello world') + .find('h2').should('contain', 'Hello world') + cy.getContent() + .type('Something new {end}') + cy.get('.save-status button').click() + cy.wait('@save') + + // Open read only for the second time + cy.reload() + cy.getEditor().should('be.visible') + cy.getContent() + .should('contain', 'Hello world') + .find('h2').should('contain', 'Something new Hello world') + + cy.login(user) + cy.shareFile('/test.md') + .then((token) => { + readToken = token + cy.logout() + cy.visit(`/s/${readToken}`) + }) + .then(() => { + // Open read only for the first time + cy.getEditor().should('be.visible') + cy.getContent() + .should('contain', 'Hello world') + .find('h2').should('contain', 'Hello world') + }) + }) + }) + +}) From d2235d2a524b2f179ebfcd031eebdd2f668e4c9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julius=20H=C3=A4rtl?= Date: Sat, 30 Mar 2024 23:23:44 +0100 Subject: [PATCH 3/6] fix: Always return initial content when needed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Julius Härtl --- cypress/e2e/initial.spec.js | 23 ++++++++++------------- lib/Service/ApiService.php | 4 ++++ 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/cypress/e2e/initial.spec.js b/cypress/e2e/initial.spec.js index d4e10ef8cd..e9cc0fb707 100644 --- a/cypress/e2e/initial.spec.js +++ b/cypress/e2e/initial.spec.js @@ -30,9 +30,14 @@ describe('Test state loading of documents', function() { cy.createUser(user) cy.login(user) cy.uploadFile('test.md', 'text/markdown') + cy.uploadFile('test.md', 'text/markdown', 'test2.md') + cy.uploadFile('test.md', 'text/markdown', 'test3.md') }) beforeEach(function() { cy.login(user) + cy.intercept({ method: 'POST', url: '**/session/*/push' }).as('push') + cy.intercept({ method: 'POST', url: '**/session/*/sync' }).as('sync') + cy.intercept({ method: 'POST', url: '**/session/*/save' }).as('save') }) it('Initial content can not be undone', function() { @@ -55,12 +60,9 @@ describe('Test state loading of documents', function() { }) it('Consecutive sessions work properly', function() { - cy.intercept({ method: 'POST', url: '**/session/*/push' }).as('push') - cy.intercept({ method: 'POST', url: '**/session/*/sync' }).as('sync') - let readToken = null let writeToken = null - cy.shareFile('/test.md') + cy.shareFile('/test2.md') .then((token) => { readToken = token cy.logout() @@ -81,7 +83,7 @@ describe('Test state loading of documents', function() { .find('h2').should('contain', 'Hello world') cy.login(user) - cy.shareFile('/test.md', { edit: true }) + cy.shareFile('/test2.md', { edit: true }) .then((token) => { writeToken = token // Open write link and edit something @@ -106,14 +108,9 @@ describe('Test state loading of documents', function() { }) it('Load after state has been saved', function() { - cy.uploadFile('test.md', 'text/markdown') - cy.intercept({ method: 'POST', url: '**/session/*/push' }).as('push') - cy.intercept({ method: 'POST', url: '**/session/*/sync' }).as('sync') - cy.intercept({ method: 'POST', url: '**/session/*/save' }).as('save') - let readToken = null let writeToken = null - cy.shareFile('/test.md', { edit: true }) + cy.shareFile('/test3.md', { edit: true }) .then((token) => { writeToken = token cy.logout() @@ -137,8 +134,8 @@ describe('Test state loading of documents', function() { .should('contain', 'Hello world') .find('h2').should('contain', 'Something new Hello world') - cy.login(user) - cy.shareFile('/test.md') + cy.login(user) + cy.shareFile('/test3.md') .then((token) => { readToken = token cy.logout() diff --git a/lib/Service/ApiService.php b/lib/Service/ApiService.php index 488b6986db..760ee90862 100644 --- a/lib/Service/ApiService.php +++ b/lib/Service/ApiService.php @@ -152,6 +152,10 @@ public function create(?int $fileId = null, ?string $filePath = null, ?string $b $this->logger->debug('Existing document, state file loaded ' . $file->getId()); } catch (NotFoundException $e) { $this->logger->debug('Existing document, but state file not found for ' . $file->getId()); + + // If we have no state file we need to load the content from the file + // On the client side we use this to initialize a idempotent initial y.js document + $content = $this->loadContent($file); } } From 94ffe79662a6474a2c18ab5b6c175b69cd3df437 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julius=20H=C3=A4rtl?= Date: Sun, 31 Mar 2024 00:21:33 +0100 Subject: [PATCH 4/6] tests: Adjust tests covering initial state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Julius Härtl --- cypress/e2e/api/SessionApi.spec.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cypress/e2e/api/SessionApi.spec.js b/cypress/e2e/api/SessionApi.spec.js index 666737b1eb..0f4eaaf46d 100644 --- a/cypress/e2e/api/SessionApi.spec.js +++ b/cypress/e2e/api/SessionApi.spec.js @@ -323,7 +323,7 @@ describe('The session Api', function() { return con }) .its('state.documentSource') - .should('eql', '') + .should('eql', '## Hello world\n') .then(() => joining.close()) .then(() => connection.close()) }) @@ -339,7 +339,7 @@ describe('The session Api', function() { return con }) .its('state.documentSource') - .should('eql', '') + .should('eql', '## Hello world\n') .then(() => joining.close()) .then(() => connection.close()) }) From 89ebc5a4c32ed9bf10ed16b10fbb008eefd6055a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julius=20H=C3=A4rtl?= Date: Sun, 31 Mar 2024 18:30:49 +0200 Subject: [PATCH 5/6] ci: Make cypress test more stable by closing connections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Julius Härtl --- cypress/e2e/initial.spec.js | 24 +++++++++++++++--------- cypress/support/commands.js | 30 ++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/cypress/e2e/initial.spec.js b/cypress/e2e/initial.spec.js index e9cc0fb707..c9372c22ff 100644 --- a/cypress/e2e/initial.spec.js +++ b/cypress/e2e/initial.spec.js @@ -35,9 +35,6 @@ describe('Test state loading of documents', function() { }) beforeEach(function() { cy.login(user) - cy.intercept({ method: 'POST', url: '**/session/*/push' }).as('push') - cy.intercept({ method: 'POST', url: '**/session/*/sync' }).as('sync') - cy.intercept({ method: 'POST', url: '**/session/*/save' }).as('save') }) it('Initial content can not be undone', function() { @@ -62,11 +59,13 @@ describe('Test state loading of documents', function() { it('Consecutive sessions work properly', function() { let readToken = null let writeToken = null + cy.interceptCreate() cy.shareFile('/test2.md') .then((token) => { readToken = token cy.logout() cy.visit(`/s/${readToken}`) + cy.wait('@create') }) .then(() => { // Open read only for the first time @@ -74,6 +73,7 @@ describe('Test state loading of documents', function() { cy.getContent() .should('contain', 'Hello world') .find('h2').should('contain', 'Hello world') + cy.closeInterceptedSession(readToken) // Open read only for the second time cy.reload() @@ -81,6 +81,7 @@ describe('Test state loading of documents', function() { cy.getContent() .should('contain', 'Hello world') .find('h2').should('contain', 'Hello world') + cy.closeInterceptedSession(readToken) cy.login(user) cy.shareFile('/test2.md', { edit: true }) @@ -94,15 +95,17 @@ describe('Test state loading of documents', function() { .find('h2').should('contain', 'Hello world') cy.getContent() .type('Something new {end}') + cy.intercept({ method: 'POST', url: '**/session/*/push' }).as('push') + cy.intercept({ method: 'POST', url: '**/session/*/sync' }).as('sync') cy.wait('@push') cy.wait('@sync') + cy.closeInterceptedSession(writeToken) // Reopen read only link and check if changes are there cy.visit(`/s/${readToken}`) cy.getEditor().should('be.visible') cy.getContent() .find('h2').should('contain', 'Something new Hello world') - }) }) }) @@ -110,6 +113,7 @@ describe('Test state loading of documents', function() { it('Load after state has been saved', function() { let readToken = null let writeToken = null + cy.interceptCreate() cy.shareFile('/test3.md', { edit: true }) .then((token) => { writeToken = token @@ -117,17 +121,19 @@ describe('Test state loading of documents', function() { cy.visit(`/s/${writeToken}`) }) .then(() => { - // Open read only for the first time + // Open a file, write and save cy.getEditor().should('be.visible') cy.getContent() .should('contain', 'Hello world') .find('h2').should('contain', 'Hello world') cy.getContent() .type('Something new {end}') + cy.intercept({ method: 'POST', url: '**/session/*/save' }).as('save') cy.get('.save-status button').click() - cy.wait('@save') + cy.wait('@save', { timeout: 10000 }) + cy.closeInterceptedSession(writeToken) - // Open read only for the second time + // Open writable file again and assert the content cy.reload() cy.getEditor().should('be.visible') cy.getContent() @@ -142,11 +148,11 @@ describe('Test state loading of documents', function() { cy.visit(`/s/${readToken}`) }) .then(() => { - // Open read only for the first time + // Open read only file again and assert the content cy.getEditor().should('be.visible') cy.getContent() .should('contain', 'Hello world') - .find('h2').should('contain', 'Hello world') + .find('h2').should('contain', 'Something new Hello world') }) }) }) diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 5928c88e2a..0e3a3edafc 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -393,6 +393,36 @@ Cypress.Commands.add('closeFile', (params = {}) => { cy.wait('@close', { timeout: 7000 }) }) +let closeData = null +Cypress.Commands.add('interceptCreate', () => { + return cy.intercept({ method: 'PUT', url: '**/session/*/create' }, (req) => { + closeData = { + url: ('' + req.url).replace('create', 'close'), + } + req.continue((res) => { + closeData = { + ...closeData, + ...res.body, + } + }) + }).as('create') +}) + +Cypress.Commands.add('closeInterceptedSession', (shareToken = undefined) => { + return cy.window().then(win => { + return axios.post( + closeData.url, + { + documentId: closeData.session.documentId, + sessionId: closeData.session.id, + sessionToken: closeData.session.token, + token: shareToken, + }, + { headers: { requesttoken: win.OC.requestToken } }, + ) + }) +}) + Cypress.Commands.add('getFile', fileName => { return cy.get(`[data-cy-files-list] tr[data-cy-files-list-row-name="${fileName}"]`) From ee944490337a699366dc8f721c7999d14a3515b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julius=20H=C3=A4rtl?= Date: Tue, 2 Apr 2024 12:48:25 +0200 Subject: [PATCH 6/6] fix: Adapt to review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Julius Härtl --- src/components/CollisionResolveDialog.vue | 2 +- src/components/Editor.vue | 2 +- src/mixins/setContent.js | 15 ++++++++------- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/components/CollisionResolveDialog.vue b/src/components/CollisionResolveDialog.vue index 7de994c7d0..304f7724d3 100644 --- a/src/components/CollisionResolveDialog.vue +++ b/src/components/CollisionResolveDialog.vue @@ -71,7 +71,7 @@ export default { const { outsideChange } = this.syncError.data this.clicked = true this.$editor.setEditable(!this.readOnly) - this.setContent(outsideChange, { isRich: this.$isRichEditor }) + this.setContent(outsideChange, { isRichEditor: this.$isRichEditor }) this.$syncService.forceSave().then(() => this.$syncService.syncUp()) }, }, diff --git a/src/components/Editor.vue b/src/components/Editor.vue index c60dd33dda..1d32916771 100644 --- a/src/components/Editor.vue +++ b/src/components/Editor.vue @@ -498,7 +498,7 @@ export default { this.$queue.push(updateMessage) } } else { - this.setInitialYjsState(documentSource, { isRich: this.isRichEditor }) + this.setInitialYjsState(documentSource, { isRichEditor: this.isRichEditor }) } this.hasConnectionIssue = false diff --git a/src/mixins/setContent.js b/src/mixins/setContent.js index 39e0a1091b..0126fde566 100644 --- a/src/mixins/setContent.js +++ b/src/mixins/setContent.js @@ -30,8 +30,8 @@ import { createEditor } from '../EditorFactory.js' export default { methods: { - setContent(content, { isRich, addToHistory = true } = {}) { - const html = isRich + setContent(content, { isRichEditor, addToHistory = true } = {}) { + const html = isRichEditor ? markdownit.render(content) + '

' : `

${escapeHtml(content)}
` this.$editor.chain() @@ -43,13 +43,13 @@ export default { .run() }, - setInitialYjsState(content, { isRich }) { - const html = isRich + setInitialYjsState(content, { isRichEditor }) { + const html = isRichEditor ? markdownit.render(content) + '

' : `

${escapeHtml(content)}
` const editor = createEditor({ - enableRichEditing: isRich, + enableRichEditing: isRichEditor, }) const json = generateJSON(html, editor.extensionManager.extensions) @@ -58,14 +58,15 @@ export default { const ydoc = new Doc() // In order to make the initial document state idempotent, we need to reset the clientID // While this is not recommended, we cannot avoid it here as we lack another mechanism - // generate the initial state on the server side + // to generate the initial state on the server side // The only other option to avoid this could be to generate the initial state once and push // it to the server immediately, however this would require read only sessions to be able // to still push a state ydoc.clientID = 0 const type = /** @type {XmlFragment} */ (ydoc.get('default', XmlFragment)) if (!type.doc) { - prosemirrorToYXmlFragment(doc, ydoc) + // This should not happen but is aligned with the upstream implementation + // https://github.com/yjs/y-prosemirror/blob/8db24263770c2baaccb08e08ea9ef92dbcf8a9da/src/lib.js#L209 return ydoc }