8
0
Pārlūkot izejas kodu

Merge branch 'master' into t/ckeditor5/742

Kamil Piechaczek 7 gadi atpakaļ
vecāks
revīzija
8f902787a1

+ 1 - 0
packages/ckeditor5-engine/package.json

@@ -21,6 +21,7 @@
     "@ckeditor/ckeditor5-typing": "^1.0.0-beta.1",
     "@ckeditor/ckeditor5-undo": "^1.0.0-beta.1",
     "@ckeditor/ckeditor5-widget": "^1.0.0-beta.1",
+    "@ckeditor/ckeditor5-link": "^1.0.0-beta.1",
     "eslint": "^4.15.0",
     "eslint-config-ckeditor5": "^1.0.7",
     "husky": "^0.14.3",

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

@@ -206,7 +206,7 @@ export default class MutationObserver extends Observer {
 		for ( const viewElement of mutatedElements ) {
 			const domElement = domConverter.mapViewToDom( viewElement );
 			const viewChildren = Array.from( viewElement.getChildren() );
-			const newViewChildren = Array.from( domConverter.domChildrenToView( domElement ) );
+			const newViewChildren = Array.from( domConverter.domChildrenToView( domElement, { withChildren: false } ) );
 
 			// It may happen that as a result of many changes (sth was inserted and then removed),
 			// both elements haven't really changed. #1031

+ 30 - 6
packages/ckeditor5-engine/src/view/renderer.js

@@ -198,12 +198,6 @@ export default class Renderer {
 			this.markedChildren.add( inlineFillerPosition.parent );
 		}
 
-		for ( const node of this.markedTexts ) {
-			if ( !this.markedChildren.has( node.parent ) && this.domConverter.mapViewToDom( node.parent ) ) {
-				this._updateText( node, { inlineFillerPosition } );
-			}
-		}
-
 		for ( const element of this.markedAttributes ) {
 			this._updateAttrs( element );
 		}
@@ -212,6 +206,12 @@ export default class Renderer {
 			this._updateChildren( element, { inlineFillerPosition } );
 		}
 
+		for ( const node of this.markedTexts ) {
+			if ( !this.markedChildren.has( node.parent ) && this.domConverter.mapViewToDom( node.parent ) ) {
+				this._updateText( node, { inlineFillerPosition } );
+			}
+		}
+
 		// Check whether the inline filler is required and where it really is in the DOM.
 		// At this point in most cases it will be in the DOM, but there are exceptions.
 		// For example, if the inline filler was deep in the created DOM structure, it will not be created.
@@ -498,6 +498,8 @@ export default class Renderer {
 				nodesToUnbind.add( actualDomChildren[ i ] );
 				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 ] ) );
 				i++;
 			}
 		}
@@ -532,6 +534,28 @@ export default class Renderer {
 	}
 
 	/**
+	 * Marks text nodes to be synced.
+	 *
+	 * If a text node is passed, it will be marked. If an element is passed, all descendant text nodes inside it will be marked.
+	 *
+	 * @private
+	 * @param {module:engine/view/node~Node} viewNode View node to sync.
+	 */
+	_markDescendantTextToSync( viewNode ) {
+		if ( !viewNode ) {
+			return;
+		}
+
+		if ( viewNode.is( 'text' ) ) {
+			this.markedTexts.add( viewNode );
+		} else if ( viewNode.is( 'element' ) ) {
+			for ( const child of viewNode.getChildren() ) {
+				this._markDescendantTextToSync( child );
+			}
+		}
+	}
+
+	/**
 	 * Checks if selection needs to be updated and possibly updates it.
 	 *
 	 * @private

+ 5 - 6
packages/ckeditor5-engine/tests/model/document.js

@@ -20,11 +20,7 @@ describe( 'Document', () => {
 
 	beforeEach( () => {
 		model = new Model();
-		doc = new Document( model );
-
-		// Normally Model is the one who creates Document instance and keeps it as reference.
-		// We have to be sure that Model uses the right Document instance.
-		model.document = doc;
+		doc = model.document;
 	} );
 
 	describe( 'constructor()', () => {
@@ -288,7 +284,10 @@ describe( 'Document', () => {
 
 		it( 'should call all already processed callbacks again if a callback returned true', () => {
 			const callA = sinon.spy();
-			const callB = sinon.stub().onFirstCall().returns( true ).onSecondCall().returns( false );
+
+			const callB = sinon.stub();
+			callB.onFirstCall().returns( true ).onSecondCall().returns( false );
+
 			const callC = sinon.spy();
 
 			doc.registerPostFixer( callA );

+ 9 - 0
packages/ckeditor5-engine/tests/view/manual/deep-render.html

@@ -0,0 +1,9 @@
+<div id="editor">
+	<p>Foo<u> bar</u></p>
+	<p><u>Foo</u><i> buz</i></p>
+	<p>Foo<a href="#"><b> biz</b></a></p>
+	<p>Text <a href="#">Link <b>Bold</b></a> Text</p>
+</div>
+
+<p>Editor HTML preview:</p>
+<code id="preview"></code>

+ 34 - 0
packages/ckeditor5-engine/tests/view/manual/deep-render.js

@@ -0,0 +1,34 @@
+/**
+ * @license Copyright (c) 2003-2018, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md.
+ */
+
+/* global console, document, window */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import Essentials from '@ckeditor/ckeditor5-essentials/src/essentials';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+import Underline from '@ckeditor/ckeditor5-basic-styles/src/underline';
+import Bold from '@ckeditor/ckeditor5-basic-styles/src/bold';
+import Italic from '@ckeditor/ckeditor5-basic-styles/src/italic';
+import Link from '@ckeditor/ckeditor5-link/src/link';
+
+ClassicEditor
+	.create( document.querySelector( '#editor' ), {
+		plugins: [ Essentials, Paragraph, Underline, Bold, Italic, Link ],
+		toolbar: [ 'undo', 'redo', '|', 'bold', 'underline', 'italic', 'link' ]
+	} )
+	.then( editor => {
+		window.editor = editor;
+
+		const preview = document.querySelector( '#preview' );
+
+		preview.innerText = editor.getData();
+
+		editor.editing.view.on( 'render', () => {
+			preview.innerText = editor.getData();
+		} );
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );

+ 15 - 0
packages/ckeditor5-engine/tests/view/manual/deep-render.md

@@ -0,0 +1,15 @@
+## Text nodes deep rendering ([#1125](https://github.com/ckeditor/ckeditor5-engine/issues/1125))
+
+### Deep space rerender ([#1093](https://github.com/ckeditor/ckeditor5-engine/issues/1093))
+
+1. Put caret after each `Foo` text (`Foo^`).
+1. Press space.
+
+**Expected**: Inserted space is visible. Space on the beginning of the next text node is replaced with `&nbsp;`.
+
+### Link ending with styles ([ckeditor5-typing#120](https://github.com/ckeditor/ckeditor5-typing/issues/120))
+
+1. Put caret on the end of the link (`Link Bold^`).
+1. Insert some text.
+
+**Expected**: Bold text is inserted as a part of the link. No errors thrown in the browser dev console.

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

@@ -15,6 +15,7 @@ import ViewPosition from '../../src/view/position';
 import DocumentSelection from '../../src/view/documentselection';
 import DomConverter from '../../src/view/domconverter';
 import Renderer from '../../src/view/renderer';
+import DocumentFragment from '../../src/view/documentfragment';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
 import { parse, setData as setViewData, getData as getViewData } from '../../src/dev-utils/view';
 import { INLINE_FILLER, INLINE_FILLER_LENGTH, isBlockFiller, BR_FILLER } from '../../src/view/filler';
@@ -1355,6 +1356,310 @@ describe( 'Renderer', () => {
 			expect( domFocusSpy.called ).to.be.false;
 		} );
 
+		it( 'should render NBSP as first space in inline element after another space', () => {
+			const viewP = parse( '<container:p>x <attribute:b>y</attribute:b></container:p>' );
+
+			viewRoot._appendChildren( viewP );
+
+			renderer.markToSync( 'children', viewRoot );
+			renderer.render();
+
+			const domP = domRoot.childNodes[ 0 ];
+			const domB = domP.childNodes[ 1 ];
+
+			expect( domP.childNodes[ 0 ].data ).to.equal( 'x ' );
+			expect( domB.childNodes[ 0 ].data ).to.equal( 'y' );
+			expect( domP.innerHTML ).to.equal( 'x <b>y</b>' );
+
+			// Insert space resulting in '<p>x <b> y</b></p>'.
+			const viewB = viewP.getChild( 1 );
+			viewB._removeChildren( 0 );
+			viewB._appendChildren( new ViewText( ' y' ) );
+
+			renderer.markToSync( 'children', viewP );
+			renderer.render();
+
+			expect( domP.childNodes[ 0 ].data ).to.equal( 'x ' );
+			expect( domB.childNodes[ 0 ].data ).to.equal( '\u00A0y' );
+			expect( domP.innerHTML ).to.equal( 'x <b>&nbsp;y</b>' );
+		} );
+
+		it( 'should update sibling after, when node before is removed', () => {
+			const viewP = parse( '<container:p>x<attribute:b> y</attribute:b></container:p>' );
+
+			viewRoot._appendChildren( viewP );
+
+			renderer.markToSync( 'children', viewRoot );
+			renderer.render();
+
+			const domP = domRoot.childNodes[ 0 ];
+			const domB = domP.childNodes[ 1 ];
+
+			expect( domP.childNodes[ 0 ].data ).to.equal( 'x' );
+			expect( domB.childNodes[ 0 ].data ).to.equal( ' y' );
+			expect( domP.innerHTML ).to.equal( 'x<b> y</b>' );
+
+			// Remove 'x' resulting in '<p><b> y</b></p>'.
+			viewP._removeChildren( 0 );
+
+			renderer.markToSync( 'children', viewP );
+			renderer.render();
+
+			expect( domB.childNodes[ 0 ].data ).to.equal( '\u00A0y' );
+			expect( domP.innerHTML ).to.equal( '<b>&nbsp;y</b>' );
+		} );
+
+		it( 'should update sibling before, when node after is removed', () => {
+			const viewP = parse( '<container:p>x <attribute:b>y</attribute:b></container:p>' );
+
+			viewRoot._appendChildren( viewP );
+
+			renderer.markToSync( 'children', viewRoot );
+			renderer.render();
+
+			const domP = domRoot.childNodes[ 0 ];
+			const domB = domP.childNodes[ 1 ];
+
+			expect( domP.childNodes[ 0 ].data ).to.equal( 'x ' );
+			expect( domB.childNodes[ 0 ].data ).to.equal( 'y' );
+			expect( domP.innerHTML ).to.equal( 'x <b>y</b>' );
+
+			// Remove '<b>y</b>' resulting in '<p>x </p>'.
+			viewP._removeChildren( 1 );
+
+			renderer.markToSync( 'children', viewP );
+			renderer.render();
+
+			expect( domP.childNodes[ 0 ].data ).to.equal( 'x\u00A0' );
+			expect( domP.innerHTML ).to.equal( 'x&nbsp;' );
+		} );
+
+		// #1093
+		it( 'should update siblings after space is inserted in element before - text-element', () => {
+			const viewP = parse( '<container:p>x<attribute:b> y</attribute:b></container:p>' );
+
+			viewRoot._appendChildren( viewP );
+
+			renderer.markToSync( 'children', viewRoot );
+			renderer.render();
+
+			const domP = domRoot.childNodes[ 0 ];
+			const domB = domP.childNodes[ 1 ];
+
+			expect( domP.childNodes[ 0 ].data ).to.equal( 'x' );
+			expect( domB.childNodes[ 0 ].data ).to.equal( ' y' );
+			expect( domP.innerHTML ).to.equal( 'x<b> y</b>' );
+
+			// Insert space resulting in '<p>x <b> y</b></p>'.
+			viewP._removeChildren( 0 );
+			viewP._insertChildren( 0, new ViewText( 'x ' ) );
+
+			renderer.markToSync( 'children', viewP );
+			renderer.render();
+
+			expect( domP.childNodes[ 0 ].data ).to.equal( 'x ' );
+			expect( domB.childNodes[ 0 ].data ).to.equal( '\u00A0y' );
+			expect( domP.innerHTML ).to.equal( 'x <b>&nbsp;y</b>' );
+		} );
+
+		// #1093
+		it( 'should update siblings after space is inserted in element before - element-text', () => {
+			const viewP = parse( '<container:p><attribute:b>x</attribute:b> y</container:p>' );
+
+			viewRoot._appendChildren( viewP );
+
+			renderer.markToSync( 'children', viewRoot );
+			renderer.render();
+
+			const domP = domRoot.childNodes[ 0 ];
+			const domB = domP.childNodes[ 0 ];
+
+			expect( domB.childNodes[ 0 ].data ).to.equal( 'x' );
+			expect( domP.childNodes[ 1 ].data ).to.equal( ' y' );
+			expect( domP.innerHTML ).to.equal( '<b>x</b> y' );
+
+			// Insert space resulting in '<p><b>x </b> y</p>'.
+			const viewB = viewP.getChild( 0 );
+			viewB._removeChildren( 0 );
+			viewB._insertChildren( 0, new ViewText( 'x ' ) );
+
+			renderer.markToSync( 'children', viewP );
+			renderer.render();
+
+			expect( domB.childNodes[ 0 ].data ).to.equal( 'x ' );
+			expect( domP.childNodes[ 1 ].data ).to.equal( '\u00A0y' );
+			expect( domP.innerHTML ).to.equal( '<b>x </b>&nbsp;y' );
+		} );
+
+		// #1093
+		it( 'should update siblings after space is inserted in element before - element-element', () => {
+			const viewP = parse( '<container:p><attribute:b>x</attribute:b><attribute:i> y</attribute:i></container:p>' );
+
+			viewRoot._appendChildren( viewP );
+
+			renderer.markToSync( 'children', viewRoot );
+			renderer.render();
+
+			const domP = domRoot.childNodes[ 0 ];
+			const domB = domP.childNodes[ 0 ];
+			const domI = domP.childNodes[ 1 ];
+
+			expect( domB.childNodes[ 0 ].data ).to.equal( 'x' );
+			expect( domI.childNodes[ 0 ].data ).to.equal( ' y' );
+			expect( domP.innerHTML ).to.equal( '<b>x</b><i> y</i>' );
+
+			// Insert space resulting in '<p><b>x </b><i> y</i></p>'.
+			const viewB = viewP.getChild( 0 );
+			viewB._removeChildren( 0 );
+			viewB._insertChildren( 0, new ViewText( 'x ' ) );
+
+			renderer.markToSync( 'children', viewP );
+			renderer.render();
+
+			expect( domB.childNodes[ 0 ].data ).to.equal( 'x ' );
+			expect( domI.childNodes[ 0 ].data ).to.equal( '\u00A0y' );
+			expect( domP.innerHTML ).to.equal( '<b>x </b><i>&nbsp;y</i>' );
+		} );
+
+		// #1125
+		it( 'should properly rerender many changes done in one batch', () => {
+			// This test transforms:
+			//
+			//		<h2>He<strong>ding 1</strong></h2>
+			//		<p>Ph <strong>Bold</strong> <i>It<strong>alic</strong></i> <a><strong>Lin</strong>k</a></p>
+			//		<blockquote><ul><li>Quoted <strong>item 1</strong></li></ul></blockquote>
+			//
+			// into:
+			//
+			//		<h2>Heading 2</h2>
+			//		<p>Ph <i><strong>Italic</strong></i> <a><strong>L</strong>ink 1</a></p>
+			//		<blockquote><p>Qu<strong>ote</strong></p><ul><li><strong>Quoted item 1</strong></li></ul></blockquote>
+			//
+			// during one rerender to check if complex structure changes are rerendered correctly.
+			const viewContent = parse( '' +
+				'<container:h2>He' +
+					'<attribute:i>ading 1</attribute:i>' +
+				'</container:h2>' +
+				'<container:p>Ph ' +
+					'<attribute:strong>Bold</attribute:strong> ' +
+					'<attribute:i>It' +
+						'<attribute:strong>alic</attribute:strong>' +
+					'</attribute:i> ' +
+					'<attribute:a href="https://ckeditor.com">' +
+						'<attribute:strong>Lin</attribute:strong>' +
+					'k</attribute:a>' +
+				'</container:p>' +
+				'<container:blockquote>' +
+					'<container:ul>' +
+						'<container:li>Quoted <attribute:strong>item 1</attribute:strong></container:li>' +
+					'</container:ul>' +
+				'</container:blockquote>' );
+
+			viewRoot._appendChildren( viewContent );
+
+			renderer.markToSync( 'children', viewRoot );
+			renderer.render();
+
+			// '<h2>He<i>ading 1</i></h2>' -> '<h2>Heading 2</h2>'
+			const viewHeading = viewRoot.getChild( 0 );
+			viewHeading._removeChildren( 0, viewHeading.childCount );
+			viewHeading._insertChildren( 0, new ViewText( 'Heading 2' ) );
+
+			// Usually whole subtree is marked to sync so we mark root, changed element and all its direct children.
+			renderer.markToSync( 'children', viewRoot );
+			renderer.markToSync( 'children', viewHeading );
+			renderer.markToSync( 'text', viewHeading.getChild( 0 ) );
+
+			// '<p>Ph <strong>Bold</strong> <i>It<strong>alic</strong></i> <a><strong>Lin</strong>k</a></p>'
+			// ->
+			// '<p>Ph <i><strong>Italic</strong></i> <a><strong>L</strong>ink 1</a></p>'
+			const viewP = viewRoot.getChild( 1 );
+			viewP._removeChildren( 0, viewP.childCount );
+			viewP._insertChildren(
+				0,
+				parse(
+					'Ph <attribute:i><attribute:strong>Italic</attribute:strong></attribute:i> ' +
+					'<attribute:a href="https://ckeditor.com"><attribute:strong>L</attribute:strong>ink 1</attribute:a>'
+				)
+			);
+
+			renderer.markToSync( 'children', viewRoot );
+			renderer.markToSync( 'children', viewP );
+			renderer.markToSync( 'children', viewP.getChild( 1 ) );
+			renderer.markToSync( 'children', viewP.getChild( 3 ) );
+			renderer.markToSync( 'text', viewP.getChild( 0 ) );
+			renderer.markToSync( 'text', viewP.getChild( 2 ) );
+
+			// '<blockquote><ul><li>Quoted <strong>item 1</strong></li></ul></blockquote>'
+			// -> '<blockquote><p>Qu<strong>ote</strong></p><ul><li><strong>Quoted item 1</strong></li></ul></blockquote>'
+			const viewBq = viewRoot.getChild( 2 );
+			viewBq._removeChildren( 0, viewBq.childCount );
+			viewBq._insertChildren(
+				0,
+				parse(
+					'<container:p>Qu<attribute:strong>ote</attribute:strong></container:p>' +
+					'<container:ul><container:li><attribute:strong>Quoted item 1</attribute:strong></container:li></container:ul>'
+				)
+			);
+
+			renderer.markToSync( 'children', viewRoot );
+			renderer.markToSync( 'children', viewBq );
+			renderer.markToSync( 'children', viewBq.getChild( 0 ) );
+			renderer.markToSync( 'children', viewBq.getChild( 1 ) );
+
+			renderer.render();
+
+			expect( normalizeHtml( domRoot.innerHTML ) ).to.equal(
+				'<h2>Heading 2</h2>' +
+				'<p>Ph <i><strong>Italic</strong></i> <a href="https://ckeditor.com"><strong>L</strong>ink 1</a></p>' +
+				'<blockquote><p>Qu<strong>ote</strong></p><ul><li><strong>Quoted item 1</strong></li></ul></blockquote>'
+			);
+		} );
+
+		// #1125
+		it( 'should properly rerender changes when whole content replaced at once', () => {
+			// This test replaces:
+			//
+			//		<h1>Header</h1>
+			//		<blockquote><ul><li>Quoted <strong>item 1</strong></li><li>Item 2</li></ul></blockquote>
+			//
+			// with:
+			//
+			//		<h2>Header</h2>
+			//		<p>Not Quoted <strong>item 1</strong> and item 2</p>
+			//
+			// during one rerender to check if complex structure changes are rerendered correctly.
+			const viewContent = parse(
+				'<container:h1>Header</container:h1>' +
+				'<container:blockquote>' +
+					'<container:ul>' +
+						'<container:li>Quoted <attribute:strong>item 1</attribute:strong></container:li>' +
+						'<container:li>Item 2</container:li>' +
+					'</container:ul>' +
+				'</container:blockquote>'
+			);
+
+			viewRoot._appendChildren( viewContent );
+
+			renderer.markToSync( 'children', viewRoot );
+			renderer.render();
+
+			const newViewContent = parse(
+				'<container:h2>Header</container:h2>' +
+				'<container:p>Not Quoted <attribute:strong>item 1</attribute:strong> and item 2</container:p>'
+			);
+
+			viewRoot._removeChildren( 0, viewRoot.childCount );
+			viewRoot._appendChildren( newViewContent );
+
+			renderer.markToSync( 'children', viewRoot );
+			renderer.render();
+
+			expect( normalizeHtml( domRoot.innerHTML ) ).to.equal(
+				'<h2>Header</h2><p>Not Quoted <strong>item 1</strong> and item 2</p>'
+			);
+		} );
+
 		describe( 'fake selection', () => {
 			beforeEach( () => {
 				const { view: viewP, selection: newSelection } = parse(
@@ -2023,6 +2328,73 @@ describe( 'Renderer', () => {
 			return true;
 		}
 	} );
+
+	describe( '_markDescendantTextToSync', () => {
+		let viewRoot;
+
+		beforeEach( () => {
+			viewRoot = new ViewElement( 'div' );
+
+			renderer.markedTexts.clear();
+			renderer.markedAttributes.clear();
+			renderer.markedChildren.clear();
+		} );
+
+		it( 'should handle null values', () => {
+			// Such situation occurs when renderer encounters inline filler in 'renderer._updateChildren'.
+			renderer._markDescendantTextToSync( null );
+
+			expect( renderer.markedChildren.size ).to.equal( 0 );
+			expect( renderer.markedAttributes.size ).to.equal( 0 );
+			expect( renderer.markedTexts.size ).to.equal( 0 );
+		} );
+
+		it( 'should handle element nodes', () => {
+			const viewP = parse( '<container:p>foo<attribute:b>bar<attribute:i>baz</attribute:i></attribute:b></container:p>' );
+
+			viewRoot._appendChildren( viewP );
+
+			renderer._markDescendantTextToSync( viewP );
+
+			expect( renderer.markedChildren.size ).to.equal( 0 );
+			expect( renderer.markedAttributes.size ).to.equal( 0 );
+			expect( renderer.markedTexts.size ).to.equal( 3 );
+		} );
+
+		it( 'should handle text nodes', () => {
+			const viewP = parse( '<container:p><attribute:b>bar<attribute:i>baz</attribute:i></attribute:b></container:p>' );
+
+			viewRoot._appendChildren( viewP );
+
+			renderer._markDescendantTextToSync( viewP.getChild( 0 ).getChild( 0 ) );
+
+			expect( renderer.markedChildren.size ).to.equal( 0 );
+			expect( renderer.markedAttributes.size ).to.equal( 0 );
+			expect( renderer.markedTexts.size ).to.equal( 1 );
+		} );
+
+		it( 'should handle document fragment', () => {
+			const fragment = new DocumentFragment();
+
+			renderer._markDescendantTextToSync( fragment );
+
+			expect( renderer.markedChildren.size ).to.equal( 0 );
+			expect( renderer.markedAttributes.size ).to.equal( 0 );
+			expect( renderer.markedTexts.size ).to.equal( 0 );
+		} );
+
+		it( 'should handle empty element nodes', () => {
+			const viewP = parse( '<container:p></container:p>' );
+
+			viewRoot._appendChildren( viewP );
+
+			renderer._markDescendantTextToSync( viewP );
+
+			expect( renderer.markedChildren.size ).to.equal( 0 );
+			expect( renderer.markedAttributes.size ).to.equal( 0 );
+			expect( renderer.markedTexts.size ).to.equal( 0 );
+		} );
+	} );
 } );
 
 function renderAndExpectNoChanges( renderer, domRoot ) {