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

Commit 8b859fb

Browse files
authored
Merge pull request #893 from ckeditor/t/889
Other: Simplified `SelectionObserver`'s infinite loop check which should improve its stability. Closes #889.
2 parents ea6c881 + 10d1338 commit 8b859fb

File tree

2 files changed

+49
-112
lines changed

2 files changed

+49
-112
lines changed

src/view/observer/selectionobserver.js

Lines changed: 5 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -83,28 +83,15 @@ export default class SelectionObserver extends Observer {
8383
*/
8484
this._fireSelectionChangeDoneDebounced = debounce( data => this.document.fire( 'selectionChangeDone', data ), 200 );
8585

86-
this._clearInfiniteLoopInterval = setInterval( () => this._clearInfiniteLoop(), 2000 );
87-
88-
/**
89-
* Private property to store the last selection, to check if the code does not enter infinite loop.
90-
*
91-
* @private
92-
* @member {module:engine/view/selection~Selection} module:engine/view/observer/selectionobserver~SelectionObserver#_lastSelection
93-
*/
94-
95-
/**
96-
* Private property to store the last but one selection, to check if the code does not enter infinite loop.
97-
*
98-
* @private
99-
* @member {module:engine/view/selection~Selection} module:engine/view/observer/selectionobserver~SelectionObserver#_lastButOneSelection
100-
*/
86+
this._clearInfiniteLoopInterval = setInterval( () => this._clearInfiniteLoop(), 1000 );
10187

10288
/**
10389
* Private property to check if the code does not enter infinite loop.
10490
*
10591
* @private
10692
* @member {Number} module:engine/view/observer/selectionobserver~SelectionObserver#_loopbackCounter
10793
*/
94+
this._loopbackCounter = 0;
10895
}
10996

11097
/**
@@ -161,7 +148,9 @@ export default class SelectionObserver extends Observer {
161148
}
162149

163150
// Ensure we are not in the infinite loop (#400).
164-
if ( this._isInfiniteLoop( newViewSelection ) ) {
151+
// This counter is reset each second. 60 selection changes in 1 second is enough high number
152+
// to be very difficult (impossible) to achieve using just keyboard keys (during normal editor use).
153+
if ( ++this._loopbackCounter > 60 ) {
165154
/**
166155
* Selection change observer detected an infinite rendering loop.
167156
* Most probably you try to put the selection in the position which is not allowed
@@ -191,45 +180,12 @@ export default class SelectionObserver extends Observer {
191180
this._fireSelectionChangeDoneDebounced( data );
192181
}
193182

194-
/**
195-
* Checks if selection rendering entered an infinite loop.
196-
*
197-
* See https://github.com/ckeditor/ckeditor5-engine/issues/400.
198-
*
199-
* @private
200-
* @param {module:engine/view/selection~Selection} newSelection DOM selection converted to view.
201-
* @returns {Boolean} True is the same selection repeat more then 10 times.
202-
*/
203-
_isInfiniteLoop( newSelection ) {
204-
// If the position is the same a the last one or the last but one we increment the counter.
205-
// We need to check last two selections because the browser will first fire a selectionchange event
206-
// for an incorrect selection and then for a corrected one.
207-
if ( this._lastSelection && this._lastButOneSelection &&
208-
( newSelection.isEqual( this._lastSelection ) || newSelection.isEqual( this._lastButOneSelection ) ) ) {
209-
this._loopbackCounter++;
210-
} else {
211-
this._lastButOneSelection = this._lastSelection;
212-
this._lastSelection = newSelection;
213-
this._loopbackCounter = 0;
214-
}
215-
216-
// This counter is reset every 2 seconds. 50 selection changes in 2 seconds is enough high number
217-
// to be very difficult (impossible) to achieve using just keyboard keys (during normal editor use).
218-
if ( this._loopbackCounter > 50 ) {
219-
return true;
220-
}
221-
222-
return false;
223-
}
224-
225183
/**
226184
* Clears `SelectionObserver` internal properties connected with preventing infinite loop.
227185
*
228186
* @protected
229187
*/
230188
_clearInfiniteLoop() {
231-
this._lastSelection = null;
232-
this._lastButOneSelection = null;
233189
this._loopbackCounter = 0;
234190
}
235191
}

tests/view/observer/selectionobserver.js

Lines changed: 44 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* For licensing, see LICENSE.md.
44
*/
55

6-
/* globals setTimeout, setInterval, clearInterval, document */
6+
/* globals setTimeout, document */
77

88
import ViewRange from '../../../src/view/range';
99
import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
@@ -136,98 +136,79 @@ describe( 'SelectionObserver', () => {
136136
changeDomSelection();
137137
} );
138138

139-
it( 'should warn and not enter infinite loop', ( done ) => {
140-
// Reset infinite loop counters so other tests won't mess up with this test.
141-
selectionObserver._clearInfiniteLoop();
142-
clearInterval( selectionObserver._clearInfiniteLoopInterval );
143-
selectionObserver._clearInfiniteLoopInterval = setInterval( () => selectionObserver._clearInfiniteLoop(), 2000 );
144-
145-
let counter = 100;
139+
it( 'should warn and not enter infinite loop', () => {
140+
// Selectionchange event is called twice per `changeDomSelection()` execution.
141+
let counter = 35;
146142

147143
const viewFoo = viewDocument.getRoot().getChild( 0 ).getChild( 0 );
148144
viewDocument.selection.addRange( ViewRange.createFromParentsAndOffsets( viewFoo, 0, viewFoo, 0 ) );
149145

150-
viewDocument.on( 'selectionChange', () => {
151-
counter--;
152-
153-
if ( counter > 0 ) {
154-
setTimeout( changeDomSelection );
155-
} else {
156-
throw 'Infinite loop!';
157-
}
158-
} );
146+
return new Promise( ( resolve, reject ) => {
147+
testUtils.sinon.stub( log, 'warn', ( msg ) => {
148+
expect( msg ).to.match( /^selectionchange-infinite-loop/ );
159149

160-
let warnedOnce = false;
150+
resolve();
151+
} );
161152

162-
testUtils.sinon.stub( log, 'warn', ( msg ) => {
163-
if ( !warnedOnce ) {
164-
warnedOnce = true;
153+
viewDocument.on( 'selectionChangeDone', () => {
154+
if ( !counter ) {
155+
reject( new Error( 'Infinite loop warning was not logged.' ) );
156+
}
157+
} );
165158

166-
setTimeout( () => {
167-
expect( msg ).to.match( /^selectionchange-infinite-loop/ );
168-
done();
169-
}, 200 );
159+
while ( counter > 0 ) {
160+
changeDomSelection();
161+
counter--;
170162
}
171163
} );
172-
173-
changeDomSelection();
174164
} );
175165

176166
it( 'should not be treated as an infinite loop if selection is changed only few times', ( done ) => {
177167
const viewFoo = viewDocument.getRoot().getChild( 0 ).getChild( 0 );
178-
179-
// Reset infinite loop counters so other tests won't mess up with this test.
180-
selectionObserver._clearInfiniteLoop();
181-
182168
viewDocument.selection.addRange( ViewRange.createFromParentsAndOffsets( viewFoo, 0, viewFoo, 0 ) );
183-
184169
const spy = testUtils.sinon.spy( log, 'warn' );
185170

171+
viewDocument.on( 'selectionChangeDone', () => {
172+
expect( spy.called ).to.be.false;
173+
done();
174+
} );
175+
186176
for ( let i = 0; i < 10; i++ ) {
187177
changeDomSelection();
188178
}
189-
190-
setTimeout( () => {
191-
expect( spy.called ).to.be.false;
192-
done();
193-
}, 400 );
194179
} );
195180

196-
it( 'should not be treated as an infinite loop if changes are not often', ( done ) => {
181+
it( 'should not be treated as an infinite loop if changes are not often', () => {
197182
const clock = testUtils.sinon.useFakeTimers( 'setInterval', 'clearInterval' );
198-
const spy = testUtils.sinon.spy( log, 'warn' );
183+
const stub = testUtils.sinon.stub( log, 'warn' );
199184

200185
// We need to recreate SelectionObserver, so it will use mocked setInterval.
201186
selectionObserver.disable();
202187
selectionObserver.destroy();
203188
viewDocument._observers.delete( SelectionObserver );
204189
viewDocument.addObserver( SelectionObserver );
205190

206-
// Inf-loop kicks in after 50th time the selection is changed in 2s.
207-
// We will test 30 times, tick sinon clock to clean counter and then test 30 times again.
208-
// Note that `changeDomSelection` fires two events.
209-
let changeCount = 15;
210-
211-
for ( let i = 0; i < changeCount; i++ ) {
212-
setTimeout( () => {
213-
changeDomSelection();
214-
}, i * 20 );
215-
}
216-
217-
setTimeout( () => {
218-
// Move the clock by 2100ms which will trigger callback added to `setInterval` and reset the inf-loop counter.
219-
clock.tick( 2100 );
220-
221-
for ( let i = 0; i < changeCount; i++ ) {
222-
changeDomSelection();
223-
}
224-
225-
setTimeout( () => {
226-
expect( spy.called ).to.be.false;
191+
return doChanges()
192+
.then( doChanges )
193+
.then( () => {
194+
sinon.assert.notCalled( stub );
227195
clock.restore();
228-
done();
229-
}, 200 );
230-
}, 400 );
196+
} );
197+
198+
// Selectionchange event is called twice per `changeDomSelection()` execution. We call it 25 times to get
199+
// 50 events. Infinite loop counter is reset, so calling this method twice should not show any warning.
200+
function doChanges() {
201+
return new Promise( resolve => {
202+
viewDocument.once( 'selectionChangeDone', () => {
203+
clock.tick( 1100 );
204+
resolve();
205+
} );
206+
207+
for ( let i = 0; i < 30; i++ ) {
208+
changeDomSelection();
209+
}
210+
} );
211+
}
231212
} );
232213

233214
it( 'should fire `selectionChangeDone` event after selection stop changing', ( done ) => {

0 commit comments

Comments
 (0)