Browse Source

Merge branch 'master' into 1640

Maciej Gołaszewski 6 years ago
parent
commit
b3392e03ff
27 changed files with 212 additions and 136 deletions
  1. 3 6
      packages/ckeditor5-engine/src/controller/editingcontroller.js
  2. 0 0
      packages/ckeditor5-engine/src/conversion/upcast-converters.js
  3. 2 2
      packages/ckeditor5-engine/src/model/document.js
  4. 2 2
      packages/ckeditor5-engine/src/model/element.js
  5. 1 8
      packages/ckeditor5-engine/src/model/model.js
  6. 3 0
      packages/ckeditor5-engine/src/view/document.js
  7. 8 2
      packages/ckeditor5-engine/src/view/element.js
  8. 1 2
      packages/ckeditor5-engine/src/view/node.js
  9. 2 2
      packages/ckeditor5-engine/src/view/observer/focusobserver.js
  10. 2 2
      packages/ckeditor5-engine/src/view/observer/mutationobserver.js
  11. 2 2
      packages/ckeditor5-engine/src/view/observer/observer.js
  12. 1 1
      packages/ckeditor5-engine/src/view/observer/selectionobserver.js
  13. 1 2
      packages/ckeditor5-engine/src/view/placeholder.js
  14. 76 33
      packages/ckeditor5-engine/src/view/view.js
  15. 1 1
      packages/ckeditor5-engine/tests/controller/editingcontroller.js
  16. 5 3
      packages/ckeditor5-engine/tests/tickets/1653.js
  17. 1 1
      packages/ckeditor5-engine/tests/view/manual/immutable.js
  18. 1 1
      packages/ckeditor5-engine/tests/view/manual/noselection.js
  19. 1 1
      packages/ckeditor5-engine/tests/view/observer/domeventobserver.js
  20. 8 5
      packages/ckeditor5-engine/tests/view/observer/focusobserver.js
  21. 12 12
      packages/ckeditor5-engine/tests/view/observer/mutationobserver.js
  22. 3 2
      packages/ckeditor5-engine/tests/view/observer/selectionobserver.js
  23. 2 2
      packages/ckeditor5-engine/tests/view/placeholder.js
  24. 10 10
      packages/ckeditor5-engine/tests/view/renderer.js
  25. 3 3
      packages/ckeditor5-engine/tests/view/view/jumpoverinlinefiller.js
  26. 1 1
      packages/ckeditor5-engine/tests/view/view/jumpoveruielement.js
  27. 60 30
      packages/ckeditor5-engine/tests/view/view/view.js

+ 3 - 6
packages/ckeditor5-engine/src/controller/editingcontroller.js

@@ -75,14 +75,11 @@ export default class EditingController {
 		//
 		// See  https://github.com/ckeditor/ckeditor5-engine/issues/1528
 		this.listenTo( this.model, '_beforeChanges', () => {
-			this.view._renderingDisabled = true;
+			this.view._disableRendering( true );
 		}, { priority: 'highest' } );
 
-		this.listenTo( this.model, '_afterChanges', ( evt, { hasModelDocumentChanged } ) => {
-			this.view._renderingDisabled = false;
-			if ( hasModelDocumentChanged ) {
-				this.view.render();
-			}
+		this.listenTo( this.model, '_afterChanges', () => {
+			this.view._disableRendering( false );
 		}, { priority: 'lowest' } );
 
 		// Whenever model document is changed, convert those changes to the view (using model.Document#differ).

+ 0 - 0
packages/ckeditor5-engine/src/conversion/upcast-converters.js


+ 2 - 2
packages/ckeditor5-engine/src/model/document.js

@@ -240,8 +240,8 @@ export default class Document {
 	}
 
 	/**
-	 * Used to register a post-fixer callback. A post-fixer mechanism guarantees that the features that listen to
-	 * the {@link module:engine/model/model~Model#event:_change model's change event} will operate on a correct model state.
+	 * Used to register a post-fixer callback. A post-fixer mechanism guarantees that the features
+	 * will operate on a correct model state.
 	 *
 	 * An execution of a feature may lead to an incorrect document tree state. The callbacks are used to fix the document tree after
 	 * it has changed. Post-fixers are fired just after all changes from the outermost change block were applied but

+ 2 - 2
packages/ckeditor5-engine/src/model/element.js

@@ -89,7 +89,7 @@ export default class Element extends Node {
 	}
 
 	/**
-	 * Checks whether given model tree object is of given type.
+	 * Checks whether this model object is of the given type.
 	 *
 	 *		obj.name; // 'listItem'
 	 *		obj instanceof Element; // true
@@ -100,7 +100,7 @@ export default class Element extends Node {
 	 *		obj.is( 'text' ); // false
 	 *		obj.is( 'element', 'image' ); // false
 	 *
-	 * Read more in {@link module:engine/model/node~Node#is}.
+	 * Read more in {@link module:engine/model/node~Node#is `Node#is()`}.
 	 *
 	 * @param {String} type Type to check when `name` parameter is present.
 	 * Otherwise, it acts like the `name` parameter.

+ 1 - 8
packages/ckeditor5-engine/src/model/model.js

@@ -683,7 +683,6 @@ export default class Model {
 	 */
 	_runPendingChanges() {
 		const ret = [];
-		let hasModelDocumentChanged = false;
 
 		this.fire( '_beforeChanges' );
 
@@ -696,9 +695,6 @@ export default class Model {
 			const callbackReturnValue = this._pendingChanges[ 0 ].callback( this._currentWriter );
 			ret.push( callbackReturnValue );
 
-			// Collect an information whether the model document has changed during from the last pending change.
-			hasModelDocumentChanged = hasModelDocumentChanged || this.document._hasDocumentChangedFromTheLastChangeBlock();
-
 			// Fire '_change' event before resetting differ.
 			this.fire( '_change', this._currentWriter );
 
@@ -708,7 +704,7 @@ export default class Model {
 			this._currentWriter = null;
 		}
 
-		this.fire( '_afterChanges', { hasModelDocumentChanged } );
+		this.fire( '_afterChanges' );
 
 		return ret;
 	}
@@ -739,9 +735,6 @@ export default class Model {
 	 *
 	 * @protected
 	 * @event _afterChanges
-	 * @param {Object} options
-	 * @param {Boolean} options.hasModelDocumentChanged `true` if the model document has changed during the
-	 * {@link module:engine/model/model~Model#change} or {@link module:engine/model/model~Model#enqueueChange} blocks.
 	 */
 
 	/**

+ 3 - 0
packages/ckeditor5-engine/src/view/document.js

@@ -115,6 +115,9 @@ export default class Document {
 	 * As a parameter, a post-fixer callback receives a {@link module:engine/view/downcastwriter~DowncastWriter downcast writer}
 	 * instance connected with the executed changes block.
 	 *
+	 * Note that registering a post-fixer won't re-render the editor's view. If the view should change after registering the post-fixer then
+	 * it should be done manually calling `view.forceRender();`.
+	 *
 	 * @param {Function} postFixer
 	 */
 	registerPostFixer( postFixer ) {

+ 8 - 2
packages/ckeditor5-engine/src/view/element.js

@@ -147,9 +147,15 @@ export default class Element extends Node {
 	}
 
 	/**
-	 * Checks whether given view tree object is of given type.
+	 * Checks whether this view object is of the given type.
 	 *
-	 * Read more in {@link module:engine/view/node~Node#is}.
+	 *		obj.is( 'element' ); // true
+	 *		obj.is( 'li' ); // true
+	 *		obj.is( 'element', 'li' ); // true
+	 *		obj.is( 'text' ); // false
+	 *		obj.is( 'element', 'img' ); // false
+	 *
+	 * Read more in {@link module:engine/view/node~Node#is `Node#is()`}.
 	 *
 	 * @param {String} type
 	 * @param {String} [name] Element name.

+ 1 - 2
packages/ckeditor5-engine/src/view/node.js

@@ -287,7 +287,7 @@ export default class Node {
 	}
 
 	/**
-	 * Checks whether given view tree object is of given type.
+	 * Checks whether this view object is of the given type.
 	 *
 	 * This method is useful when processing view tree objects that are of unknown type. For example, a function
 	 * may return {@link module:engine/view/documentfragment~DocumentFragment} or {@link module:engine/view/node~Node}
@@ -300,7 +300,6 @@ export default class Node {
 	 *		obj.is( 'p' ); // shortcut for obj.is( 'element', 'p' )
 	 *		obj.is( 'text' ); // true for text node, false for element and document fragment
 	 *
-	 * @method #is
 	 * @param {'element'|'containerElement'|'attributeElement'|'emptyElement'|'uiElement'|
 	 * 'rootElement'|'documentFragment'|'text'|'textProxy'} type
 	 * @returns {Boolean}

+ 2 - 2
packages/ckeditor5-engine/src/view/observer/focusobserver.js

@@ -37,7 +37,7 @@ export default class FocusObserver extends DomEventObserver {
 			// overwrite new DOM selection with selection from the view.
 			// See https://github.com/ckeditor/ckeditor5-engine/issues/795 for more details.
 			// Long timeout is needed to solve #676 and https://github.com/ckeditor/ckeditor5-engine/issues/1157 issues.
-			this._renderTimeoutId = setTimeout( () => view.render(), 50 );
+			this._renderTimeoutId = setTimeout( () => view.forceRender(), 50 );
 		} );
 
 		document.on( 'blur', ( evt, data ) => {
@@ -47,7 +47,7 @@ export default class FocusObserver extends DomEventObserver {
 				document.isFocused = false;
 
 				// Re-render the document to update view elements.
-				view.render();
+				view.forceRender();
 			}
 		} );
 

+ 2 - 2
packages/ckeditor5-engine/src/view/observer/mutationobserver.js

@@ -55,7 +55,7 @@ export default class MutationObserver extends Observer {
 		this.domConverter = view.domConverter;
 
 		/**
-		 * Reference to the {@link module:engine/view/view~View#renderer}.
+		 * Reference to the {@link module:engine/view/view~View#_renderer}.
 		 *
 		 * @member {module:engine/view/renderer~Renderer}
 		 */
@@ -248,7 +248,7 @@ export default class MutationObserver extends Observer {
 
 		// If nothing changes on `mutations` event, at this point we have "dirty DOM" (changed) and de-synched
 		// view (which has not been changed). In order to "reset DOM" we render the view again.
-		this.view.render();
+		this.view.forceRender();
 
 		function sameNodes( child1, child2 ) {
 			// First level of comparison (array of children vs array of children) – use the Lodash's default behavior.

+ 2 - 2
packages/ckeditor5-engine/src/view/observer/observer.js

@@ -52,7 +52,7 @@ export default class Observer {
 
 	/**
 	 * Enables the observer. This method is called when the observer is registered to the
-	 * {@link module:engine/view/view~View} and after {@link module:engine/view/view~View#render rendering}
+	 * {@link module:engine/view/view~View} and after {@link module:engine/view/view~View#forceRender rendering}
 	 * (all observers are {@link #disable disabled} before rendering).
 	 *
 	 * A typical use case for disabling observers is that mutation observers need to be disabled for the rendering.
@@ -66,7 +66,7 @@ export default class Observer {
 
 	/**
 	 * Disables the observer. This method is called before
-	 * {@link module:engine/view/view~View#render rendering} to prevent firing events during rendering.
+	 * {@link module:engine/view/view~View#forceRender rendering} to prevent firing events during rendering.
 	 *
 	 * @see module:engine/view/observer/observer~Observer#enable
 	 */

+ 1 - 1
packages/ckeditor5-engine/src/view/observer/selectionobserver.js

@@ -166,7 +166,7 @@ export default class SelectionObserver extends Observer {
 		if ( this.selection.isSimilar( newViewSelection ) ) {
 			// If selection was equal and we are at this point of algorithm, it means that it was incorrect.
 			// Just re-render it, no need to fire any events, etc.
-			this.view.render();
+			this.view.forceRender();
 		} else {
 			const data = {
 				oldSelection: this.selection,

+ 1 - 2
packages/ckeditor5-engine/src/view/placeholder.js

@@ -39,8 +39,7 @@ export function attachPlaceholder( view, element, placeholderText, checkFunction
 		checkFunction
 	} );
 
-	// Update view right away.
-	view.render();
+	view.change( writer => updateAllPlaceholders( document, writer ) );
 }
 
 /**

+ 76 - 33
packages/ckeditor5-engine/src/view/view.js

@@ -66,42 +66,42 @@ export default class View {
 		 * Instance of the {@link module:engine/view/document~Document} associated with this view controller.
 		 *
 		 * @readonly
-		 * @member {module:engine/view/document~Document} module:engine/view/view~View#document
+		 * @type {module:engine/view/document~Document}
 		 */
 		this.document = new Document();
 
 		/**
-		 * Instance of the {@link module:engine/view/domconverter~DomConverter domConverter} use by
-		 * {@link module:engine/view/view~View#renderer renderer}
+		 * Instance of the {@link module:engine/view/domconverter~DomConverter domConverter} used by
+		 * {@link module:engine/view/view~View#_renderer renderer}
 		 * and {@link module:engine/view/observer/observer~Observer observers}.
 		 *
 		 * @readonly
-		 * @member {module:engine/view/domconverter~DomConverter} module:engine/view/view~View#domConverter
+		 * @type {module:engine/view/domconverter~DomConverter}
 		 */
 		this.domConverter = new DomConverter();
 
 		/**
-		 * Instance of the {@link module:engine/view/renderer~Renderer renderer}.
+		 * Roots of the DOM tree. Map on the `HTMLElement`s with roots names as keys.
 		 *
-		 * @protected
-		 * @member {module:engine/view/renderer~Renderer} module:engine/view/view~View#renderer
+		 * @readonly
+		 * @type {Map.<String, HTMLElement>}
 		 */
-		this._renderer = new Renderer( this.domConverter, this.document.selection );
-		this._renderer.bind( 'isFocused' ).to( this.document );
+		this.domRoots = new Map();
 
 		/**
-		 * Roots of the DOM tree. Map on the `HTMLElement`s with roots names as keys.
+		 * Instance of the {@link module:engine/view/renderer~Renderer renderer}.
 		 *
-		 * @readonly
-		 * @member {Map} module:engine/view/view~View#domRoots
+		 * @protected
+		 * @type {module:engine/view/renderer~Renderer}
 		 */
-		this.domRoots = new Map();
+		this._renderer = new Renderer( this.domConverter, this.document.selection );
+		this._renderer.bind( 'isFocused' ).to( this.document );
 
 		/**
 		 * Map of registered {@link module:engine/view/observer/observer~Observer observers}.
 		 *
 		 * @private
-		 * @member {Map.<Function, module:engine/view/observer/observer~Observer>} module:engine/view/view~View#_observers
+		 * @type {Map.<Function, module:engine/view/observer/observer~Observer>}
 		 */
 		this._observers = new Map();
 
@@ -109,39 +109,48 @@ export default class View {
 		 * Is set to `true` when {@link #change view changes} are currently in progress.
 		 *
 		 * @private
-		 * @member {Boolean} module:engine/view/view~View#_ongoingChange
+		 * @type {Boolean}
 		 */
 		this._ongoingChange = false;
 
 		/**
-		 * Used to prevent calling {@link #render} and {@link #change} during rendering view to the DOM.
+		 * Used to prevent calling {@link #forceRender} and {@link #change} during rendering view to the DOM.
 		 *
 		 * @private
-		 * @member {Boolean} module:engine/view/view~View#_renderingInProgress
+		 * @type {Boolean}
 		 */
 		this._renderingInProgress = false;
 
 		/**
-		 * Used to prevent calling {@link #render} and {@link #change} during rendering view to the DOM.
+		 * Used to prevent calling {@link #forceRender} and {@link #change} during rendering view to the DOM.
 		 *
 		 * @private
-		 * @member {Boolean} module:engine/view/view~View#_renderingInProgress
+		 * @type {Boolean}
 		 */
 		this._postFixersInProgress = false;
 
 		/**
-		 * Internal flag to temporary disable rendering. See usage in the editing controller.
+		 * Internal flag to temporary disable rendering. See the usage in the {@link #_disableRendering}.
 		 *
-		 * @protected
-		 * @member {Boolean} module:engine/view/view~View#_renderingDisabled
+		 * @private
+		 * @type {Boolean}
 		 */
 		this._renderingDisabled = false;
 
 		/**
-		 * DowncastWriter instance used in {@link #change change method) callbacks.
+		 * Internal flag that disables rendering when there are no changes since the last rendering.
+		 * It stores information about changed selection and changed elements from attached document roots.
+		 *
+		 * @private
+		 * @type {Boolean}
+		 */
+		this._hasChangedSinceTheLastRendering = false;
+
+		/**
+		 * DowncastWriter instance used in {@link #change change method} callbacks.
 		 *
 		 * @private
-		 * @member {module:engine/view/downcastwriter~DowncastWriter} module:engine/view/view~View#_writer
+		 * @type {module:engine/view/downcastwriter~DowncastWriter}
 		 */
 		this._writer = new DowncastWriter( this.document );
 
@@ -163,6 +172,14 @@ export default class View {
 
 			// Informs that layout has changed after render.
 			this.document.fire( 'layoutChanged' );
+
+			// Reset the `_hasChangedSinceTheLastRendering` flag after rendering.
+			this._hasChangedSinceTheLastRendering = false;
+		} );
+
+		// Listen to the document selection changes directly.
+		this.listenTo( this.document.selection, 'change', () => {
+			this._hasChangedSinceTheLastRendering = true;
 		} );
 	}
 
@@ -192,6 +209,10 @@ export default class View {
 		viewRoot.on( 'change:attributes', ( evt, node ) => this._renderer.markToSync( 'attributes', node ) );
 		viewRoot.on( 'change:text', ( evt, node ) => this._renderer.markToSync( 'text', node ) );
 
+		viewRoot.on( 'change', () => {
+			this._hasChangedSinceTheLastRendering = true;
+		} );
+
 		for ( const observer of this._observers.values() ) {
 			observer.observe( domRoot, name );
 		}
@@ -293,7 +314,7 @@ export default class View {
 
 			if ( editable ) {
 				this.domConverter.focus( editable );
-				this.render();
+				this.forceRender();
 			} else {
 				/**
 				 * Before focusing view document, selection should be placed inside one of the view's editables.
@@ -309,9 +330,10 @@ export default class View {
 
 	/**
 	 * The `change()` method is the primary way of changing the view. You should use it to modify any node in the view tree.
-	 * It makes sure that after all changes are made the view is rendered to the DOM. It prevents situations when the DOM is updated
-	 * when the view state is not yet correct. It allows to nest calls one inside another and still performs a single rendering
-	 * after all those changes are made. It also returns the return value of its callback.
+	 * It makes sure that after all changes are made the view is rendered to the DOM (assuming that the view will be changed
+	 * inside the callback). It prevents situations when the DOM is updated when the view state is not yet correct. It allows
+	 * to nest calls one inside another and still performs a single rendering after all those changes are made.
+	 * It also returns the return value of its callback.
 	 *
 	 *		const text = view.change( writer => {
 	 *			const newText = writer.createText( 'foo' );
@@ -342,8 +364,8 @@ export default class View {
 			 * cause some unexpected behaviour and inconsistency between the DOM and the view.
 			 * This may be caused by:
 			 *
-			 * * calling {@link #change} or {@link #render} during rendering process,
-			 * * calling {@link #change} or {@link #render} inside of
+			 * * calling {@link #change} or {@link #forceRender} during rendering process,
+			 * * calling {@link #change} or {@link #forceRender} inside of
 			 *   {@link module:engine/view/document~Document#registerPostFixer post-fixer function}.
 			 *
 			 * @error cannot-change-view-tree
@@ -368,7 +390,8 @@ export default class View {
 
 		// This lock is used by editing controller to render changes from outer most model.change() once. As plugins might call
 		// view.change() inside model.change() block - this will ensures that postfixers and rendering are called once after all changes.
-		if ( !this._renderingDisabled ) {
+		// Also, we don't need to render anything if there're no changes since last rendering.
+		if ( !this._renderingDisabled && this._hasChangedSinceTheLastRendering ) {
 			this._postFixersInProgress = true;
 			this.document._callPostFixers( this._writer );
 			this._postFixersInProgress = false;
@@ -380,13 +403,17 @@ export default class View {
 	}
 
 	/**
-	 * Renders {@link module:engine/view/document~Document view document} to DOM. If any view changes are
+	 * Forces rendering {@link module:engine/view/document~Document view document} to DOM. If any view changes are
 	 * currently in progress, rendering will start after all {@link #change change blocks} are processed.
 	 *
+	 * Note that this method is dedicated for special cases. All view changes should be wrapped in the {@link #change}
+	 * block and the view will automatically check whether it needs to render DOM or not.
+	 *
 	 * Throws {@link module:utils/ckeditorerror~CKEditorError CKEditorError} `applying-view-changes-on-rendering` when
 	 * trying to re-render when rendering to DOM has already started.
 	 */
-	render() {
+	forceRender() {
+		this._hasChangedSinceTheLastRendering = true;
 		this.change( () => {} );
 	}
 
@@ -543,6 +570,22 @@ export default class View {
 	}
 
 	/**
+	 * Disables or enables rendering. If the flag is set to `true` then the rendering will be disabled.
+	 * If the flag is set to `false` and if there was some change in the meantime, then the rendering action will be performed.
+	 *
+	 * @protected
+	 * @param {Boolean} flag A flag indicates whether the rendering should be disabled.
+	 */
+	_disableRendering( flag ) {
+		this._renderingDisabled = flag;
+
+		if ( flag == false ) {
+			// Render when you stop blocking rendering.
+			this.change( () => {} );
+		}
+	}
+
+	/**
 	 * Renders all changes. In order to avoid triggering the observers (e.g. mutations) all observers are disabled
 	 * before rendering and re-enabled after that.
 	 *

+ 1 - 1
packages/ckeditor5-engine/tests/controller/editingcontroller.js

@@ -186,7 +186,7 @@ describe( 'EditingController', () => {
 			} );
 
 			editing.view.document.isFocused = true;
-			editing.view.render();
+			editing.view.forceRender();
 
 			const domSelection = document.getSelection();
 			domSelection.removeAllRanges();

+ 5 - 3
packages/ckeditor5-engine/tests/tickets/1653.js

@@ -9,7 +9,7 @@ import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictest
 import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 
 describe( 'Bug ckeditor5-engine#1653', () => {
-	it( '`DataController.parse()` should not invoke `editing.view.render()`', () => {
+	it( '`DataController.parse()` should not fire `editing.view#render`', () => {
 		let editor;
 
 		const element = document.createElement( 'div' );
@@ -20,10 +20,12 @@ describe( 'Bug ckeditor5-engine#1653', () => {
 			.then( newEditor => {
 				editor = newEditor;
 
-				const spy = sinon.spy( editor.editing.view, 'render' );
+				const editingViewSpy = sinon.spy();
+
+				editor.editing.view.on( 'fire', editingViewSpy );
 				editor.data.parse( '<p></p>' );
 
-				sinon.assert.notCalled( spy );
+				sinon.assert.notCalled( editingViewSpy );
 			} )
 			.then( () => {
 				element.remove();

+ 1 - 1
packages/ckeditor5-engine/tests/view/manual/immutable.js

@@ -24,7 +24,7 @@ setData( view,
 viewDocument.on( 'selectionChange', () => {
 	// Re-render view selection each time selection is changed.
 	// See https://github.com/ckeditor/ckeditor5-engine/issues/796.
-	view.render();
+	view.forceRender();
 } );
 
 view.focus();

+ 1 - 1
packages/ckeditor5-engine/tests/view/manual/noselection.js

@@ -17,7 +17,7 @@ view.attachDomRoot( document.getElementById( 'editor' ) );
 viewDocument.on( 'selectionChange', () => {
 	// Re-render view selection each time selection is changed.
 	// See https://github.com/ckeditor/ckeditor5-engine/issues/796.
-	view.render();
+	view.forceRender();
 } );
 
 setData( view,

+ 1 - 1
packages/ckeditor5-engine/tests/view/observer/domeventobserver.js

@@ -183,7 +183,7 @@ describe( 'DomEventObserver', () => {
 			view.attachDomRoot( domRoot );
 			uiElement = createUIElement( 'p' );
 			viewRoot._appendChild( uiElement );
-			view.render();
+			view.forceRender();
 
 			domEvent = new MouseEvent( 'click', { bubbles: true } );
 			evtSpy = sinon.spy();

+ 8 - 5
packages/ckeditor5-engine/tests/view/observer/focusobserver.js

@@ -58,7 +58,8 @@ describe( 'FocusObserver', () => {
 		} );
 
 		it( 'should render document after blurring', () => {
-			const renderSpy = sinon.spy( view, 'render' );
+			const renderSpy = sinon.spy();
+			view.on( 'render', renderSpy );
 
 			observer.onDomEvent( { type: 'blur', target: document.body } );
 
@@ -122,7 +123,8 @@ describe( 'FocusObserver', () => {
 		} );
 
 		it( 'should delay rendering by 50ms', () => {
-			const renderSpy = sinon.spy( view, 'render' );
+			const renderSpy = sinon.spy();
+			view.on( 'render', renderSpy );
 			const clock = sinon.useFakeTimers();
 
 			observer.onDomEvent( { type: 'focus', target: domMain } );
@@ -134,7 +136,8 @@ describe( 'FocusObserver', () => {
 		} );
 
 		it( 'should not call render if destroyed', () => {
-			const renderSpy = sinon.spy( view, 'render' );
+			const renderSpy = sinon.spy();
+			view.on( 'render', renderSpy );
 			const clock = sinon.useFakeTimers();
 
 			observer.onDomEvent( { type: 'focus', target: domMain } );
@@ -167,7 +170,7 @@ describe( 'FocusObserver', () => {
 			const renderSpy = sinon.spy();
 
 			setData( view, '<div contenteditable="true">foo bar</div>' );
-			view.render();
+			view.forceRender();
 
 			viewDocument.on( 'selectionChange', selectionChangeSpy );
 			view.on( 'render', renderSpy );
@@ -188,7 +191,7 @@ describe( 'FocusObserver', () => {
 			const renderSpy = sinon.spy();
 
 			setData( view, '<div contenteditable="true">foo bar</div>' );
-			view.render();
+			view.forceRender();
 			const domEditable = domRoot.childNodes[ 0 ];
 
 			viewDocument.on( 'selectionChange', selectionChangeSpy );

+ 12 - 12
packages/ckeditor5-engine/tests/view/observer/mutationobserver.js

@@ -39,7 +39,7 @@ describe( 'MutationObserver', () => {
 
 		viewRoot._appendChild( parse( '<container:p>foo</container:p><container:p>bar</container:p>' ) );
 
-		view.render();
+		view.forceRender();
 	} );
 
 	afterEach( () => {
@@ -98,7 +98,7 @@ describe( 'MutationObserver', () => {
 	it( 'should handle unbold', () => {
 		viewRoot._removeChildren( 0, viewRoot.childCount );
 		viewRoot._appendChild( parse( '<container:p><attribute:b>foo</attribute:b></container:p>' ) );
-		view.render();
+		view.forceRender();
 
 		const domP = domEditor.childNodes[ 0 ];
 		const domB = domP.childNodes[ 0 ];
@@ -207,7 +207,7 @@ describe( 'MutationObserver', () => {
 			parse( '<container:p>foo</container:p><container:p>bar</container:p>' ) );
 
 		// Render AdditionalEditor (first editor has been rendered in the beforeEach function)
-		view.render();
+		view.forceRender();
 
 		domEditor.childNodes[ 0 ].childNodes[ 0 ].data = 'foom';
 		domAdditionalEditor.childNodes[ 0 ].childNodes[ 0 ].data = 'foom';
@@ -378,7 +378,7 @@ describe( 'MutationObserver', () => {
 	it( 'should have no block filler in mutation', () => {
 		viewRoot._appendChild( parse( '<container:p></container:p>' ) );
 
-		view.render();
+		view.forceRender();
 
 		const domP = domEditor.childNodes[ 2 ];
 		domP.removeChild( domP.childNodes[ 0 ] );
@@ -397,7 +397,7 @@ describe( 'MutationObserver', () => {
 	it( 'should ignore mutation with bogus br inserted on the end of the empty paragraph', () => {
 		viewRoot._appendChild( parse( '<container:p></container:p>' ) );
 
-		view.render();
+		view.forceRender();
 
 		const domP = domEditor.childNodes[ 2 ];
 		domP.appendChild( document.createElement( 'br' ) );
@@ -410,7 +410,7 @@ describe( 'MutationObserver', () => {
 	it( 'should ignore mutation with bogus br inserted on the end of the paragraph with text', () => {
 		viewRoot._appendChild( parse( '<container:p>foo</container:p>' ) );
 
-		view.render();
+		view.forceRender();
 
 		const domP = domEditor.childNodes[ 2 ];
 		domP.appendChild( document.createElement( 'br' ) );
@@ -423,7 +423,7 @@ describe( 'MutationObserver', () => {
 	it( 'should ignore mutation with bogus br inserted on the end of the paragraph while processing text mutations', () => {
 		viewRoot._appendChild( parse( '<container:p>foo</container:p>' ) );
 
-		view.render();
+		view.forceRender();
 
 		const domP = domEditor.childNodes[ 2 ];
 		domP.childNodes[ 0 ].data = 'foo ';
@@ -440,7 +440,7 @@ describe( 'MutationObserver', () => {
 	it( 'should ignore child mutations which resulted in no changes – when element contains elements', () => {
 		viewRoot._appendChild( parse( '<container:p><container:x></container:x></container:p>' ) );
 
-		view.render();
+		view.forceRender();
 
 		const domP = domEditor.childNodes[ 2 ];
 		const domY = document.createElement( 'y' );
@@ -474,7 +474,7 @@ describe( 'MutationObserver', () => {
 	it( 'should not ignore mutation with br inserted not on the end of the paragraph', () => {
 		viewRoot._appendChild( parse( '<container:p>foo</container:p>' ) );
 
-		view.render();
+		view.forceRender();
 
 		const domP = domEditor.childNodes[ 2 ];
 		domP.insertBefore( document.createElement( 'br' ), domP.childNodes[ 0 ] );
@@ -493,7 +493,7 @@ describe( 'MutationObserver', () => {
 	it( 'should not ignore mutation inserting element different than br on the end of the empty paragraph', () => {
 		viewRoot._appendChild( parse( '<container:p></container:p>' ) );
 
-		view.render();
+		view.forceRender();
 
 		const domP = domEditor.childNodes[ 2 ];
 		domP.appendChild( document.createElement( 'span' ) );
@@ -511,7 +511,7 @@ describe( 'MutationObserver', () => {
 	it( 'should not ignore mutation inserting element different than br on the end of the paragraph with text', () => {
 		viewRoot._appendChild( parse( '<container:p>foo</container:p>' ) );
 
-		view.render();
+		view.forceRender();
 
 		const domP = domEditor.childNodes[ 2 ];
 		domP.appendChild( document.createElement( 'span' ) );
@@ -545,7 +545,7 @@ describe( 'MutationObserver', () => {
 			const uiElement = createUIElement( 'div' );
 			viewRoot._appendChild( uiElement );
 
-			view.render();
+			view.forceRender();
 		} );
 
 		it( 'should not collect text mutations from UIElement', () => {

+ 3 - 2
packages/ckeditor5-engine/tests/view/observer/selectionobserver.js

@@ -311,6 +311,7 @@ describe( 'SelectionObserver', () => {
 		const domParagraph = domMain.childNodes[ 0 ];
 		const domText = domParagraph.childNodes[ 0 ];
 		const domUI = domParagraph.childNodes[ 1 ];
+		const viewRenderSpy = sinon.spy();
 
 		// Add rendering on selectionChange event to check this feature.
 		viewDocument.on( 'selectionChange', () => {
@@ -330,7 +331,7 @@ describe( 'SelectionObserver', () => {
 
 			selectionObserver.listenTo( domDocument, 'selectionchange', () => {
 				// 4. Check if view was re-rendered.
-				expect( view.render.called ).to.be.true;
+				sinon.assert.calledOnce( viewRenderSpy );
 
 				done();
 			}, { priority: 'lowest' } );
@@ -339,7 +340,7 @@ describe( 'SelectionObserver', () => {
 			// Current and new selection position are similar in view (but not equal!).
 			// Also add a spy to `viewDocument#render` to see if view will be re-rendered.
 			sel.collapse( domUI, 0 );
-			sinon.spy( view, 'render' );
+			view.on( 'render', viewRenderSpy );
 
 			// Some browsers like Safari won't allow to put selection inside empty ui element.
 			// In that situation selection should stay in correct place.

+ 2 - 2
packages/ckeditor5-engine/tests/view/placeholder.js

@@ -84,7 +84,7 @@ describe( 'placeholder', () => {
 			expect( element.hasClass( 'ck-placeholder' ) ).to.be.true;
 
 			result = false;
-			view.render();
+			view.forceRender();
 			expect( element.hasClass( 'ck-placeholder' ) ).to.be.false;
 		} );
 
@@ -122,7 +122,7 @@ describe( 'placeholder', () => {
 			attachPlaceholder( view, element, 'foo bar baz' );
 			setData( view, '<p>paragraph</p>' );
 
-			view.render();
+			view.forceRender();
 		} );
 
 		it( 'should allow to add placeholder to elements from different documents', () => {

+ 10 - 10
packages/ckeditor5-engine/tests/view/renderer.js

@@ -3224,7 +3224,7 @@ describe( 'Renderer', () => {
 			);
 
 			// Render it to DOM to create initial DOM <-> view mappings.
-			view.render();
+			view.forceRender();
 
 			// Unwrap italic attribute element.
 			view.change( writer => {
@@ -3234,7 +3234,7 @@ describe( 'Renderer', () => {
 			expect( getViewData( view ) ).to.equal( '<p>[<strong>foo</strong>]</p>' );
 
 			// Re-render changes in view to DOM.
-			view.render();
+			view.forceRender();
 
 			// Check if DOM is rendered correctly.
 			expect( normalizeHtml( domRoot.innerHTML ) ).to.equal( '<p><strong>foo</strong></p>' );
@@ -3250,7 +3250,7 @@ describe( 'Renderer', () => {
 				'</container:p>' );
 
 			// Render it to DOM to create initial DOM <-> view mappings.
-			view.render();
+			view.forceRender();
 
 			// Unwrap italic attribute element and change text inside.
 			view.change( writer => {
@@ -3261,7 +3261,7 @@ describe( 'Renderer', () => {
 			expect( getViewData( view ) ).to.equal( '<p>[<strong>bar</strong>]</p>' );
 
 			// Re-render changes in view to DOM.
-			view.render();
+			view.forceRender();
 
 			// Check if DOM is rendered correctly.
 			expect( normalizeHtml( domRoot.innerHTML ) ).to.equal( '<p><strong>bar</strong></p>' );
@@ -3274,7 +3274,7 @@ describe( 'Renderer', () => {
 			);
 
 			// Render it to DOM to create initial DOM <-> view mappings.
-			view.render();
+			view.forceRender();
 
 			// Change text and insert new element into paragraph.
 			const textNode = viewRoot.getChild( 0 ).getChild( 0 );
@@ -3287,7 +3287,7 @@ describe( 'Renderer', () => {
 			expect( getViewData( view ) ).to.equal( '<p>foobar<img></img></p>' );
 
 			// Re-render changes in view to DOM.
-			view.render();
+			view.forceRender();
 
 			// Check if DOM is rendered correctly.
 			expect( normalizeHtml( domRoot.innerHTML ) ).to.equal( '<p>foobar<img></img></p>' );
@@ -3300,7 +3300,7 @@ describe( 'Renderer', () => {
 			);
 
 			// Render it to DOM to create initial DOM <-> view mappings.
-			view.render();
+			view.forceRender();
 
 			// Change text and insert new element into paragraph.
 			const textNode = viewRoot.getChild( 0 ).getChild( 0 );
@@ -3313,7 +3313,7 @@ describe( 'Renderer', () => {
 			expect( getViewData( view ) ).to.equal( '<p><img></img>foobar</p>' );
 
 			// Re-render changes in view to DOM.
-			view.render();
+			view.forceRender();
 
 			// Check if DOM is rendered correctly.
 			expect( normalizeHtml( domRoot.innerHTML ) ).to.equal( '<p><img></img>foobar</p>' );
@@ -3330,7 +3330,7 @@ describe( 'Renderer', () => {
 			);
 
 			// Render it to DOM to create initial DOM <-> view mappings.
-			view.render();
+			view.forceRender();
 
 			// Remove first element and reinsert it at the end.
 			const container = viewRoot.getChild( 0 );
@@ -3344,7 +3344,7 @@ describe( 'Renderer', () => {
 			expect( getViewData( view ) ).to.equal( '<p><i></i><span></span><b></b></p>' );
 
 			// Re-render changes in view to DOM.
-			view.render();
+			view.forceRender();
 
 			// Check if DOM is rendered correctly.
 			expect( normalizeHtml( domRoot.innerHTML ) ).to.equal( '<p><i></i><span></span><b></b></p>' );

+ 3 - 3
packages/ckeditor5-engine/tests/view/view/jumpoverinlinefiller.js

@@ -42,7 +42,7 @@ describe( 'View', () => {
 	describe( 'jump over inline filler hack', () => {
 		it( 'should jump over inline filler when left arrow is pressed after inline filler', () => {
 			setData( view, '<container:p>foo<attribute:b>[]</attribute:b>bar</container:p>' );
-			view.render();
+			view.forceRender();
 
 			viewDocument.fire( 'keydown', { keyCode: keyCodes.arrowleft, domTarget: view.domRoots.get( 'main' ) } );
 
@@ -64,7 +64,7 @@ describe( 'View', () => {
 
 		it( 'should do nothing when another key is pressed', () => {
 			setData( view, '<container:p>foo<attribute:b>[]</attribute:b>bar</container:p>' );
-			view.render();
+			view.forceRender();
 
 			viewDocument.fire( 'keydown', { keyCode: keyCodes.arrowright, domTarget: view.domRoots.get( 'main' ) } );
 
@@ -77,7 +77,7 @@ describe( 'View', () => {
 
 		it( 'should do nothing if range is not collapsed', () => {
 			setData( view, '<container:p>foo<attribute:b>{x}</attribute:b>bar</container:p>' );
-			view.render();
+			view.forceRender();
 
 			viewDocument.fire( 'keydown', { keyCode: keyCodes.arrowleft, domTarget: view.domRoots.get( 'main' ) } );
 

+ 1 - 1
packages/ckeditor5-engine/tests/view/view/jumpoveruielement.js

@@ -62,7 +62,7 @@ describe( 'View', () => {
 	} );
 
 	function renderAndFireKeydownEvent( options ) {
-		view.render();
+		view.forceRender();
 
 		const eventData = Object.assign( { keyCode: keyCodes.arrowright, domTarget: view.domRoots.get( 'main' ) }, options );
 		viewDocument.fire( 'keydown', eventData );

+ 60 - 30
packages/ckeditor5-engine/tests/view/view/view.js

@@ -204,7 +204,7 @@ describe( 'view', () => {
 
 		it( 'should be disabled and re-enabled on render', () => {
 			const observerMock = view.addObserver( ObserverMock );
-			view.render();
+			view.forceRender();
 
 			sinon.assert.calledOnce( observerMock.disable );
 			sinon.assert.calledOnce( view._renderer.render );
@@ -327,7 +327,7 @@ describe( 'view', () => {
 
 		it( 'should focus editable with selection', () => {
 			const converterFocusSpy = testUtils.sinon.spy( view.domConverter, 'focus' );
-			const renderSpy = testUtils.sinon.spy( view, 'render' );
+			const renderSpy = testUtils.sinon.spy( view, 'forceRender' );
 
 			view.focus();
 
@@ -344,7 +344,7 @@ describe( 'view', () => {
 
 		it( 'should not focus if document is already focused', () => {
 			const converterFocusSpy = testUtils.sinon.spy( view.domConverter, 'focus' );
-			const renderSpy = testUtils.sinon.spy( view, 'render' );
+			const renderSpy = testUtils.sinon.spy( view, 'forceRender' );
 			viewDocument.isFocused = true;
 
 			view.focus();
@@ -377,28 +377,41 @@ describe( 'view', () => {
 		} );
 	} );
 
-	describe( 'render()', () => {
+	describe( 'forceRender()', () => {
 		it( 'disable observers, renders and enable observers', () => {
 			const observerMock = view.addObserver( ObserverMock );
 			const renderStub = sinon.stub( view._renderer, 'render' );
 
-			view.render();
+			view.forceRender();
 
 			sinon.assert.callOrder( observerMock.disable, renderStub, observerMock.enable );
 		} );
 
-		it( 'should fire view.document.layoutChanged event on render', () => {
-			const spy = sinon.spy();
+		it( 'should fire `render` and `layoutChanged` even if there were no changes', () => {
+			const renderSpy = sinon.spy();
+			const layoutChangedSpy = sinon.spy();
 
-			view.document.on( 'layoutChanged', spy );
+			view.on( 'render', renderSpy );
+			view.document.on( 'layoutChanged', layoutChangedSpy );
 
-			view.render();
+			view.forceRender();
 
-			sinon.assert.calledOnce( spy );
+			sinon.assert.calledOnce( renderSpy );
+			sinon.assert.calledOnce( layoutChangedSpy );
+		} );
+
+		it( 'should fire `render` and `layoutChanged` if there is some buffered change', () => {
+			const renderSpy = sinon.spy();
+			const layoutChangedSpy = sinon.spy();
 
-			view.render();
+			view.on( 'render', renderSpy );
+			view.document.on( 'layoutChanged', layoutChangedSpy );
 
-			sinon.assert.calledTwice( spy );
+			view.document.selection._setTo( null );
+			view.forceRender();
+
+			sinon.assert.calledOnce( renderSpy );
+			sinon.assert.calledOnce( layoutChangedSpy );
 		} );
 	} );
 
@@ -414,7 +427,7 @@ describe( 'view', () => {
 
 			createViewRoot( viewDocument, 'div', 'main' );
 			view.attachDomRoot( domDiv );
-			view.render();
+			view.forceRender();
 
 			expect( domDiv.childNodes.length ).to.equal( 1 );
 			expect( isBlockFiller( domDiv.childNodes[ 0 ], BR_FILLER ) ).to.be.true;
@@ -432,7 +445,7 @@ describe( 'view', () => {
 			view.attachDomRoot( domDiv );
 
 			viewDocument.getRoot()._appendChild( new ViewElement( 'p' ) );
-			view.render();
+			view.forceRender();
 
 			expect( domDiv.childNodes.length ).to.equal( 1 );
 			expect( domDiv.childNodes[ 0 ].tagName ).to.equal( 'P' );
@@ -451,13 +464,13 @@ describe( 'view', () => {
 
 			const viewP = new ViewElement( 'p', { class: 'foo' } );
 			viewRoot._appendChild( viewP );
-			view.render();
+			view.forceRender();
 
 			expect( domRoot.childNodes.length ).to.equal( 1 );
 			expect( domRoot.childNodes[ 0 ].getAttribute( 'class' ) ).to.equal( 'foo' );
 
 			viewP._setAttribute( 'class', 'bar' );
-			view.render();
+			view.forceRender();
 
 			expect( domRoot.childNodes.length ).to.equal( 1 );
 			expect( domRoot.childNodes[ 0 ].getAttribute( 'class' ) ).to.equal( 'bar' );
@@ -503,7 +516,7 @@ describe( 'view', () => {
 				} ).to.throw( CKEditorError, /^cannot-change-view-tree/ );
 			} );
 
-			view.render();
+			view.forceRender();
 			domDiv.remove();
 		} );
 
@@ -513,7 +526,9 @@ describe( 'view', () => {
 
 			view.on( 'render', eventSpy );
 
-			view.change( () => {} );
+			view.change( writer => {
+				writer.setSelection( null );
+			} );
 
 			sinon.assert.callOrder( renderSpy, eventSpy );
 		} );
@@ -521,16 +536,24 @@ describe( 'view', () => {
 		it( 'should fire render event once for nested change blocks', () => {
 			const renderSpy = sinon.spy( view._renderer, 'render' );
 			const eventSpy = sinon.spy();
+			const viewEditable = createViewRoot( viewDocument, 'div', 'main' );
 
 			view.on( 'render', eventSpy );
 
-			view.change( () => {
-				view.change( () => {} );
-				view.change( () => {
-					view.change( () => {} );
-					view.change( () => {} );
+			view.change( writer => {
+				writer.setSelection( null );
+				view.change( writer => {
+					writer.setSelection( viewEditable, 0 );
+				} );
+				view.change( writer => {
+					writer.setSelection( null );
+					view.change( writer => {
+						writer.setSelection( viewEditable, 0 );
+					} );
+				} );
+				view.change( writer => {
+					writer.setSelection( null );
 				} );
-				view.change( () => {} );
 			} );
 
 			sinon.assert.calledOnce( renderSpy );
@@ -545,11 +568,12 @@ describe( 'view', () => {
 			view.on( 'render', eventSpy );
 
 			view.change( () => {
-				view.render();
-				view.change( () => {
-					view.render();
+				view.forceRender();
+				view.change( writer => {
+					writer.setSelection( null );
+					view.forceRender();
 				} );
-				view.render();
+				view.forceRender();
 			} );
 
 			sinon.assert.calledOnce( renderSpy );
@@ -567,7 +591,10 @@ describe( 'view', () => {
 			viewDocument.registerPostFixer( postFixer2 );
 			view.on( 'render', eventSpy );
 
-			view.change( changeSpy );
+			view.change( writer => {
+				changeSpy();
+				writer.setSelection( null );
+			} );
 
 			sinon.assert.calledOnce( postFixer1 );
 			sinon.assert.calledOnce( postFixer2 );
@@ -598,7 +625,10 @@ describe( 'view', () => {
 			} );
 			view.on( 'render', eventSpy );
 
-			view.change( changeSpy );
+			view.change( writer => {
+				changeSpy();
+				writer.setSelection( null );
+			} );
 
 			sinon.assert.calledOnce( postFixer1 );
 			sinon.assert.calledOnce( postFixer2 );