Skip to content
This repository has been archived by the owner on Jun 26, 2020. It is now read-only.

Avoid unnecessary DOM structure updates in renderer #1424

Merged
merged 23 commits into from
Jun 18, 2018
Merged

Avoid unnecessary DOM structure updates in renderer #1424

merged 23 commits into from
Jun 18, 2018

Conversation

f1ames
Copy link
Contributor

@f1ames f1ames commented Jun 1, 2018

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.

@coveralls
Copy link

coveralls commented Jun 1, 2018

Coverage Status

Coverage remained the same at 100.0% when pulling 4beb226 on t/1417 into 3e74554 on master.

@Reinmar
Copy link
Member

Reinmar commented Jun 4, 2018

@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.

@Mgsy
Copy link
Member

Mgsy commented Jun 4, 2018

Steps to reproduce

Scenario 1

  1. Put the caret at the beginning of a block.
  2. Press Ctrl + K.
  3. Insert new link.
  4. Put the caret at the beginning of the link.

Scenario 2

  1. Open the article sample.
  2. Put the caret at the beginning of the link.
  3. Press Arrow right.
  4. Repeat step 3.
  5. Press Arrow left.

Current result

The editor crashes. In scenario 2, after step 3, a highlight doesn't activate on the link.

Error

renderer.js:540 Uncaught TypeError: Cannot read property 'attributes' of undefined
    at Renderer._updateAttrs (renderer.js:540)
    at Renderer.render (renderer.js:214)
    at View._render (view.js:391)
    at View.on (view.js:151)
    at View.fire (emittermixin.js:196)
    at View.change (view.js:357)
    at Document.EditingController.listenTo (editingcontroller.js:81)
    at Document.fire (emittermixin.js:196)
    at Model.Document.listenTo (document.js:154)
    at Model.fire (emittermixin.js:196)

GIF

(Scenario 1)
Click.

@Mgsy
Copy link
Member

Mgsy commented Jun 4, 2018

Steps to reproduce

  1. Set the following data:
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>' );
  1. Start the selection in Item 3 and move it through the Paragraph to Item 2.
  2. Click on the ordered list button in the toolbar.

Current result

Item 3 hasn't unwrapped properly and remains inside <ol> element:

image

GIF

Click.

@Mgsy
Copy link
Member

Mgsy commented Jun 4, 2018

Firefox

Steps to reproduce

  1. Open the article sample.
  2. Change your keyboard to Hiragana.
  3. Put the caret inside the link.
  4. Start the composition.
  5. Press Enter to finish the composition.

Current result

The editor crashes.

Error

TypeError: domElement is undefined[Learn More] renderer.js:540
	_updateAttrs renderer.js:540
	render renderer.js:214
	_render view.js:391
	View/< view.js:151
	fire emittermixin.js:196
	change view.js:357
	EditingController/< editingcontroller.js:81
	fire emittermixin.js:196
	Document/< document.js:154
	fire emittermixin.js:196
	_runPendingChanges model.js:405
	enqueueChange model.js:207
	execute inputcommand.js:79
	decorate/< observablemixin.js:250
	fire emittermixin.js:196
	decorate/this[methodName] observablemixin.js:254
	execute commandcollection.js:67
	execute editor.js:254
	_handleTextMutation input.js:293
	handle input.js:152
	_handleMutations input.js:106
	init/< input.js:50
	fire emittermixin.js:196
	_onMutations mutationobserver.js:247
	_onMutations self-hosted:977:17

GIF

Click.

@Mgsy
Copy link
Member

Mgsy commented Jun 5, 2018

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
Click.

Another case:

  1. On Client A put the caret in some word.
  2. Press Arrow right.

Result: The marker becomes invisible on Client B.

@f1ames
Copy link
Contributor Author

f1ames commented Jun 11, 2018

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 replace actions. In some cases if no elements in a group were matched as similar (when group contained only insert or delete actions), actions from this group were skipped (instead of being added unchanged to the output). It was fixed by ebfacb0 commit.

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 (renderer.markedAttributes) and then it was removed from mappings during the renderer._updateChildrenMappings() function call (because similar already existing element was found), the renderer._updateAttrs() function was not able to proceed due to missing mapping (no corresponding DOM element). This was covered in 8147685.

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 renderer accurately diffs whole structure and the order doesn't make a difference here (especially that sorting had been the first thing inside the renderer.render() function and then the renderer._updateChildrenMappings() function was adding more elements so the order was not kept anyway).

@f1ames
Copy link
Contributor Author

f1ames commented Jun 11, 2018

The problem with markers was more general issue with handling UIElements. The UIElement is specific due to the fact that its children are not stored in a view (#799). Before rendering optimisation was implemented the whole marker (marker is just a <span> element) was rerendered as a p element child.
Now as the marker element is treated as replaced element, only its contents were rerendered. Since uiElement child are not present in a view, its expected DOM children were always empty so entire contents of such element were removed during rendering. I think it makes sense just to skip such elements so they are processed as before - covered in c974fbb.

@f1ames
Copy link
Contributor Author

f1ames commented Jun 11, 2018

Ready for 2nd review 🎉 cc @Mgsy @Reinmar

@Reinmar Reinmar changed the title Avoid unnecessary structure updates in renderer Avoid unnecessary DOM structure updates in renderer Jun 12, 2018
@Reinmar Reinmar requested review from Reinmar and scofalik June 12, 2018 08:48
return actions;
}

function areSimilar( domNode1, domNode2 ) {
Copy link
Member

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.

@@ -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';
Copy link
Member

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().

if ( expectedSlice.length && actualSlice.length ) {
newActions = newActions.concat( calculateReplaceActions( actualSlice, expectedSlice ) );
} else if ( expectedSlice.length || actualSlice.length ) {
newActions = newActions.concat( skipActions );
Copy link
Contributor

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?

Copy link
Contributor

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

* @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.
Copy link
Member

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().

* @returns {Array} result.actualDomChildren Current viewElement DOM children.
* @returns {Array} result.expectedDomChildren Expected viewElement DOM children.
*/
_diffElementChildren( viewElement, options ) {
Copy link
Member

@Reinmar Reinmar Jun 12, 2018

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).

@Reinmar
Copy link
Member

Reinmar commented Jun 12, 2018

One generic thing which I don't like in some of the Render's code is that we have methods which don't use this in any way. In such a case I think it'd be better to make them a simple functions.

The same, in fact, applies to the DomConverter where I carelessly added two new methods while they could be simple fns too.

if ( diff ) {
const actions = this._findReplaceActions( diff.actions, diff.actualDomChildren, diff.expectedDomChildren );

if ( actions.indexOf( 'replace' ) !== -1 ) {
Copy link
Member

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.


if ( actions.indexOf( 'replace' ) !== -1 ) {
const counter = { equal: 0, insert: 0, delete: 0 };
for ( const action of actions ) {
Copy link
Member

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.

Copy link
Contributor Author

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 👍

// 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' ) ) {
Copy link
Member

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 ) {
Copy link
Member

@Reinmar Reinmar Jun 12, 2018

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.

Copy link
Contributor Author

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:

  1. through all updated element children to check and remove them from markedAttributes,
  2. 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 ?

const diff = this._diffElementChildren( viewElement,
{ inlineFillerPosition: options.inlineFillerPosition, bind: true, withChildren: true } );

if ( diff ) {
Copy link
Member

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?

Copy link
Contributor Author

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?

Copy link
Member

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:

  1. Why is this method called for up-to-date element in the first place.
  2. Can we prevent calling it for up-to-date element (so to remove this if()).
  3. 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.

Copy link
Contributor Author

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).

Copy link
Contributor Author

@f1ames f1ames Jun 13, 2018

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.

const expectedDomChildren = diff.expectedDomChildren;

let i = 0;
const nodesToUnbind = new Set();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blank line missing.

return null;
}

function sameNodes( actualDomChild, expectedDomChild ) {
Copy link
Member

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.

@Reinmar
Copy link
Member

Reinmar commented Jun 12, 2018

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.

@f1ames
Copy link
Contributor Author

f1ames commented Jun 13, 2018

I have covered all proposed changes and explained two more complex cases:

I think it is ready for further review. cc @Reinmar @scofalik

@Reinmar
Copy link
Member

Reinmar commented Jun 18, 2018

Crash:

  1. <p>[Foo<strong>Bar]</strong></p>
  2. Apply a link

@Reinmar
Copy link
Member

Reinmar commented Jun 18, 2018

I miss a test for the last change. We had a bug. A fix for this bug should be tested.

@Reinmar
Copy link
Member

Reinmar commented Jun 18, 2018

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! 🌮

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.