-
Notifications
You must be signed in to change notification settings - Fork 40
Avoid unnecessary DOM structure updates in renderer #1424
Conversation
@Mgsy, could you check these changes? I'm mainly interested if there are some regressions. Scenarios to cover are hard to describe... all that require bigger DOM updates. From typing in weird places, to backspace/enter on bigger non-collapsed selections, applying block quote, lists. Collaboration too. Pretty much everything. |
Steps to reproduceScenario 1
Scenario 2
Current resultThe editor crashes. In scenario 2, after step 3, a highlight doesn't activate on the link. Error
GIF(Scenario 1) |
Steps to reproduce
editor.setData( '<ol><li>Item 1<ol><li>Item 2</li></ol></li></ol><p>Paragraph</p><ol><li>Item 3<ol><li>Item 4</li></ol></li></ol>' );
Current result
GIF |
FirefoxSteps to reproduce
Current resultThe editor crashes. Error
GIF |
This solution causes problems with the user's selection and markers in the collaborative editing. Most of times the marker isn't rendered and a colour of the selection is incorrect. GIF Another case:
Result: The marker becomes invisible on Client B. |
So there were 2 main issues (3 with markers handling but I will cover this in a separate comment): The one with list rendering problem - #1424 (comment) was caused by some logic flaw in a function calculating The second issues is related to attributes rendering (it covers both #1424 (comment) and #1424 (comment)) and it's also analysed in depth in https://github.com/ckeditor/ckeditor5-engine/issues/1427. In short the problem was caused by updating mappings. If new view element was marked to have its attributes updated ( I have also completely removed marked elements sorting. It was one of the first things which were added. From this point the solution evolved significantly and it is no longer needed as |
The problem with markers was more general issue with handling |
src/view/renderer.js
Outdated
return actions; | ||
} | ||
|
||
function areSimilar( domNode1, domNode2 ) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These helper fns should not be defined in this scope. Every execution of this method creates them.
src/view/renderer.js
Outdated
@@ -19,6 +19,7 @@ import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin'; | |||
import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror'; | |||
import isText from '@ckeditor/ckeditor5-utils/src/dom/istext'; | |||
import fastDiff from '@ckeditor/ckeditor5-utils/src/fastdiff'; | |||
import isNode from '@ckeditor/ckeditor5-utils/src/dom/isnode'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Move up, to be right after/before isText()
.
src/view/renderer.js
Outdated
if ( expectedSlice.length && actualSlice.length ) { | ||
newActions = newActions.concat( calculateReplaceActions( actualSlice, expectedSlice ) ); | ||
} else if ( expectedSlice.length || actualSlice.length ) { | ||
newActions = newActions.concat( skipActions ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this needed? Wouldn't calculateReplaceActions
, or rather diff
, return [ 'insert', 'insert', ... ]
if actualSlice
is empty and [ 'delete', 'delete', ... ]
if expectedSlice
is empty?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Especially since you don't have this below (in line 713) which is the same situation as meeting equal
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍
src/view/renderer.js
Outdated
* @param {Object} options | ||
* @param {module:engine/view/position~Position} options.inlineFillerPosition The position on which the inline | ||
* filler should be rendered. | ||
* @param {Boolean} options.bind If new view elements should be bind to their corresponding DOM elements. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is smelly. It violates the single responsibility principle – why does diffSth()
bind anything?
I noticed this because I couldn't understand the comment above:
// We do not perform any operations on DOM here so there is no need to bind view element or convert its children.
It didn't have any sense for me in that context because I saw that the line is const diff = this._diffElementChildren()
.
src/view/renderer.js
Outdated
* @returns {Array} result.actualDomChildren Current viewElement DOM children. | ||
* @returns {Array} result.expectedDomChildren Expected viewElement DOM children. | ||
*/ | ||
_diffElementChildren( viewElement, options ) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
_diffChildren()
would be enough. Or maybe _diffDomChildren()
to indicate that this method works on the DOM (OTOH, "DOM children" doesn't sound good and "DOM element's children" is too much :D).
One generic thing which I don't like in some of the The same, in fact, applies to the |
src/view/renderer.js
Outdated
if ( diff ) { | ||
const actions = this._findReplaceActions( diff.actions, diff.actualDomChildren, diff.expectedDomChildren ); | ||
|
||
if ( actions.indexOf( 'replace' ) !== -1 ) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Make it an early return. Easier to read when there's less nesting.
src/view/renderer.js
Outdated
|
||
if ( actions.indexOf( 'replace' ) !== -1 ) { | ||
const counter = { equal: 0, insert: 0, delete: 0 }; | ||
for ( const action of actions ) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There should be a blank line before for()
/if()
/while
/function
. Odd that we don't have this covered in ESLint.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I suppose I wasn't fully aware of that, but I will keep this in mind now 👍
src/view/renderer.js
Outdated
// The 'uiElement' is a special one and its children are not stored in a view (#799), | ||
// so we cannot use it with replacing flow (since it uses view children during rendering | ||
// which will always result in rendering empty element). | ||
if ( viewChild && !viewChild.is( 'uiElement' ) ) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This function grew a bit long. So, perhaps contents of this if()
would make a reasonable function itself. It only accepts 3 params – the 3 vars that you define above. That's why I found it a good place to split the code a bit.
@@ -447,6 +501,15 @@ export default class Renderer { | |||
*/ | |||
_updateAttrs( viewElement ) { | |||
const domElement = this.domConverter.mapViewToDom( viewElement ); | |||
|
|||
if ( !domElement ) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure about this – this function should not be called if the mapping is broken. Can't we clean Renderer#markedAttribiutes
earlier, when updating the mappings.
In other words – if you call a function, you expect it to do something. You should be more or less sure it makes sense. Otherwise, this function has two responsibilities – do that thing, but also check if it should do it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
AFAIR the main reason was that when we update bindings of a specific element (it is removed from mappings and also all its children mappings are removed). So if you would like to update markedAttributes
during updating bindings you will have to iterate:
- through all updated element children to check and remove them from
markedAttributes
, - or through
markedAttributes
and check if any elements mappings were removed.
It just seemed simpler to check it here if we anyway iterate through markedAttributes
(and this is just an equivalent to 2nd point just in different place). WDYT @Reinmar ?
src/view/renderer.js
Outdated
const diff = this._diffElementChildren( viewElement, | ||
{ inlineFillerPosition: options.inlineFillerPosition, bind: true, withChildren: true } ); | ||
|
||
if ( diff ) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Again – early return.
And – how is it possible that we call this method when its children are up to date?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
And – how is it possible that we call this method when its children are up to date?
I'm not sure what you meant here? Do you have any particular case in mind?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I meant the logic of this method – you call updateChildren()
based on markedElements
. My assumption is – if an element is marked its children have changed. And hence, the diff
should not be empty in this case.
The existence of this if()
here means that my assumption is wrong. But why? We need to have a clarity on:
- Why is this method called for up-to-date element in the first place.
- Can we prevent calling it for up-to-date element (so to remove this
if()
). - Eventually, if it's most convenient to have this
if()
here and it's really needed (there are no other straightforward solutions), we need a comment.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
One thing, even in situations when children are equal we mark descendant text nodes to sync so I'm not sure if we may completely skip such situations (if that's what you mean).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Didn't noticed your comment earlier @Reinmar, so my last comment refers to previous ones.
As for the latest comment - diff
is empty not when elements are up-to-date (then you will have diff
result like [ equal, equal, ... ]
), but when the viewElement
which is passed to updateChildren()
and then diffChildren()
does not have corresponding DOM element (it cannot be mapped which usually means that corresponding DOM element was removed from DOM, but also similar cases as with attributes - #1424 (comment)). This is not a new if()
here, as it was there before and it was just moved to diffChildren()
method together with comment explaining what is happening - https://github.com/ckeditor/ckeditor5-engine/pull/1424/files#diff-4c87133ed830fc4ea0646426deba32cfR611.
src/view/renderer.js
Outdated
const expectedDomChildren = diff.expectedDomChildren; | ||
|
||
let i = 0; | ||
const nodesToUnbind = new Set(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Blank line missing.
src/view/renderer.js
Outdated
return null; | ||
} | ||
|
||
function sameNodes( actualDomChild, expectedDomChild ) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Don't define helper functions inside other methods unless really necessary.
OK, I think we've got enough for now. I haven't dug into the logic because I think it may change a bit still. |
I have covered all proposed changes and explained two more complex cases:
I think it is ready for further review. cc @Reinmar @scofalik |
Crash:
|
I miss a test for the last change. We had a bug. A fix for this bug should be tested. |
I made quite a lot of tests and all seems fine. Checking the tests now and if all's fine I'll be merging this. Congrats! 🌮 |
Suggested merge commit message (convention)
Fix: Avoid unnecessary DOM structure updates in
Renderer
. Closes ckeditor/ckeditor5#4347. Closes ckeditor/ckeditor5#4340. Closes ckeditor/ckeditor5#4307. Closes ckeditor/ckeditor5#4296. Closes ckeditor/ckeditor5#4033. Closes ckeditor/ckeditor5#3109. Closes ckeditor/ckeditor5#3084. Closes ckeditor/ckeditor5#4354.Additional information
See the extensive comment about the implemented solution in https://github.com/ckeditor/ckeditor5-engine/issues/1417#issuecomment-393845235.
There is also one issue which I'm unable to reproduce (from the beginning) so we should definitely ping the person who reported it to recheck after this PR gets merged - ckeditor/ckeditor5#748.