Skip to content

Commit df8742d

Browse files
authored
Merge pull request #5593 from nextcloud/backport/5589/stable29
[stable29] Always initialize with the same yjs document if no state is present
2 parents 8eacbeb + 4900fa2 commit df8742d

File tree

10 files changed

+243
-14
lines changed

10 files changed

+243
-14
lines changed

cypress/e2e/api/SessionApi.spec.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -323,7 +323,7 @@ describe('The session Api', function() {
323323
return con
324324
})
325325
.its('state.documentSource')
326-
.should('eql', '')
326+
.should('eql', '## Hello world\n')
327327
.then(() => joining.close())
328328
.then(() => connection.close())
329329
})
@@ -339,7 +339,7 @@ describe('The session Api', function() {
339339
return con
340340
})
341341
.its('state.documentSource')
342-
.should('eql', '')
342+
.should('eql', '## Hello world\n')
343343
.then(() => joining.close())
344344
.then(() => connection.close())
345345
})

cypress/e2e/initial.spec.js

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
/**
2+
* @copyright Copyright (c) 2020 Julius Härtl <[email protected]>
3+
*
4+
* @author Julius Härtl <[email protected]>
5+
*
6+
* @license AGPL-3.0-or-later
7+
*
8+
* This program is free software: you can redistribute it and/or modify
9+
* it under the terms of the GNU Affero General Public License as
10+
* published by the Free Software Foundation, either version 3 of the
11+
* License, or (at your option) any later version.
12+
*
13+
* This program is distributed in the hope that it will be useful,
14+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
15+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16+
* GNU Affero General Public License for more details.
17+
*
18+
* You should have received a copy of the GNU Affero General Public License
19+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
20+
*
21+
*/
22+
23+
import { randUser } from '../utils/index.js'
24+
25+
const user = randUser()
26+
27+
describe('Test state loading of documents', function() {
28+
before(function() {
29+
// Init user
30+
cy.createUser(user)
31+
cy.login(user)
32+
cy.uploadFile('test.md', 'text/markdown')
33+
cy.uploadFile('test.md', 'text/markdown', 'test2.md')
34+
cy.uploadFile('test.md', 'text/markdown', 'test3.md')
35+
})
36+
beforeEach(function() {
37+
cy.login(user)
38+
})
39+
40+
it('Initial content can not be undone', function() {
41+
cy.shareFile('/test.md', { edit: true })
42+
.then((token) => {
43+
cy.visit(`/s/${token}`)
44+
})
45+
.then(() => {
46+
cy.getEditor().should('be.visible')
47+
cy.getContent()
48+
.should('contain', 'Hello world')
49+
.find('h2').should('contain', 'Hello world')
50+
51+
cy.getMenu().should('be.visible')
52+
cy.getActionEntry('undo').should('be.visible').click()
53+
cy.getContent()
54+
.should('contain', 'Hello world')
55+
.find('h2').should('contain', 'Hello world')
56+
})
57+
})
58+
59+
it('Consecutive sessions work properly', function() {
60+
let readToken = null
61+
let writeToken = null
62+
cy.interceptCreate()
63+
cy.shareFile('/test2.md')
64+
.then((token) => {
65+
readToken = token
66+
cy.logout()
67+
cy.visit(`/s/${readToken}`)
68+
cy.wait('@create')
69+
})
70+
.then(() => {
71+
// Open read only for the first time
72+
cy.getEditor().should('be.visible')
73+
cy.getContent()
74+
.should('contain', 'Hello world')
75+
.find('h2').should('contain', 'Hello world')
76+
cy.closeInterceptedSession(readToken)
77+
78+
// Open read only for the second time
79+
cy.reload()
80+
cy.getEditor().should('be.visible')
81+
cy.getContent()
82+
.should('contain', 'Hello world')
83+
.find('h2').should('contain', 'Hello world')
84+
cy.closeInterceptedSession(readToken)
85+
86+
cy.login(user)
87+
cy.shareFile('/test2.md', { edit: true })
88+
.then((token) => {
89+
writeToken = token
90+
// Open write link and edit something
91+
cy.visit(`/s/${writeToken}`)
92+
cy.getEditor().should('be.visible')
93+
cy.getContent()
94+
.should('contain', 'Hello world')
95+
.find('h2').should('contain', 'Hello world')
96+
cy.getContent()
97+
.type('Something new {end}')
98+
cy.intercept({ method: 'POST', url: '**/session/*/push' }).as('push')
99+
cy.intercept({ method: 'POST', url: '**/session/*/sync' }).as('sync')
100+
cy.wait('@push')
101+
cy.wait('@sync')
102+
cy.closeInterceptedSession(writeToken)
103+
104+
// Reopen read only link and check if changes are there
105+
cy.visit(`/s/${readToken}`)
106+
cy.getEditor().should('be.visible')
107+
cy.getContent()
108+
.find('h2').should('contain', 'Something new Hello world')
109+
})
110+
})
111+
})
112+
113+
it('Load after state has been saved', function() {
114+
let readToken = null
115+
let writeToken = null
116+
cy.interceptCreate()
117+
cy.shareFile('/test3.md', { edit: true })
118+
.then((token) => {
119+
writeToken = token
120+
cy.logout()
121+
cy.visit(`/s/${writeToken}`)
122+
})
123+
.then(() => {
124+
// Open a file, write and save
125+
cy.getEditor().should('be.visible')
126+
cy.getContent()
127+
.should('contain', 'Hello world')
128+
.find('h2').should('contain', 'Hello world')
129+
cy.getContent()
130+
.type('Something new {end}')
131+
cy.intercept({ method: 'POST', url: '**/session/*/save' }).as('save')
132+
cy.get('.save-status button').click()
133+
cy.wait('@save', { timeout: 10000 })
134+
cy.closeInterceptedSession(writeToken)
135+
136+
// Open writable file again and assert the content
137+
cy.reload()
138+
cy.getEditor().should('be.visible')
139+
cy.getContent()
140+
.should('contain', 'Hello world')
141+
.find('h2').should('contain', 'Something new Hello world')
142+
143+
cy.login(user)
144+
cy.shareFile('/test3.md')
145+
.then((token) => {
146+
readToken = token
147+
cy.logout()
148+
cy.visit(`/s/${readToken}`)
149+
})
150+
.then(() => {
151+
// Open read only file again and assert the content
152+
cy.getEditor().should('be.visible')
153+
cy.getContent()
154+
.should('contain', 'Hello world')
155+
.find('h2').should('contain', 'Something new Hello world')
156+
})
157+
})
158+
})
159+
160+
})

cypress/support/commands.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,36 @@ Cypress.Commands.add('closeFile', (params = {}) => {
393393
cy.wait('@close', { timeout: 7000 })
394394
})
395395

396+
let closeData = null
397+
Cypress.Commands.add('interceptCreate', () => {
398+
return cy.intercept({ method: 'PUT', url: '**/session/*/create' }, (req) => {
399+
closeData = {
400+
url: ('' + req.url).replace('create', 'close'),
401+
}
402+
req.continue((res) => {
403+
closeData = {
404+
...closeData,
405+
...res.body,
406+
}
407+
})
408+
}).as('create')
409+
})
410+
411+
Cypress.Commands.add('closeInterceptedSession', (shareToken = undefined) => {
412+
return cy.window().then(win => {
413+
return axios.post(
414+
closeData.url,
415+
{
416+
documentId: closeData.session.documentId,
417+
sessionId: closeData.session.id,
418+
sessionToken: closeData.session.token,
419+
token: shareToken,
420+
},
421+
{ headers: { requesttoken: win.OC.requestToken } },
422+
)
423+
})
424+
})
425+
396426
Cypress.Commands.add('getFile', fileName => {
397427
return cy.get(`[data-cy-files-list] tr[data-cy-files-list-row-name="${fileName}"]`)
398428

lib/Service/ApiService.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,10 @@ public function create(?int $fileId = null, ?string $filePath = null, ?string $b
152152
$this->logger->debug('Existing document, state file loaded ' . $file->getId());
153153
} catch (NotFoundException $e) {
154154
$this->logger->debug('Existing document, but state file not found for ' . $file->getId());
155+
156+
// If we have no state file we need to load the content from the file
157+
// On the client side we use this to initialize a idempotent initial y.js document
158+
$content = $this->loadContent($file);
155159
}
156160
}
157161

package-lock.json

Lines changed: 1 addition & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@
108108
"vue-click-outside": "^1.1.0",
109109
"vue-material-design-icons": "^5.3.0",
110110
"vuex": "^3.6.2",
111+
"y-prosemirror": "^1.0.20",
111112
"y-protocols": "^1.0.6",
112113
"y-websocket": "^2.0.1",
113114
"yjs": "^13.6.14"

src/EditorFactory.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ const loadSyntaxHighlight = async (language) => {
4949
}
5050
}
5151

52-
const createEditor = ({ language, onCreate, onUpdate = () => {}, extensions, enableRichEditing, session, relativePath, isEmbedded = false }) => {
52+
const createEditor = ({ language, onCreate = () => {}, onUpdate = () => {}, extensions, enableRichEditing, session, relativePath, isEmbedded = false }) => {
5353
let defaultExtensions
5454
if (enableRichEditing) {
5555
defaultExtensions = [

src/components/CollisionResolveDialog.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ export default {
7171
const { outsideChange } = this.syncError.data
7272
this.clicked = true
7373
this.$editor.setEditable(!this.readOnly)
74-
this.setContent(outsideChange, { isRich: this.$isRichEditor })
74+
this.setContent(outsideChange, { isRichEditor: this.$isRichEditor })
7575
this.$syncService.forceSave().then(() => this.$syncService.syncUp())
7676
},
7777
},

src/components/Editor.vue

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,8 @@ export default {
497497
logger.debug('onLoaded: Pushing local changes to server')
498498
this.$queue.push(updateMessage)
499499
}
500+
} else {
501+
this.setInitialYjsState(documentSource, { isRichEditor: this.isRichEditor })
500502
}
501503
502504
this.hasConnectionIssue = false
@@ -542,12 +544,6 @@ export default {
542544
isEmbedded: this.isEmbedded,
543545
})
544546
this.hasEditor = true
545-
if (!documentState && documentSource) {
546-
this.setContent(documentSource, {
547-
isRich: this.isRichEditor,
548-
addToHistory: false,
549-
})
550-
}
551547
this.listenEditorEvents()
552548
} else {
553549
// $editor already existed. So this is a reconnect.

src/mixins/setContent.js

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,16 @@
2222

2323
import escapeHtml from 'escape-html'
2424
import markdownit from './../markdownit/index.js'
25+
import { Doc, encodeStateAsUpdate, XmlFragment, applyUpdate } from 'yjs'
26+
import { generateJSON } from '@tiptap/core'
27+
import { prosemirrorToYXmlFragment } from 'y-prosemirror'
28+
import { Node } from '@tiptap/pm/model'
29+
import { createEditor } from '../EditorFactory.js'
2530

2631
export default {
2732
methods: {
28-
setContent(content, { isRich, addToHistory = true } = {}) {
29-
const html = isRich
33+
setContent(content, { isRichEditor, addToHistory = true } = {}) {
34+
const html = isRichEditor
3035
? markdownit.render(content) + '<p/>'
3136
: `<pre>${escapeHtml(content)}</pre>`
3237
this.$editor.chain()
@@ -38,5 +43,39 @@ export default {
3843
.run()
3944
},
4045

46+
setInitialYjsState(content, { isRichEditor }) {
47+
const html = isRichEditor
48+
? markdownit.render(content) + '<p/>'
49+
: `<pre>${escapeHtml(content)}</pre>`
50+
51+
const editor = createEditor({
52+
enableRichEditing: isRichEditor,
53+
})
54+
const json = generateJSON(html, editor.extensionManager.extensions)
55+
56+
const doc = Node.fromJSON(editor.schema, json)
57+
const getBaseDoc = (doc) => {
58+
const ydoc = new Doc()
59+
// In order to make the initial document state idempotent, we need to reset the clientID
60+
// While this is not recommended, we cannot avoid it here as we lack another mechanism
61+
// to generate the initial state on the server side
62+
// The only other option to avoid this could be to generate the initial state once and push
63+
// it to the server immediately, however this would require read only sessions to be able
64+
// to still push a state
65+
ydoc.clientID = 0
66+
const type = /** @type {XmlFragment} */ (ydoc.get('default', XmlFragment))
67+
if (!type.doc) {
68+
// This should not happen but is aligned with the upstream implementation
69+
// https://github.com/yjs/y-prosemirror/blob/8db24263770c2baaccb08e08ea9ef92dbcf8a9da/src/lib.js#L209
70+
return ydoc
71+
}
72+
73+
prosemirrorToYXmlFragment(doc, type)
74+
return ydoc
75+
}
76+
77+
const baseUpdate = encodeStateAsUpdate(getBaseDoc(doc))
78+
applyUpdate(this.$ydoc, baseUpdate)
79+
},
4180
},
4281
}

0 commit comments

Comments
 (0)