浏览代码

Merge branch 'master' into t/ckeditor5-table/12

Piotrek Koszuliński 7 年之前
父节点
当前提交
3de5f8555c
共有 2 个文件被更改,包括 1089 次插入71 次删除
  1. 263 71
      packages/ckeditor5-engine/src/view/renderer.js
  2. 826 0
      packages/ckeditor5-engine/tests/view/renderer.js

+ 263 - 71
packages/ckeditor5-engine/src/view/renderer.js

@@ -18,20 +18,21 @@ import remove from '@ckeditor/ckeditor5-utils/src/dom/remove';
 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 isNode from '@ckeditor/ckeditor5-utils/src/dom/isnode';
 import fastDiff from '@ckeditor/ckeditor5-utils/src/fastdiff';
 
 /**
- * Renderer updates DOM structure and selection, to make them a reflection of the view structure and selection.
+ * Renderer is responsible for updating the DOM structure and the DOM selection based on
+ * the {@link module:engine/view/renderer~Renderer#markToSync information about updated view nodes}.
+ * In other words, it renders the view to the DOM.
  *
- * View nodes which may need to be rendered needs to be {@link module:engine/view/renderer~Renderer#markToSync marked}.
- * Then, on {@link module:engine/view/renderer~Renderer#render render}, renderer compares view nodes with DOM nodes
- * in order to check which ones really need to be refreshed. Finally, it creates DOM nodes from these view nodes,
- * {@link module:engine/view/domconverter~DomConverter#bindElements binds} them and inserts into the DOM tree.
+ * Its main responsibility is to make only the necessary, minimal changes to the DOM. However, unlike in many
+ * virtual DOM implementations, the primary reason for doing minimal changes is not the performance but ensuring
+ * that native editing features such as text composition, autocompletion, spell checking, selection's x-index are
+ * affected as little as possible.
  *
- * Every time {@link module:engine/view/renderer~Renderer#render render} is called, renderer additionally checks if
- * {@link module:engine/view/renderer~Renderer#selection selection} needs update and updates it if so.
- *
- * Renderer uses {@link module:engine/view/domconverter~DomConverter} to transform and bind nodes.
+ * Renderer uses {@link module:engine/view/domconverter~DomConverter} to transform view nodes and positions
+ * to and from the DOM.
  */
 export default class Renderer {
 	/**
@@ -90,14 +91,6 @@ export default class Renderer {
 		this.selection = selection;
 
 		/**
-		 * The text node in which the inline filler was rendered.
-		 *
-		 * @private
-		 * @member {Text}
-		 */
-		this._inlineFiller = null;
-
-		/**
 		 * Indicates if the view document is focused and selection can be rendered. Selection will not be rendered if
 		 * this is set to `false`.
 		 *
@@ -106,6 +99,14 @@ export default class Renderer {
 		this.isFocused = false;
 
 		/**
+		 * The text node in which the inline filler was rendered.
+		 *
+		 * @private
+		 * @member {Text}
+		 */
+		this._inlineFiller = null;
+
+		/**
 		 * DOM element containing fake selection.
 		 *
 		 * @private
@@ -115,7 +116,7 @@ export default class Renderer {
 	}
 
 	/**
-	 * Mark node to be synchronized.
+	 * Marks a view node to be updated in the DOM by {@link #render `render()`}.
 	 *
 	 * Note that only view nodes which parents have corresponding DOM elements need to be marked to be synchronized.
 	 *
@@ -154,32 +155,24 @@ export default class Renderer {
 	}
 
 	/**
-	 * Render method checks {@link #markedAttributes},
-	 * {@link #markedChildren} and {@link #markedTexts} and updates all
-	 * nodes which need to be updated. Then it clears all three sets. Also, every time render is called it compares and
-	 * if needed updates the selection.
+	 * Renders all buffered changes ({@link #markedAttributes}, {@link #markedChildren} and {@link #markedTexts}) and
+	 * the current view selection (if needed) to the DOM by applying a minimal set of changes to it.
 	 *
-	 * Renderer tries not to break text composition (e.g. IME) and x-index of the selection,
+	 * Renderer tries not to break the text composition (e.g. IME) and x-index of the selection,
 	 * so it does as little as it is needed to update the DOM.
 	 *
-	 * For attributes it adds new attributes to DOM elements, updates values and removes
-	 * attributes which do not exist in the view element.
-	 *
-	 * For text nodes it updates the text string if it is different. Note that if parent element is marked as an element
-	 * which changed child list, text node update will not be done, because it may not be possible to
-	 * {@link module:engine/view/domconverter~DomConverter#findCorrespondingDomText find a corresponding DOM text}.
-	 * The change will be handled in the parent element.
-	 *
-	 * For elements, which child lists have changed, it calculates a {@link module:utils/diff~diff} and adds or removes children which have
-	 * changed.
-	 *
-	 * Rendering also handles {@link module:engine/view/filler fillers}. Especially, it checks if the inline filler is needed
-	 * at selection position and adds or removes it. To prevent breaking text composition inline filler will not be
+	 * Renderer also handles {@link module:engine/view/filler fillers}. Especially, it checks if the inline filler is needed
+	 * at the selection position and adds or removes it. To prevent breaking text composition inline filler will not be
 	 * removed as long selection is in the text node which needed it at first.
 	 */
 	render() {
 		let inlineFillerPosition;
 
+		// Refresh mappings.
+		for ( const element of this.markedChildren ) {
+			this._updateChildrenMappings( element );
+		}
+
 		// There was inline filler rendered in the DOM but it's not
 		// at the selection position any more, so we can remove it
 		// (cause even if it's needed, it must be placed in another location).
@@ -244,6 +237,84 @@ export default class Renderer {
 	}
 
 	/**
+	 * Updates mappings of `viewElement`'s children.
+	 *
+	 * Children which were replaced in the view structure by similar elements (same tag name) are treated as 'replaced'.
+	 * This means that we can update their mappings so the new view elements are mapped to the existing DOM elements.
+	 * Thanks to that we won't need to re-render these elements completely.
+	 *
+	 * @private
+	 * @param {module:engine/view/node~Node} viewElement The view element which children mappings will be updated.
+	 */
+	_updateChildrenMappings( viewElement ) {
+		const domElement = this.domConverter.mapViewToDom( viewElement );
+
+		if ( !domElement ) {
+			// If there is no `domElement` it means that it was already removed from DOM and there is no need to process it.
+			return;
+		}
+
+		const diff = this._diffChildren( viewElement );
+		const actions = this._findReplaceActions( diff.actions, diff.actualDomChildren, diff.expectedDomChildren );
+
+		if ( actions.indexOf( 'replace' ) !== -1 ) {
+			const counter = { equal: 0, insert: 0, delete: 0 };
+
+			for ( const action of actions ) {
+				if ( action === 'replace' ) {
+					const insertIndex = counter.equal + counter.insert;
+					const deleteIndex = counter.equal + counter.delete;
+					const viewChild = viewElement.getChild( insertIndex );
+
+					// 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' ) ) {
+						this._updateElementMappings( viewChild, diff.actualDomChildren[ deleteIndex ] );
+					}
+
+					remove( diff.expectedDomChildren[ insertIndex ] );
+					counter.equal++;
+				} else {
+					counter[ action ]++;
+				}
+			}
+		}
+	}
+
+	/**
+	 * Updates mappings of a given `viewElement`.
+	 *
+	 * @private
+	 * @param {module:engine/view/node~Node} viewElement The view element which mappings will be updated.
+	 * @param {Node} domElement The DOM element representing given view element.
+	 */
+	_updateElementMappings( viewElement, domElement ) {
+		// Because we replace new view element mapping with the existing one, the corresponding DOM element
+		// will not be rerendered. The new view element may have different attributes than the previous one.
+		// Since its corresponding DOM element will not be rerendered, new attributes will not be added
+		// to the DOM, so we need to mark it here to make sure its attributes gets updated.
+		// Such situations may happen if only new view element was added to `this.markedAttributes`
+		// or none of the elements were added (relying on 'this._updateChildren()' which by rerendering the element
+		// also rerenders its attributes). See #1427 for more detailed case study.
+		const newViewChild = this.domConverter.mapDomToView( domElement );
+
+		// It may also happen that 'newViewChild' mapping is not present since its parent mapping
+		// was already removed (the 'domConverter.unbindDomElement()' method also unbinds children
+		// mappings) so we also check for '!newViewChild'.
+		if ( !newViewChild || newViewChild && !newViewChild.isSimilar( viewElement ) ) {
+			this.markedAttributes.add( viewElement );
+		}
+
+		// Remap 'DomConverter' bindings.
+		this.domConverter.unbindDomElement( domElement );
+		this.domConverter.bindElements( domElement, viewElement );
+
+		// View element may have children which needs to be updated, but are not marked, mark them to update.
+		this.markedChildren.add( viewElement );
+	}
+
+	/**
 	 * Adds inline filler at given position.
 	 *
 	 * The position can be given as an array of DOM nodes and an offset in that array,
@@ -443,10 +514,19 @@ export default class Renderer {
 	 * Checks if attributes list needs to be updated and possibly updates it.
 	 *
 	 * @private
-	 * @param {module:engine/view/element~Element} viewElement View element to update.
+	 * @param {module:engine/view/element~Element} viewElement The view element to update.
 	 */
 	_updateAttrs( viewElement ) {
 		const domElement = this.domConverter.mapViewToDom( viewElement );
+
+		if ( !domElement ) {
+			// If there is no `domElement` it means that 'viewElement' is outdated as its mapping was updated
+			// in 'this._updateChildrenMappings()'. There is no need to process it as new view element which
+			// replaced old 'viewElement' mapping was also added to 'this.markedAttributes'
+			// in 'this._updateChildrenMappings()' so it will be processed separately.
+			return;
+		}
+
 		const domAttrKeys = Array.from( domElement.attributes ).map( attr => attr.name );
 		const viewAttrKeys = viewElement.getAttributeKeys();
 
@@ -473,33 +553,24 @@ export default class Renderer {
 	 * filler should be rendered.
 	 */
 	_updateChildren( viewElement, options ) {
-		const domConverter = this.domConverter;
-		const domElement = domConverter.mapViewToDom( viewElement );
+		const domElement = this.domConverter.mapViewToDom( viewElement );
 
 		if ( !domElement ) {
 			// If there is no `domElement` it means that it was already removed from DOM.
-			// There is no need to update it. It will be updated when re-inserted.
+			// There is no need to process it. It will be processed when re-inserted.
 			return;
 		}
 
-		const domDocument = domElement.ownerDocument;
-		const filler = options.inlineFillerPosition;
-		const actualDomChildren = domElement.childNodes;
-		const expectedDomChildren = Array.from( domConverter.viewChildrenToDom( viewElement, domDocument, { bind: true } ) );
-
-		// Inline filler element has to be created during children update because we need it to diff actual dom
-		// elements with expected dom elements. We need inline filler in expected dom elements so we won't re-render
-		// text node if it is not necessary.
-		if ( filler && filler.parent == viewElement ) {
-			this._addInlineFiller( domDocument, expectedDomChildren, filler.offset );
-		}
-
-		const actions = diff( actualDomChildren, expectedDomChildren, sameNodes );
+		const inlineFillerPosition = options.inlineFillerPosition;
+		// As binding may change actual DOM children we need to do this before diffing.
+		const expectedDomChildren = this._getElementExpectedChildren( viewElement, domElement, { bind: true, inlineFillerPosition } );
+		const diff = this._diffChildren( viewElement, inlineFillerPosition );
+		const actualDomChildren = diff.actualDomChildren;
 
 		let i = 0;
 		const nodesToUnbind = new Set();
 
-		for ( const action of actions ) {
+		for ( const action of diff.actions ) {
 			if ( action === 'insert' ) {
 				insertAt( domElement, i, expectedDomChildren[ i ] );
 				i++;
@@ -508,7 +579,7 @@ export default class Renderer {
 				remove( actualDomChildren[ i ] );
 			} else { // 'equal'
 				// Force updating text nodes inside elements which did not change and do not need to be re-rendered (#1125).
-				this._markDescendantTextToSync( domConverter.domToView( expectedDomChildren[ i ] ) );
+				this._markDescendantTextToSync( this.domConverter.domToView( expectedDomChildren[ i ] ) );
 				i++;
 			}
 		}
@@ -521,25 +592,102 @@ export default class Renderer {
 				this.domConverter.unbindDomElement( node );
 			}
 		}
+	}
 
-		function sameNodes( actualDomChild, expectedDomChild ) {
-			// Elements.
-			if ( actualDomChild === expectedDomChild ) {
-				return true;
-			}
-			// Texts.
-			else if ( isText( actualDomChild ) && isText( expectedDomChild ) ) {
-				return actualDomChild.data === expectedDomChild.data;
-			}
-			// Block fillers.
-			else if ( isBlockFiller( actualDomChild, domConverter.blockFiller ) &&
-				isBlockFiller( expectedDomChild, domConverter.blockFiller ) ) {
-				return true;
-			}
+	/**
+	 * Compares `viewElement`'s actual and expected children and returns actions sequence which can be used to transform
+	 * actual children into expected ones.
+	 *
+	 * @private
+	 * @param {module:engine/view/node~Node} viewElement The view element which children will be compared.
+	 * @param {module:engine/view/position~Position} [inlineFillerPosition=null] The position on which the inline
+	 * filler should be rendered.
+	 * @returns {Object|null} result
+	 * @returns {Array.<String>} result.actions List of actions based on {@link module:utils/diff~diff} function.
+	 * @returns {Array.<Node>} result.actualDomChildren Current `viewElement`'s DOM children.
+	 * @returns {Array.<Node>} result.expectedDomChildren Expected `viewElement`'s DOM children.
+	 */
+	_diffChildren( viewElement, inlineFillerPosition = null ) {
+		const domElement = this.domConverter.mapViewToDom( viewElement );
+		const actualDomChildren = domElement.childNodes;
+		const expectedDomChildren = this._getElementExpectedChildren( viewElement, domElement,
+			{ withChildren: false, inlineFillerPosition } );
+
+		return {
+			actions: diff( actualDomChildren, expectedDomChildren, sameNodes.bind( null, this.domConverter.blockFiller ) ),
+			actualDomChildren,
+			expectedDomChildren
+		};
+	}
 
-			// Not matching types.
-			return false;
+	/**
+	 * Returns expected DOM children for a given `viewElement`.
+	 *
+	 * @private
+	 * @param {module:engine/view/node~Node} viewElement View element which children will be returned.
+	 * @param {Node} domElement DOM representation of a given view element.
+	 * @param {Object} options See {@link module:engine/view/domconverter~DomConverter#viewToDom} options parameter.
+	 * @param {module:engine/view/position~Position} [options.inlineFillerPosition=null] The position on which
+	 * the inline filler should be rendered.
+	 * @returns {Array.<Node>} The `viewElement`'s expected children.
+	 */
+	_getElementExpectedChildren( viewElement, domElement, options ) {
+		const expectedDomChildren = Array.from( this.domConverter.viewChildrenToDom( viewElement, domElement.ownerDocument, options ) );
+		const filler = options.inlineFillerPosition;
+
+		// Inline filler element has to be created as it is present in a DOM, but not in a view. It is required
+		// during diffing so text nodes could be compared correctly and also during rendering to maintain
+		// proper order and indexes while updating the DOM.
+		if ( filler && filler.parent === viewElement ) {
+			this._addInlineFiller( domElement.ownerDocument, expectedDomChildren, filler.offset );
 		}
+
+		return expectedDomChildren;
+	}
+
+	/**
+	 * Finds DOM nodes which were replaced with the similar nodes (same tag name) in the view. All nodes are compared
+	 * within one `insert`/`delete` action group, for example:
+	 *
+	 * 		Actual DOM:		<p><b>Foo</b>Bar<i>Baz</i><b>Bax</b></p>
+	 * 		Expected DOM:	<p>Bar<b>123</b><i>Baz</i><b>456</b></p>
+	 * 		Input actions:	[ insert, insert, delete, delete, equal, insert, delete ]
+	 * 		Output actions:	[ insert, replace, delete, equal, replace ]
+	 *
+	 * @private
+	 * @param {Array.<String>} actions Actions array which is result of {@link module:utils/diff~diff} function.
+	 * @param {Array.<Node>} actualDom Actual DOM children
+	 * @param {Array.<Node>} expectedDom Expected DOM children.
+	 * @returns {Array.<String>} Actions array modified with `replace` actions.
+	 */
+	_findReplaceActions( actions, actualDom, expectedDom ) {
+		// If there is no both 'insert' and 'delete' actions, no need to check for replaced elements.
+		if ( actions.indexOf( 'insert' ) === -1 || actions.indexOf( 'delete' ) === -1 ) {
+			return actions;
+		}
+
+		let newActions = [];
+		let actualSlice = [];
+		let expectedSlice = [];
+
+		const counter = { equal: 0, insert: 0, delete: 0 };
+
+		for ( const action of actions ) {
+			if ( action === 'insert' ) {
+				expectedSlice.push( expectedDom[ counter.equal + counter.insert ] );
+			} else if ( action === 'delete' ) {
+				actualSlice.push( actualDom[ counter.equal + counter.delete ] );
+			} else { // equal
+				newActions = newActions.concat( diff( actualSlice, expectedSlice, areSimilar ).map( x => x === 'equal' ? 'replace' : x ) );
+				newActions.push( 'equal' );
+				// Reset stored elements on 'equal'.
+				actualSlice = [];
+				expectedSlice = [];
+			}
+			counter[ action ]++;
+		}
+
+		return newActions.concat( diff( actualSlice, expectedSlice, areSimilar ).map( x => x === 'equal' ? 'replace' : x ) );
 	}
 
 	/**
@@ -764,3 +912,47 @@ function _isEditable( element ) {
 
 	return !parent || parent.getAttribute( 'contenteditable' ) == 'true';
 }
+
+// Whether two DOM nodes should be considered as similar.
+// Nodes are considered similar if they have the same tag name.
+//
+// @private
+// @param {Node} node1
+// @param {Node} node2
+// @returns {Boolean}
+function areSimilar( node1, node2 ) {
+	return isNode( node1 ) && isNode( node2 ) &&
+		!isText( node1 ) && !isText( node2 ) &&
+		node1.tagName.toLowerCase() === node2.tagName.toLowerCase();
+}
+
+// Whether two dom nodes should be considered as the same.
+// Two nodes which are considered the same are:
+//
+//		* Text nodes with the same text.
+//		* Element nodes represented by the same object.
+//		* Two block filler elements.
+//
+// @private
+// @param {Function} blockFiller Block filler creator function, see {@link module:engine/view/domconverter~DomConverter#blockFiller}.
+// @param {Node} node1
+// @param {Node} node2
+// @returns {Boolean}
+function sameNodes( blockFiller, actualDomChild, expectedDomChild ) {
+	// Elements.
+	if ( actualDomChild === expectedDomChild ) {
+		return true;
+	}
+	// Texts.
+	else if ( isText( actualDomChild ) && isText( expectedDomChild ) ) {
+		return actualDomChild.data === expectedDomChild.data;
+	}
+	// Block fillers.
+	else if ( isBlockFiller( actualDomChild, blockFiller ) &&
+		isBlockFiller( expectedDomChild, blockFiller ) ) {
+		return true;
+	}
+
+	// Not matching types.
+	return false;
+}

+ 826 - 0
packages/ckeditor5-engine/tests/view/renderer.js

@@ -12,6 +12,7 @@ import ViewAttributeElement from '../../src/view/attributeelement';
 import ViewText from '../../src/view/text';
 import ViewRange from '../../src/view/range';
 import ViewPosition from '../../src/view/position';
+import UIElement from '../../src/view/uielement';
 import DocumentSelection from '../../src/view/documentselection';
 import DomConverter from '../../src/view/domconverter';
 import Renderer from '../../src/view/renderer';
@@ -2182,6 +2183,831 @@ describe( 'Renderer', () => {
 				expect( selectionExtendSpy.notCalled ).to.true;
 			} );
 		} );
+
+		// #1417
+		describe( 'optimal rendering', () => {
+			it( 'should render inline element replacement (before text)', () => {
+				viewRoot._appendChild( parse( '<container:p><attribute:i>A</attribute:i>1</container:p>' ) );
+
+				renderer.markToSync( 'children', viewRoot );
+				renderer.render();
+
+				expect( domRoot.innerHTML ).to.equal( '<p><i>A</i>1</p>' );
+
+				const viewP = viewRoot.getChild( 0 );
+				viewP._removeChildren( 0, 2 );
+				viewP._insertChild( 0, parse( '<attribute:i>B</attribute:i>2' ) );
+
+				const domI = domRoot.childNodes[ 0 ].childNodes[ 0 ];
+
+				renderer.markToSync( 'children', viewRoot );
+				renderer.markToSync( 'children', viewP );
+				renderer.render();
+
+				expect( domRoot.innerHTML ).to.equal( '<p><i>B</i>2</p>' );
+				expect( domI ).to.equal( domRoot.childNodes[ 0 ].childNodes[ 0 ] );
+			} );
+
+			it( 'should render inline element replacement (after text)', () => {
+				viewRoot._appendChild( parse( '<container:p>1<attribute:i>A</attribute:i></container:p>' ) );
+
+				renderer.markToSync( 'children', viewRoot );
+				renderer.render();
+
+				expect( domRoot.innerHTML ).to.equal( '<p>1<i>A</i></p>' );
+
+				const viewP = viewRoot.getChild( 0 );
+				viewP._removeChildren( 0, 2 );
+				viewP._insertChild( 0, parse( '2<attribute:i>B</attribute:i>' ) );
+
+				const domI = domRoot.childNodes[ 0 ].childNodes[ 1 ];
+
+				renderer.markToSync( 'children', viewRoot );
+				renderer.markToSync( 'children', viewP );
+				renderer.render();
+
+				expect( domRoot.innerHTML ).to.equal( '<p>2<i>B</i></p>' );
+				expect( domI ).to.equal( domRoot.childNodes[ 0 ].childNodes[ 1 ] );
+			} );
+
+			it( 'should render inline element replacement (before text swapped order)', () => {
+				viewRoot._appendChild( parse( '<container:p><attribute:i>A</attribute:i>1</container:p>' ) );
+
+				renderer.markToSync( 'children', viewRoot );
+				renderer.render();
+
+				expect( domRoot.innerHTML ).to.equal( '<p><i>A</i>1</p>' );
+
+				const viewP = viewRoot.getChild( 0 );
+				viewP._removeChildren( 0, 2 );
+				viewP._insertChild( 0, parse( '2<attribute:i>B</attribute:i>' ) );
+
+				const domI = domRoot.childNodes[ 0 ].childNodes[ 0 ];
+
+				renderer.markToSync( 'children', viewRoot );
+				renderer.markToSync( 'children', viewP );
+				renderer.render();
+
+				expect( domRoot.innerHTML ).to.equal( '<p>2<i>B</i></p>' );
+				expect( domI ).to.equal( domRoot.childNodes[ 0 ].childNodes[ 1 ] );
+			} );
+
+			it( 'should render inline element replacement (after text swapped order)', () => {
+				viewRoot._appendChild( parse( '<container:p>1<attribute:i>A</attribute:i></container:p>' ) );
+
+				renderer.markToSync( 'children', viewRoot );
+				renderer.render();
+
+				expect( domRoot.innerHTML ).to.equal( '<p>1<i>A</i></p>' );
+
+				const viewP = viewRoot.getChild( 0 );
+				viewP._removeChildren( 0, 2 );
+				viewP._insertChild( 0, parse( '<attribute:i>B</attribute:i>2' ) );
+
+				const domI = domRoot.childNodes[ 0 ].childNodes[ 1 ];
+
+				renderer.markToSync( 'children', viewRoot );
+				renderer.markToSync( 'children', viewP );
+				renderer.render();
+
+				expect( domRoot.innerHTML ).to.equal( '<p><i>B</i>2</p>' );
+				expect( domI ).to.equal( domRoot.childNodes[ 0 ].childNodes[ 0 ] );
+			} );
+
+			it( 'should render single replacement in p group', () => {
+				const content = '' +
+					'<container:p>1</container:p>' +
+					'<container:p>2</container:p>' +
+					'<container:p>3</container:p>';
+
+				viewRoot._appendChild( parse( content ) );
+
+				renderer.markToSync( 'children', viewRoot );
+				renderer.render();
+
+				expect( domRoot.innerHTML ).to.equal( '<p>1</p><p>2</p><p>3</p>' );
+
+				viewRoot._removeChildren( 1 );
+				viewRoot._insertChild( 1, parse( '<container:p>4</container:p>' ) );
+
+				const domP = domRoot.childNodes[ 1 ];
+
+				renderer.markToSync( 'children', viewRoot );
+				renderer.render();
+
+				expect( domRoot.innerHTML ).to.equal( '<p>1</p><p>4</p><p>3</p>' );
+				expect( domP ).to.equal( domRoot.childNodes[ 1 ] );
+			} );
+
+			it( 'should render replacement and insertion in p group', () => {
+				const content = '' +
+					'<container:p>1<attribute:i>A</attribute:i></container:p>' +
+					'<container:p>2<attribute:i>B</attribute:i></container:p>' +
+					'<container:p>3<attribute:i>C</attribute:i></container:p>';
+
+				const replacement = '' +
+					'<container:p><attribute:i>D</attribute:i></container:p>' +
+					'<container:p>5<attribute:i>E</attribute:i></container:p>';
+
+				viewRoot._appendChild( parse( content ) );
+
+				renderer.markToSync( 'children', viewRoot );
+				renderer.render();
+
+				expect( domRoot.innerHTML ).to.equal( '<p>1<i>A</i></p><p>2<i>B</i></p><p>3<i>C</i></p>' );
+
+				viewRoot._removeChildren( 1 );
+				viewRoot._insertChild( 1, parse( replacement ) );
+
+				const domP2 = domRoot.childNodes[ 1 ];
+				const domP3 = domRoot.childNodes[ 2 ];
+
+				renderer.markToSync( 'children', viewRoot );
+				renderer.render();
+
+				expect( domRoot.innerHTML ).to.equal( '<p>1<i>A</i></p><p><i>D</i></p><p>5<i>E</i></p><p>3<i>C</i></p>' );
+				expect( domP2 ).to.equal( domRoot.childNodes[ 1 ] );
+				expect( domP3 ).to.equal( domRoot.childNodes[ 3 ] );
+			} );
+
+			it( 'should render replacement and deletion in p group', () => {
+				const content = '' +
+					'<container:p><attribute:i>A</attribute:i>1</container:p>' +
+					'<container:p><attribute:i>B</attribute:i>2</container:p>' +
+					'<container:p><attribute:i>C</attribute:i>3</container:p>';
+
+				viewRoot._appendChild( parse( content ) );
+
+				renderer.markToSync( 'children', viewRoot );
+				renderer.render();
+
+				expect( domRoot.innerHTML ).to.equal( '<p><i>A</i>1</p><p><i>B</i>2</p><p><i>C</i>3</p>' );
+
+				viewRoot._removeChildren( 0, 2 );
+				viewRoot._insertChild( 0, parse( '<container:p>4</container:p>' ) );
+
+				const domP0 = domRoot.childNodes[ 0 ];
+				const domP2 = domRoot.childNodes[ 2 ];
+
+				renderer.markToSync( 'children', viewRoot );
+				renderer.render();
+
+				expect( domRoot.innerHTML ).to.equal( '<p>4</p><p><i>C</i>3</p>' );
+				expect( domP0 ).to.equal( domRoot.childNodes[ 0 ] );
+				expect( domP2 ).to.equal( domRoot.childNodes[ 1 ] );
+			} );
+
+			it( 'should render multiple continuous replacement in p group', () => {
+				const content = '' +
+					'<container:p>1</container:p>' +
+					'<container:p>2</container:p>' +
+					'<container:p>3</container:p>' +
+					'<container:p>4</container:p>' +
+					'<container:p>5</container:p>';
+
+				viewRoot._appendChild( parse( content ) );
+
+				renderer.markToSync( 'children', viewRoot );
+				renderer.render();
+
+				expect( domRoot.innerHTML ).to.equal( '<p>1</p><p>2</p><p>3</p><p>4</p><p>5</p>' );
+
+				viewRoot._removeChildren( 0, 3 );
+				viewRoot._insertChild( 0, parse( '<container:p>6<attribute:i>A</attribute:i></container:p><container:p>7</container:p>' ) );
+
+				const domP1 = domRoot.childNodes[ 0 ];
+				const domP2 = domRoot.childNodes[ 1 ];
+				const domP4 = domRoot.childNodes[ 3 ];
+				const domP5 = domRoot.childNodes[ 4 ];
+
+				renderer.markToSync( 'children', viewRoot );
+				renderer.render();
+
+				expect( domRoot.innerHTML ).to.equal( '<p>6<i>A</i></p><p>7</p><p>4</p><p>5</p>' );
+				expect( domP1 ).to.equal( domRoot.childNodes[ 0 ] );
+				expect( domP2 ).to.equal( domRoot.childNodes[ 1 ] );
+				expect( domP4 ).to.equal( domRoot.childNodes[ 2 ] );
+				expect( domP5 ).to.equal( domRoot.childNodes[ 3 ] );
+			} );
+
+			it( 'should render multiple replacement in p group', () => {
+				const content = '' +
+					'<container:p>1</container:p>' +
+					'<container:p>2</container:p>' +
+					'<container:p>3</container:p>' +
+					'<container:p>4</container:p>' +
+					'<container:p>5</container:p>';
+
+				viewRoot._appendChild( parse( content ) );
+
+				renderer.markToSync( 'children', viewRoot );
+				renderer.render();
+
+				expect( domRoot.innerHTML ).to.equal( '<p>1</p><p>2</p><p>3</p><p>4</p><p>5</p>' );
+
+				viewRoot._removeChildren( 4 );
+				viewRoot._removeChildren( 1, 2 );
+				viewRoot._insertChild( 2, parse( '<container:p>6</container:p>' ) );
+				viewRoot._insertChild( 1, parse( '<container:p><attribute:i>A</attribute:i>7</container:p>' ) );
+
+				const domP1 = domRoot.childNodes[ 0 ];
+				const domP2 = domRoot.childNodes[ 1 ];
+				const domP4 = domRoot.childNodes[ 3 ];
+				const domP5 = domRoot.childNodes[ 4 ];
+
+				renderer.markToSync( 'children', viewRoot );
+				renderer.render();
+
+				expect( domRoot.innerHTML ).to.equal( '<p>1</p><p><i>A</i>7</p><p>4</p><p>6</p>' );
+				expect( domP1 ).to.equal( domRoot.childNodes[ 0 ] );
+				expect( domP2 ).to.equal( domRoot.childNodes[ 1 ] );
+				expect( domP4 ).to.equal( domRoot.childNodes[ 2 ] );
+				expect( domP5 ).to.equal( domRoot.childNodes[ 3 ] );
+			} );
+
+			it( 'should not rerender DOM when view replaced with the same structure', () => {
+				const content = '' +
+					'<container:h2>He' +
+						'<attribute:i>ading 1</attribute:i>' +
+					'</container:h2>' +
+					'<container:p>Ph ' +
+						'<attribute:strong>Bold</attribute:strong>' +
+						'<attribute:a href="https://ckeditor.com">' +
+							'<attribute:strong>Lin<attribute:i>k</attribute:i></attribute:strong>' +
+						'</attribute:a>' +
+					'</container:p>' +
+					'<container:blockquote>' +
+						'<container:ul>' +
+							'<container:li>Quoted <attribute:strong>item 1</attribute:strong></container:li>' +
+						'</container:ul>' +
+					'</container:blockquote>';
+
+				viewRoot._appendChild( parse( content ) );
+
+				renderer.markToSync( 'children', viewRoot );
+				renderer.render();
+
+				expect( domRoot.innerHTML ).to.equal( '<h2>He<i>ading 1</i></h2><p>Ph <strong>Bold</strong>' +
+					'<a href="https://ckeditor.com"><strong>Lin<i>k</i></strong></a></p><blockquote><ul><li>' +
+					'Quoted <strong>item 1</strong></li></ul></blockquote>' );
+
+				viewRoot._removeChildren( 0, viewRoot.childCount );
+				viewRoot._appendChild( parse( content ) );
+
+				const viewH = viewRoot.getChild( 0 );
+				const viewP = viewRoot.getChild( 1 );
+				const viewQ = viewRoot.getChild( 2 );
+
+				const domH = domRoot.childNodes[ 0 ];
+				const domHI = domH.childNodes[ 1 ];
+				const domP = domRoot.childNodes[ 1 ];
+				const domPT = domP.childNodes[ 0 ];
+				const domPABI = domP.childNodes[ 2 ].childNodes[ 0 ].childNodes[ 1 ];
+				const domQ = domRoot.childNodes[ 2 ];
+				const domQULB = domQ.childNodes[ 0 ].childNodes[ 0 ].childNodes[ 1 ];
+
+				renderer.markToSync( 'children', viewRoot );
+				renderer.render();
+
+				// Assert content.
+				expect( domRoot.innerHTML ).to.equal( '<h2>He<i>ading 1</i></h2><p>Ph <strong>Bold</strong>' +
+					'<a href="https://ckeditor.com"><strong>Lin<i>k</i></strong></a></p><blockquote><ul><li>' +
+					'Quoted <strong>item 1</strong></li></ul></blockquote>' );
+
+				// Assert if DOM elements did not change.
+				expect( domRoot.childNodes[ 0 ] ).to.equal( domH );
+				expect( domH.childNodes[ 1 ] ).to.equal( domHI );
+				expect( domRoot.childNodes[ 1 ] ).to.equal( domP );
+				expect( domP.childNodes[ 0 ] ).to.equal( domPT );
+				expect( domP.childNodes[ 2 ].childNodes[ 0 ].childNodes[ 1 ] ).to.equal( domPABI );
+				expect( domRoot.childNodes[ 2 ] ).to.equal( domQ );
+				expect( domQ.childNodes[ 0 ].childNodes[ 0 ].childNodes[ 1 ] ).to.equal( domQULB );
+
+				// Assert mappings.
+				const mappings = renderer.domConverter._domToViewMapping;
+				expect( mappings.get( domH ) ).to.equal( viewH );
+				expect( mappings.get( domHI ) ).to.equal( viewH.getChild( 1 ) );
+				expect( mappings.get( domP ) ).to.equal( viewP );
+				expect( mappings.get( domPABI ) ).to.equal( viewP.getChild( 2 ).getChild( 0 ).getChild( 1 ) );
+				expect( mappings.get( domQ ) ).to.equal( viewQ );
+				expect( mappings.get( domQULB ) ).to.equal( viewQ.getChild( 0 ).getChild( 0 ).getChild( 1 ) );
+			} );
+
+			it( 'should not rerender DOM when view replaced with the same structure without first node', () => {
+				const content = '' +
+					'<container:h2>He' +
+						'<attribute:i>ading 1</attribute:i>' +
+					'</container:h2>' +
+					'<container:p>Ph ' +
+						'<attribute:strong>Bold</attribute:strong>' +
+						'<attribute:a href="https://ckeditor.com">' +
+							'<attribute:strong>Lin<attribute:i>k</attribute:i></attribute:strong>' +
+						'</attribute:a>' +
+					'</container:p>' +
+					'<container:blockquote>' +
+						'<container:ul>' +
+							'<container:li>Quoted <attribute:strong>item 1</attribute:strong></container:li>' +
+						'</container:ul>' +
+					'</container:blockquote>';
+
+				const content2 = '' +
+					'<container:p>Ph ' +
+						'<attribute:strong>Bold</attribute:strong>' +
+						'<attribute:a href="https://ckeditor.com">' +
+							'<attribute:strong>Lin<attribute:i>k</attribute:i></attribute:strong>' +
+						'</attribute:a>' +
+					'</container:p>' +
+					'<container:blockquote>' +
+						'<container:ul>' +
+							'<container:li>Quoted <attribute:strong>item 1</attribute:strong></container:li>' +
+						'</container:ul>' +
+					'</container:blockquote>';
+
+				viewRoot._appendChild( parse( content ) );
+
+				renderer.markToSync( 'children', viewRoot );
+				renderer.render();
+
+				expect( domRoot.innerHTML ).to.equal( '<h2>He<i>ading 1</i></h2><p>Ph <strong>Bold</strong>' +
+					'<a href="https://ckeditor.com"><strong>Lin<i>k</i></strong></a></p><blockquote><ul><li>' +
+					'Quoted <strong>item 1</strong></li></ul></blockquote>' );
+
+				viewRoot._removeChildren( 0, viewRoot.childCount );
+				viewRoot._appendChild( parse( content2 ) );
+
+				const viewP = viewRoot.getChild( 0 );
+				const viewQ = viewRoot.getChild( 1 );
+
+				const domP = domRoot.childNodes[ 1 ];
+				const domPT = domP.childNodes[ 0 ];
+				const domPABI = domP.childNodes[ 2 ].childNodes[ 0 ].childNodes[ 1 ];
+				const domQ = domRoot.childNodes[ 2 ];
+				const domQULB = domQ.childNodes[ 0 ].childNodes[ 0 ].childNodes[ 1 ];
+
+				renderer.markToSync( 'children', viewRoot );
+				renderer.render();
+
+				// Assert content.
+				expect( domRoot.innerHTML ).to.equal( '<p>Ph <strong>Bold</strong>' +
+					'<a href="https://ckeditor.com"><strong>Lin<i>k</i></strong></a></p><blockquote><ul><li>' +
+					'Quoted <strong>item 1</strong></li></ul></blockquote>' );
+
+				// Assert if DOM elements did not change.
+				expect( domRoot.childNodes[ 0 ] ).to.equal( domP );
+				expect( domP.childNodes[ 0 ] ).to.equal( domPT );
+				expect( domP.childNodes[ 2 ].childNodes[ 0 ].childNodes[ 1 ] ).to.equal( domPABI );
+				expect( domRoot.childNodes[ 1 ] ).to.equal( domQ );
+				expect( domQ.childNodes[ 0 ].childNodes[ 0 ].childNodes[ 1 ] ).to.equal( domQULB );
+
+				// Assert mappings.
+				const mappings = renderer.domConverter._domToViewMapping;
+				expect( mappings.get( domP ) ).to.equal( viewP );
+				expect( mappings.get( domPABI ) ).to.equal( viewP.getChild( 2 ).getChild( 0 ).getChild( 1 ) );
+				expect( mappings.get( domQ ) ).to.equal( viewQ );
+				expect( mappings.get( domQULB ) ).to.equal( viewQ.getChild( 0 ).getChild( 0 ).getChild( 1 ) );
+			} );
+
+			it( 'should not rerender DOM when typing inside empty inline element', () => {
+				const view = parse( '<container:p>Foo Bar<attribute:strong></attribute:strong></container:p>' );
+
+				viewRoot._appendChild( view );
+
+				renderer.markToSync( 'children', viewRoot );
+				renderer.render();
+
+				expect( domRoot.innerHTML ).to.equal( '<p>Foo Bar<strong></strong></p>' );
+
+				const viewP = viewRoot.getChild( 0 );
+				viewP._removeChildren( 1 );
+				viewP._insertChild( 1, parse( '<attribute:strong>a</attribute:strong>' ) );
+
+				const domP = domRoot.childNodes[ 0 ];
+				const domText = domP.childNodes[ 0 ];
+				const domB = domP.childNodes[ 1 ];
+
+				domB.innerHTML = 'a';
+
+				renderer.markToSync( 'children', viewRoot );
+				renderer.markToSync( 'children', viewRoot.getChild( 0 ) );
+				renderer.render();
+
+				// Assert content.
+				expect( domRoot.innerHTML ).to.equal( '<p>Foo Bar<strong>a</strong></p>' );
+
+				// Assert if DOM elements did not change.
+				expect( domRoot.childNodes[ 0 ] ).to.equal( domP );
+				expect( domRoot.childNodes[ 0 ].childNodes[ 0 ] ).to.equal( domText );
+				expect( domRoot.childNodes[ 0 ].childNodes[ 1 ] ).to.equal( domB );
+
+				// Assert mappings.
+				const mappings = renderer.domConverter._domToViewMapping;
+				expect( mappings.get( domP ) ).to.equal( viewP );
+				expect( mappings.get( domB ) ).to.equal( viewP.getChild( 1 ) );
+			} );
+
+			it( 'should handle complex view duplication', () => {
+				const content = '' +
+					'<container:blockquote>' +
+						'<container:ul>' +
+							'<container:li>Quoted <attribute:strong>item 1</attribute:strong></container:li>' +
+							'<container:li>Item 2</container:li>' +
+							'<container:li>' +
+								'<attribute:a href="https://cksource.com">Li<attribute:strong>nk</attribute:strong></attribute:a>' +
+							'</container:li>' +
+						'</container:ul>' +
+					'</container:blockquote>';
+
+				const expected = '' +
+					'<blockquote>' +
+						'<ul>' +
+							'<li>Quoted <strong>item 1</strong></li>' +
+							'<li>Item 2</li>' +
+							'<li><a href="https://cksource.com">Li<strong>nk</strong></a></li>' +
+						'</ul>' +
+					'</blockquote>';
+
+				viewRoot._appendChild( parse( content ) );
+
+				renderer.markToSync( 'children', viewRoot );
+				renderer.render();
+
+				expect( domRoot.innerHTML ).to.equal( expected );
+
+				viewRoot._removeChildren( 0, viewRoot.childCount );
+				viewRoot._appendChild( parse( content + content ) );
+
+				const domBQ = domRoot.childNodes[ 0 ];
+				const domUL = domBQ.childNodes[ 0 ];
+				const domLI1 = domUL.childNodes[ 0 ];
+				const domLI2 = domUL.childNodes[ 1 ];
+				const domLI3 = domUL.childNodes[ 2 ];
+
+				renderer.markToSync( 'children', viewRoot );
+				renderer.render();
+
+				// Assert content.
+				expect( domRoot.innerHTML ).to.equal( expected + expected );
+
+				// Assert if DOM elements did not change.
+				expect( domRoot.childNodes[ 0 ] ).to.equal( domBQ );
+				expect( domBQ.childNodes[ 0 ] ).to.equal( domUL );
+				expect( domUL.childNodes[ 0 ] ).to.equal( domLI1 );
+				expect( domUL.childNodes[ 1 ] ).to.equal( domLI2 );
+				expect( domUL.childNodes[ 2 ] ).to.equal( domLI3 );
+
+				// Assert mappings.
+				const domMappings = renderer.domConverter._domToViewMapping;
+				expect( domMappings.get( domBQ ) ).to.equal( viewRoot.getChild( 0 ) );
+				expect( domMappings.get( domUL ) ).to.equal( viewRoot.getChild( 0 ).getChild( 0 ) );
+				expect( domMappings.get( domLI1 ) ).to.equal( viewRoot.getChild( 0 ).getChild( 0 ).getChild( 0 ) );
+				expect( domMappings.get( domLI2 ) ).to.equal( viewRoot.getChild( 0 ).getChild( 0 ).getChild( 1 ) );
+				expect( domMappings.get( domLI3 ) ).to.equal( viewRoot.getChild( 0 ).getChild( 0 ).getChild( 2 ) );
+
+				// Assert if new view elements are bind to new DOM elements.
+				const viewMappings = renderer.domConverter._domToViewMapping;
+				expect( viewMappings.get( viewRoot.getChild( 1 ) ) ).not.equal( domBQ );
+				expect( viewMappings.get( viewRoot.getChild( 1 ).getChild( 0 ) ) ).not.equal( domUL );
+				expect( viewMappings.get( viewRoot.getChild( 1 ).getChild( 0 ).getChild( 0 ) ) ).not.equal( domLI1 );
+				expect( viewMappings.get( viewRoot.getChild( 1 ).getChild( 0 ).getChild( 1 ) ) ).not.equal( domLI2 );
+				expect( viewMappings.get( viewRoot.getChild( 1 ).getChild( 0 ).getChild( 2 ) ) ).not.equal( domLI3 );
+			} );
+
+			it( 'should handle complex view replace', () => {
+				const content = '' +
+					'<container:h2>He' +
+						'<attribute:i>ading 1</attribute:i>' +
+					'</container:h2>' +
+					'<container:p>Ph ' +
+						'<attribute:strong>Bold</attribute:strong>' +
+						'<attribute:a href="https://ckeditor.com">' +
+							'<attribute:strong>Lin<attribute:i>k</attribute:i></attribute:strong>' +
+						'</attribute:a>' +
+					'</container:p>' +
+					'<container:blockquote>' +
+						'<container:ul>' +
+							'<container:li>Quoted <attribute:strong>item 1</attribute:strong></container:li>' +
+							'<container:li>Item 2</container:li>' +
+							'<container:li>' +
+								'<attribute:a href="https://cksource.com">Li<attribute:strong>nk</attribute:strong></attribute:a>' +
+							'</container:li>' +
+						'</container:ul>' +
+					'</container:blockquote>';
+
+				const replacement = '' +
+					'<container:p>' +
+						'1' +
+						'<attribute:i>A</attribute:i>' +
+					'</container:p>' +
+					'<container:p>' +
+						'<attribute:a href="https://cksource.com">' +
+							'Li' +
+							'<attribute:strong>nk</attribute:strong>' +
+						'</attribute:a>' +
+					'</container:p>' +
+					'<container:h1>' +
+						'Heading ' +
+						'<attribute:strong>1</attribute:strong>' +
+					'</container:h1>' +
+					'<container:h2>' +
+						'<attribute:a href="https://ckeditor.com">Heading 2</attribute:a>' +
+					'</container:h2>' +
+					'<container:h3>' +
+						'Heading' +
+						'<attribute:i> 3</attribute:i>' +
+					'</container:h3>' +
+					'<container:blockquote>' +
+						'Foo Bar Baz' +
+					'</container:blockquote>' +
+					'<container:ul>' +
+						'<container:li>' +
+							'Item ' +
+							'<attribute:strong>1</attribute:strong>' +
+						'</container:li>' +
+						'<container:li>' +
+							'<attribute:a href="https://ckeditor.com">Item</attribute:a>' +
+							' 2' +
+						'</container:li>' +
+					'</container:ul>';
+
+				viewRoot._appendChild( parse( content ) );
+
+				renderer.markToSync( 'children', viewRoot );
+				renderer.render();
+
+				expect( domRoot.innerHTML ).to.equal( '' +
+					'<h2>He<i>ading 1</i></h2>' +
+					'<p>Ph <strong>Bold</strong><a href="https://ckeditor.com"><strong>Lin<i>k</i></strong></a></p>' +
+					'<blockquote><ul>' +
+						'<li>Quoted <strong>item 1</strong></li>' +
+						'<li>Item 2</li><li><a href="https://cksource.com">Li<strong>nk</strong></a></li>' +
+					'</ul></blockquote>' );
+
+				viewRoot._removeChildren( 0, viewRoot.childCount );
+				viewRoot._appendChild( parse( replacement ) );
+
+				renderer.markToSync( 'children', viewRoot );
+				renderer.render();
+
+				// Here we just check if new DOM structure was properly rendered.
+				expect( domRoot.innerHTML ).to.equal( '' +
+					'<p>1<i>A</i></p>' +
+					'<p><a href="https://cksource.com">Li<strong>nk</strong></a></p>' +
+					'<h1>Heading <strong>1</strong></h1>' +
+					'<h2><a href="https://ckeditor.com">Heading 2</a></h2>' +
+					'<h3>Heading<i> 3</i></h3>' +
+					'<blockquote>Foo Bar Baz</blockquote>' +
+					'<ul><li>Item <strong>1</strong></li><li><a href="https://ckeditor.com">Item</a> 2</li></ul>' );
+			} );
+
+			it( 'should handle br elements while refreshing bindings', () => {
+				const expected = `<p>Foo Bar</p><p>${ BR_FILLER( document ).outerHTML }</p>`; // eslint-disable-line new-cap
+
+				viewRoot._appendChild( parse( '<container:p>Foo Bar</container:p><container:p></container:p>' ) );
+
+				renderer.markToSync( 'children', viewRoot );
+				renderer.render();
+
+				expect( domRoot.innerHTML ).to.equal( expected );
+
+				// There is a case in Safari that during accent panel navigation on macOS our 'BR_FILLER' is replaced with
+				// just '<br>' element which breaks accent composition in an empty paragraph. It also throws an error while
+				// refreshing mappings in a renderer. Simulate such behaviour (#1354).
+				domRoot.childNodes[ 1 ].innerHTML = '<br>';
+
+				viewRoot._removeChildren( 1 );
+				viewRoot._insertChild( 1, parse( '<container:p></container:p>' ) );
+
+				renderer.markToSync( 'children', viewRoot );
+				renderer.render();
+
+				expect( domRoot.innerHTML ).to.equal( expected );
+			} );
+
+			it( 'should handle list to paragraph conversion', () => {
+				const view = '' +
+					'<container:ol>' +
+						'<container:li>Item 1' +
+							'<container:ol>' +
+								'<container:li>Item 2</container:li>' +
+							'</container:ol>' +
+						'</container:li>' +
+					'</container:ol>' +
+					'<container:p>Paragraph</container:p>' +
+					'<container:ol>' +
+						'<container:li>Item 3' +
+							'<container:ol>' +
+								'<container:li>Item 4</container:li>' +
+							'</container:ol>' +
+						'</container:li>' +
+					'</container:ol>';
+
+				viewRoot._appendChild( parse( view ) );
+
+				renderer.markToSync( 'children', viewRoot );
+				renderer.render();
+
+				expect( domRoot.innerHTML ).to.equal(
+					'<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>' );
+
+				const viewOL1 = viewRoot.getChild( 0 );
+				viewOL1.getChild( 0 )._removeChildren( 1 );
+				viewRoot._removeChildren( 2 );
+				viewRoot._insertChild( 1, parse( '<container:p>Item 2</container:p>' ) );
+				viewRoot._insertChild( 3, parse( '<container:p>Item 3</container:p>' ) );
+				viewRoot._insertChild( 4, parse( '<container:ol><container:li>Item 4</container:li></container:ol>' ) );
+
+				const domOL1 = domRoot.childNodes[ 0 ];
+				const domOL2 = domRoot.childNodes[ 2 ];
+				const domP = domRoot.childNodes[ 1 ];
+
+				renderer.markToSync( 'children', viewRoot );
+				renderer.markToSync( 'children', viewOL1.getChild( 0 ) );
+				renderer.render();
+
+				expect( domRoot.innerHTML ).to.equal(
+					'<ol><li>Item 1</li></ol><p>Item 2</p><p>Paragraph</p><p>Item 3</p><ol><li>Item 4</li></ol>' );
+
+				expect( domRoot.childNodes[ 0 ] ).to.equal( domOL1 );
+				expect( domRoot.childNodes[ 2 ] ).to.equal( domP );
+				expect( domRoot.childNodes[ 4 ] ).to.equal( domOL2 );
+			} );
+
+			it( 'should handle attributes change in replaced elements', () => {
+				const view = '' +
+					'<container:ol>' +
+						'<container:li data-index="1" align="left">Item 1</container:li>' +
+					'</container:ol>' +
+					'<container:p>Paragraph ' +
+						'<attribute:a href="123">Link</attribute:a>' +
+					'</container:p>' +
+					'<container:p id="p1"><attribute:i>Bar</attribute:i>Baz</container:p>';
+
+				viewRoot._appendChild( parse( view ) );
+
+				renderer.markToSync( 'children', viewRoot );
+				renderer.render();
+
+				expect( normalizeHtml( domRoot.innerHTML ) ).to.equal( normalizeHtml(
+					'<ol><li data-index="1" align="left">Item 1</li></ol>' +
+					'<p>Paragraph <a href="123">Link</a></p><p id="p1"><i>Bar</i>Baz</p>' ) );
+
+				const viewOL = viewRoot.getChild( 0 );
+				viewOL._removeChildren( 0 );
+				viewOL._insertChild( 0, parse( '<container:li data-index="2" data-attr="foo">Item 1</container:li>' ) );
+
+				const viewP1 = viewRoot.getChild( 1 );
+				viewP1._removeChildren( 1 );
+				viewP1._insertChild( 1, parse( '<attribute:a href="456" class="cke">Foo</attribute:a>' ) );
+
+				viewRoot._removeChildren( 2 );
+				viewRoot._insertChild( 2, parse( '<container:p>Bar</container:p>' ) );
+
+				renderer.markToSync( 'children', viewRoot );
+				renderer.markToSync( 'children', viewOL );
+				renderer.markToSync( 'children', viewP1 );
+				renderer.render();
+
+				expect( normalizeHtml( domRoot.innerHTML ) ).to.equal( normalizeHtml(
+					'<ol><li data-index="2" data-attr="foo">Item 1</li></ol>' +
+					'<p>Paragraph <a href="456" class="cke">Foo</a></p><p>Bar</p>' ) );
+			} );
+
+			it( 'should handle classes change in replaced elements', () => {
+				const view = '' +
+					'<container:ol>' +
+						'<container:li class="foo1 bar2 baz3">Item 1</container:li>' +
+					'</container:ol>' +
+					'<container:p><attribute:i class="i1 i2">Bar</attribute:i>Baz</container:p>';
+
+				viewRoot._appendChild( parse( view ) );
+
+				renderer.markToSync( 'children', viewRoot );
+				renderer.render();
+
+				expect( normalizeHtml( domRoot.innerHTML ) ).to.equal( normalizeHtml(
+					'<ol><li class="foo1 bar2 baz3">Item 1</li></ol><p><i class="i1 i2">Bar</i>Baz</p>' ) );
+
+				const viewOL = viewRoot.getChild( 0 );
+				const oldViewLI = viewOL.getChild( 0 );
+				viewOL._removeChildren( 0 );
+				viewOL._insertChild( 0, parse( '<container:li class="bar2 baz4 bax5">Item 1</container:li>' ) );
+
+				const oldViewP = viewRoot.getChild( 1 );
+				viewRoot._removeChildren( 1 );
+				viewRoot._insertChild( 1, parse( '<container:p class="p1 p2"><attribute:i>Foo</attribute:i></container:p>' ) );
+
+				renderer.markToSync( 'attributes', oldViewLI );
+				renderer.markToSync( 'attributes', oldViewP );
+				renderer.markToSync( 'children', viewRoot );
+				renderer.markToSync( 'children', viewOL );
+				renderer.render();
+
+				expect( normalizeHtml( domRoot.innerHTML ) ).to.equal( normalizeHtml(
+					'<ol><li class="bar2 baz4 bax5">Item 1</li></ol><p class="p1 p2"><i>Foo</i></p>' ) );
+			} );
+
+			it( 'should handle styles change in replaced elements', () => {
+				const view = '' +
+					'<container:ol>' +
+						'<container:li style="color:#000;font-weight:bold;">Foo</container:li>' +
+						'<container:li>Bar ' +
+							'<attribute:i>' +
+								'<attribute:b style="color:#00F;background-color:#000;font-size:12px;">Baz</attribute:b>' +
+							' Bax</attribute:i>' +
+						'</container:li>' +
+					'</container:ol>';
+
+				viewRoot._appendChild( parse( view ) );
+
+				renderer.markToSync( 'children', viewRoot );
+				renderer.render();
+
+				expect( normalizeHtml( domRoot.innerHTML ) ).to.equal( normalizeHtml(
+					'<ol><li style="color:#000;font-weight:bold;">Foo</li>' +
+					'<li>Bar <i><b style="color:#00F;background-color:#000;font-size:12px;">Baz</b> Bax</i></li></ol>' ) );
+
+				const viewOL = viewRoot.getChild( 0 );
+				const viewLI1 = viewOL.getChild( 0 );
+				const viewLI2 = viewOL.getChild( 1 );
+
+				viewLI1._removeStyle( 'font-weight' );
+				viewLI1._setStyle( { color: '#FFF' } );
+				viewLI2._setStyle( { 'font-weight': 'bold' } );
+
+				viewLI2._removeChildren( 0, viewLI2.childCount );
+				viewLI2._insertChild( 0, parse( 'Ba1 <attribute:i style="color:#000;border-width:1px;">Ba3 ' +
+					'<attribute:b style="font-size:15px;">Ba2</attribute:b></attribute:i>' ) );
+
+				renderer.markToSync( 'attributes', viewLI1 );
+				renderer.markToSync( 'attributes', viewLI2 );
+				renderer.markToSync( 'children', viewRoot );
+				renderer.markToSync( 'children', viewLI2 );
+				renderer.render();
+
+				expect( normalizeHtml( domRoot.innerHTML ) ).to.equal( normalizeHtml(
+					'<ol><li style="color:#FFF;">Foo</li>' +
+					'<li style="font-weight:bold;">Ba1 <i style="color:#000;border-width:1px;">Ba3 ' +
+					'<b style="font-size:15px;">Ba2</b></i></li></ol>' ) );
+			} );
+
+			it( 'should handle uiElement rendering', () => {
+				function createUIElement( id, text ) {
+					const element = new UIElement( 'span' );
+					element.render = function( domDocument ) {
+						const domElement = this.toDomElement( domDocument );
+						domElement.innerText = `<span id="${ id }"><b>${ text }</b></span>`;
+						return domElement;
+					};
+
+					return element;
+				}
+
+				const ui1 = createUIElement( 'id1', 'UI1' );
+				const ui2 = createUIElement( 'id2', 'UI2' );
+				const viewP = new ViewContainerElement( 'p', null, [ new ViewText( 'Foo ' ), ui1, new ViewText( 'Bar' ) ] );
+				viewRoot._appendChild( viewP );
+
+				renderer.markToSync( 'children', viewRoot );
+				renderer.render();
+
+				expect( normalizeHtml( domRoot.innerHTML ) ).to.equal( normalizeHtml(
+					'<p>Foo <span><span id="id1"><b>UI1</b></span></span>Bar</p>' ) );
+
+				viewP._removeChildren( 0, viewP.childCount );
+				viewP._insertChild( 0, [ new ViewText( 'Foo' ), ui2, new ViewText( ' Bar' ) ] );
+
+				renderer.markToSync( 'children', viewRoot );
+				renderer.markToSync( 'children', viewP );
+				renderer.render();
+
+				expect( normalizeHtml( domRoot.innerHTML ) ).to.equal( normalizeHtml(
+					'<p>Foo<span><span id="id2"><b>UI2</b></span></span> Bar</p>' ) );
+			} );
+
+			it( 'should handle linking entire content', () => {
+				viewRoot._appendChild( parse( '<container:p>Foo<attribute:i>Bar</attribute:i></container:p>' ) );
+
+				renderer.markToSync( 'children', viewRoot );
+				renderer.render();
+
+				expect( domRoot.innerHTML ).to.equal( '<p>Foo<i>Bar</i></p>' );
+
+				const viewP = viewRoot.getChild( 0 );
+				// While linking, the existing DOM children are moved to a new `a` element during binding
+				// inside the `domConverter.viewToDom()` method. It happens because of a modified view structure
+				// where view elements were moved to a newly created link view element.
+				const viewA = new ViewAttributeElement( 'a', { href: '#href' }, [ new ViewText( 'Foo' ), viewP.getChild( 1 ) ] );
+
+				viewP._removeChildren( 0, viewP.childCount );
+				viewP._insertChild( 0, viewA );
+
+				renderer.markToSync( 'children', viewRoot );
+				renderer.markToSync( 'children', viewP );
+				renderer.render();
+
+				expect( domRoot.innerHTML ).to.equal( '<p><a href="#href">Foo<i>Bar</i></a></p>' );
+			} );
+		} );
 	} );
 
 	describe( '#922', () => {