Skip to content

Commit b47f981

Browse files
divdavemmaxokorokov
authored andcommitted
fix(datepicker): ExpressionChangedAfterItHasBeenCheckedError when switching between months
Reuses data structures in the datepicker data model instead of building new objects each time the user navigates to a different month. As a consequence, DOM elements are no longer destroyed when changing month. This fixes the following issue which occurred when BrowserAnimationsModule was used: when using the keyboard to navigate from one month to another, the focused element was destroyed during the change detection of the keyboard event, which synchronously triggered the focusout event in Chrome, leading to a change in the data model (and that change is still part of the data model only when BrowserAnimationsModule is used, because that module changes the behavior of Angular to not destroy views and their data model as quickly as when this module is not loaded), and this was causing the ExpressionChangedAfterItHasBeenCheckedError error. Fixes #2408 Closes #2462
1 parent d7e3649 commit b47f981

File tree

4 files changed

+303
-131
lines changed

4 files changed

+303
-131
lines changed

src/datepicker/datepicker-service.spec.ts

Lines changed: 12 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -158,29 +158,22 @@ describe('ngb-datepicker-service', () => {
158158
expect(getDayCtx(5).disabled).toBe(true); // 6 MAY
159159
});
160160

161-
it(`should rebuild month when 'min/maxDates' change and visible`, () => {
161+
it(`should update month when 'min/maxDates' change and visible`, () => {
162162
// MAY 2017
163163
service.focus(new NgbDate(2017, 5, 5));
164164
expect(model.months.length).toBe(1);
165165
expect(model.minDate).toBeUndefined();
166166
expect(model.maxDate).toBeUndefined();
167167

168-
const month = model.months[0];
169-
const date = month.weeks[0].days[0].date;
170-
171168
// MIN -> 5 MAY, 2017
172169
service.minDate = new NgbDate(2017, 5, 5);
173170
expect(model.months.length).toBe(1);
174-
expect(model.months[0]).not.toBe(month);
175171
expect(getDayCtx(0).disabled).toBe(true);
176-
expect(getDay(0).date).not.toBe(date);
177172

178173
// MAX -> 10 MAY, 2017
179174
service.maxDate = new NgbDate(2017, 5, 10);
180175
expect(model.months.length).toBe(1);
181-
expect(model.months[0]).not.toBe(month);
182176
expect(model.months[0].weeks[4].days[0].context.disabled).toBe(true);
183-
expect(model.months[0].weeks[0].days[0].date).not.toBe(date);
184177
});
185178
});
186179

@@ -233,18 +226,19 @@ describe('ngb-datepicker-service', () => {
233226
expect(model.months[0].weekdays[0]).toBe(4);
234227
});
235228

236-
it(`should rebuild months when 'firstDayOfWeek' changes`, () => {
229+
it(`should update months when 'firstDayOfWeek' changes`, () => {
237230
service.focus(new NgbDate(2017, 5, 5));
238231
expect(model.months.length).toBe(1);
239232
expect(model.firstDayOfWeek).toBe(1);
240233

241-
const month = model.months[0];
242-
const date = month.weeks[0].days[0].date;
234+
const oldFirstDate = getDay(0).date.toString();
235+
expect(oldFirstDate).toBe('2017-5-1');
243236

244237
service.firstDayOfWeek = 3;
245238
expect(model.months.length).toBe(1);
246-
expect(model.months[0]).not.toBe(month);
247-
expect(getDay(0).date).not.toBe(date);
239+
expect(model.firstDayOfWeek).toBe(3);
240+
const newFirstDate = getDay(0).date.toString();
241+
expect(newFirstDate).toBe('2017-4-26');
248242
});
249243
});
250244

@@ -905,17 +899,16 @@ describe('ngb-datepicker-service', () => {
905899
expect(day.context.disabled).toBe(true);
906900
});
907901

908-
it(`should rebuild months when 'markDisabled changes'`, () => {
902+
it(`should update months when 'markDisabled changes'`, () => {
909903
// MAY 2017
910904
service.markDisabled = (_) => true;
911905
service.focus(new NgbDate(2017, 5, 1));
912906

913-
const month = model.months[0];
914-
const date = month.weeks[0].days[0].date;
907+
expect(getDay(0).context.disabled).toBe(true);
915908

916-
service.markDisabled = (_) => true;
917-
expect(model.months[0]).not.toBe(month);
918-
expect(getDay(0).date).not.toBe(date);
909+
service.markDisabled = (_) => false;
910+
911+
expect(getDay(0).context.disabled).toBe(false);
919912
});
920913
});
921914

src/datepicker/datepicker-tools.spec.ts

Lines changed: 189 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
import {NgbDate} from './ngb-date';
1212
import {NgbCalendar, NgbCalendarGregorian} from './ngb-calendar';
1313
import {TestBed} from '@angular/core/testing';
14-
import {DatepickerViewModel, NgbMarkDisabled} from './datepicker-view-model';
14+
import {DatepickerViewModel, NgbMarkDisabled, MonthViewModel} from './datepicker-view-model';
1515
import {NgbDatepickerI18n, NgbDatepickerI18nDefault} from './datepicker-i18n';
1616
import {DatePipe} from '@angular/common';
1717

@@ -248,92 +248,199 @@ describe(`datepicker-tools`, () => {
248248
expect(months.length).toBe(2);
249249
});
250250

251-
it(`should not rebuild existing months by default`, () => {
252-
const may = new NgbDate(2017, 5, 5);
253-
const june = new NgbDate(2017, 6, 5);
251+
const storeMonthsDataStructure = (months: MonthViewModel[]) => {
252+
return months.map(month => {
253+
const storage = {weeks: month.weeks, weekdays: month.weekdays};
254+
const weeks = month.weeks;
255+
for (let weekIndex = 0, weeksLength = weeks.length; weekIndex < weeksLength; weekIndex++) {
256+
const currentWeek = weeks[weekIndex];
257+
storage[`weeks[${weekIndex}]`] = currentWeek;
258+
const days = currentWeek.days;
259+
storage[`weeks[${weekIndex}].days`] = days;
260+
for (let dayIndex = 0, daysLength = days.length; dayIndex < daysLength; dayIndex++) {
261+
const currentDay = days[dayIndex];
262+
storage[`weeks[${weekIndex}].days[${dayIndex}]`] = currentDay;
263+
}
264+
}
265+
return storage;
266+
});
267+
};
268+
269+
const customMatchers: jasmine.CustomMatcherFactories = {
270+
toHaveTheSameMonthDataStructureAs: function(util, customEqualityTesters) {
271+
return {
272+
compare(actualMonthsStorage, expectedMonthsStorage) {
273+
try {
274+
const monthsNumber = actualMonthsStorage.length;
275+
if (expectedMonthsStorage.length !== monthsNumber) {
276+
throw 'the number of months';
277+
};
278+
for (let i = 0; i < monthsNumber; i++) {
279+
const storage1 = actualMonthsStorage[i];
280+
const storage2 = expectedMonthsStorage[i];
281+
const keys1 = Object.keys(storage1);
282+
const keys2 = Object.keys(storage2);
283+
if (!util.equals(keys2, keys1, customEqualityTesters)) {
284+
throw `the set of keys in months[${i}]: ${keys1} != ${keys2}`;
285+
}
286+
for (const key of keys1) {
287+
if (storage1[key] !== storage2[key]) {
288+
throw `months[${i}].${key}`;
289+
}
290+
}
291+
}
292+
return {
293+
pass: true, message: 'Expected different months data structures, but the same data structure was found.'
294+
}
295+
} catch (e) {
296+
return {
297+
pass: false,
298+
message: typeof e === 'string' ?
299+
`Expected the same months data structure, but a difference was found in ${e}` :
300+
`${e}`
301+
};
302+
}
303+
}
304+
};
305+
}
306+
};
307+
308+
beforeEach(function() { jasmine.addMatchers(customMatchers); });
254309

255-
// one same month
310+
it(`should reuse the same data structure (force = false)`, () => {
256311
let state = { displayMonths: 1, firstDayOfWeek: 1, months: [] } as DatepickerViewModel;
257-
state.months = buildMonths(calendar, may, state, i18n, false);
258-
let newMonths = buildMonths(calendar, may, state, i18n, false);
259-
260-
expect(state.months.length).toBe(1);
261-
expect(newMonths.length).toBe(1);
262-
expect(state.months[0]).toBe(newMonths[0]);
263-
264-
// one new month
265-
state = { displayMonths: 1, firstDayOfWeek: 1, months: [] } as DatepickerViewModel;
266-
state.months = buildMonths(calendar, may, state, i18n, false);
267-
newMonths = buildMonths(calendar, june, state, i18n, false);
268-
269-
expect(state.months.length).toBe(1);
270-
expect(newMonths.length).toBe(1);
271-
expect(state.months[0]).not.toBe(newMonths[0]);
272-
273-
// two same months
274-
state = { displayMonths: 2, firstDayOfWeek: 1, months: [] } as DatepickerViewModel;
275-
state.months = buildMonths(calendar, may, state, i18n, false);
276-
newMonths = buildMonths(calendar, may, state, i18n, false);
277-
278-
expect(state.months.length).toBe(2);
279-
expect(newMonths.length).toBe(2);
280-
expect(state.months[0]).toBe(newMonths[0]);
281-
expect(state.months[1]).toBe(newMonths[1]);
282-
283-
// two months, one overlaps
284-
state = { displayMonths: 2, firstDayOfWeek: 1, months: [] } as DatepickerViewModel;
285-
state.months = buildMonths(calendar, may, state, i18n, false);
286-
newMonths = buildMonths(calendar, june, state, i18n, false);
287-
288-
expect(state.months.length).toBe(2);
289-
expect(newMonths.length).toBe(2);
290-
expect(state.months[0]).not.toBe(newMonths[0]);
291-
expect(state.months[1]).not.toBe(newMonths[1]);
292-
expect(state.months[1]).toBe(newMonths[0]); // june reused
293-
});
312+
let months = buildMonths(calendar, new NgbDate(2017, 5, 5), state, i18n, false);
313+
expect(months).toBe(state.months);
314+
expect(months.length).toBe(1);
315+
let monthsStructure = storeMonthsDataStructure(months);
316+
317+
months = buildMonths(calendar, new NgbDate(2018, 5, 5), state, i18n, false);
318+
expect(months).toBe(state.months);
319+
expect(months.length).toBe(1);
320+
expect(storeMonthsDataStructure(months))['toHaveTheSameMonthDataStructureAs'](monthsStructure);
321+
322+
state.displayMonths = 2;
323+
months = buildMonths(calendar, new NgbDate(2018, 5, 5), state, i18n, false);
324+
expect(months).toBe(state.months);
325+
expect(months.length).toBe(2);
326+
monthsStructure.push(...storeMonthsDataStructure([months[1]]));
327+
expect(storeMonthsDataStructure(months))['toHaveTheSameMonthDataStructureAs'](monthsStructure);
328+
329+
// next month
330+
months = buildMonths(calendar, new NgbDate(2018, 6, 5), state, i18n, false);
331+
expect(months).toBe(state.months);
332+
expect(months.length).toBe(2);
333+
// the structures should be swapped:
334+
monthsStructure.push(monthsStructure.shift());
335+
expect(storeMonthsDataStructure(months))['toHaveTheSameMonthDataStructureAs'](monthsStructure);
294336

295-
it(`should rebuild existing months with 'rebuild=false'`, () => {
296-
const may = new NgbDate(2017, 5, 5);
297-
const june = new NgbDate(2017, 6, 5);
337+
// previous month
338+
months = buildMonths(calendar, new NgbDate(2018, 5, 5), state, i18n, false);
339+
expect(months).toBe(state.months);
340+
expect(months.length).toBe(2);
341+
// the structures should be swapped (again):
342+
monthsStructure.push(monthsStructure.shift());
343+
expect(storeMonthsDataStructure(months))['toHaveTheSameMonthDataStructureAs'](monthsStructure);
344+
345+
state.displayMonths = 5;
346+
months = buildMonths(calendar, new NgbDate(2018, 5, 5), state, i18n, false);
347+
expect(months).toBe(state.months);
348+
expect(months.length).toBe(5);
349+
monthsStructure.push(...storeMonthsDataStructure(months.slice(2)));
350+
expect(storeMonthsDataStructure(months))['toHaveTheSameMonthDataStructureAs'](monthsStructure);
351+
352+
// go to two months after, the 3 last months are reused as is
353+
months = buildMonths(calendar, new NgbDate(2018, 7, 5), state, i18n, false);
354+
expect(months).toBe(state.months);
355+
expect(months.length).toBe(5);
356+
monthsStructure.unshift(...monthsStructure.splice(2, 3));
357+
expect(storeMonthsDataStructure(months))['toHaveTheSameMonthDataStructureAs'](monthsStructure);
358+
359+
// go to two months before, the 3 first months are reused as is
360+
months = buildMonths(calendar, new NgbDate(2018, 5, 5), state, i18n, false);
361+
expect(months).toBe(state.months);
362+
expect(months.length).toBe(5);
363+
monthsStructure.push(...monthsStructure.splice(0, 3));
364+
expect(storeMonthsDataStructure(months))['toHaveTheSameMonthDataStructureAs'](monthsStructure);
365+
366+
// completely change the dates, nothing is shifted in monthsStructure
367+
months = buildMonths(calendar, new NgbDate(2018, 10, 5), state, i18n, false);
368+
expect(months).toBe(state.months);
369+
expect(months.length).toBe(5);
370+
expect(storeMonthsDataStructure(months))['toHaveTheSameMonthDataStructureAs'](monthsStructure);
371+
372+
// keep 2 months
373+
state.displayMonths = 2;
374+
months = buildMonths(calendar, new NgbDate(2018, 11, 5), state, i18n, false);
375+
expect(months).toBe(state.months);
376+
expect(months.length).toBe(2);
377+
monthsStructure = monthsStructure.slice(1, 3);
378+
expect(storeMonthsDataStructure(months))['toHaveTheSameMonthDataStructureAs'](monthsStructure);
379+
});
298380

299-
// one same month
381+
it(`should reuse the same data structure (force = true)`, () => {
300382
let state = { displayMonths: 1, firstDayOfWeek: 1, months: [] } as DatepickerViewModel;
301-
state.months = buildMonths(calendar, may, state, i18n, true);
302-
let newMonths = buildMonths(calendar, may, state, i18n, true);
303-
304-
expect(state.months.length).toBe(1);
305-
expect(newMonths.length).toBe(1);
306-
expect(state.months[0]).not.toBe(newMonths[0]);
307-
308-
// one new month
309-
state = { displayMonths: 1, firstDayOfWeek: 1, months: [] } as DatepickerViewModel;
310-
state.months = buildMonths(calendar, may, state, i18n, true);
311-
newMonths = buildMonths(calendar, june, state, i18n, true);
312-
313-
expect(state.months.length).toBe(1);
314-
expect(newMonths.length).toBe(1);
315-
expect(state.months[0]).not.toBe(newMonths[0]);
316-
317-
// two same months
318-
state = { displayMonths: 2, firstDayOfWeek: 1, months: [] } as DatepickerViewModel;
319-
state.months = buildMonths(calendar, may, state, i18n, true);
320-
newMonths = buildMonths(calendar, may, state, i18n, true);
321-
322-
expect(state.months.length).toBe(2);
323-
expect(newMonths.length).toBe(2);
324-
expect(state.months[0]).not.toBe(newMonths[0]);
325-
expect(state.months[1]).not.toBe(newMonths[1]);
326-
327-
// two months, one overlaps
328-
state = { displayMonths: 2, firstDayOfWeek: 1, months: [] } as DatepickerViewModel;
329-
state.months = buildMonths(calendar, may, state, i18n, true);
330-
newMonths = buildMonths(calendar, june, state, i18n, true);
331-
332-
expect(state.months.length).toBe(2);
333-
expect(newMonths.length).toBe(2);
334-
expect(state.months[0]).not.toBe(newMonths[0]);
335-
expect(state.months[1]).not.toBe(newMonths[1]);
336-
expect(state.months[1]).not.toBe(newMonths[0]);
383+
let months = buildMonths(calendar, new NgbDate(2017, 5, 5), state, i18n, true);
384+
expect(months).toBe(state.months);
385+
expect(months.length).toBe(1);
386+
let monthsStructure = storeMonthsDataStructure(months);
387+
388+
months = buildMonths(calendar, new NgbDate(2018, 5, 5), state, i18n, true);
389+
expect(months).toBe(state.months);
390+
expect(months.length).toBe(1);
391+
expect(storeMonthsDataStructure(months))['toHaveTheSameMonthDataStructureAs'](monthsStructure);
392+
393+
state.displayMonths = 2;
394+
months = buildMonths(calendar, new NgbDate(2018, 5, 5), state, i18n, true);
395+
expect(months).toBe(state.months);
396+
expect(months.length).toBe(2);
397+
monthsStructure.push(...storeMonthsDataStructure([months[1]]));
398+
expect(storeMonthsDataStructure(months))['toHaveTheSameMonthDataStructureAs'](monthsStructure);
399+
400+
// next month
401+
months = buildMonths(calendar, new NgbDate(2018, 6, 5), state, i18n, true);
402+
expect(months).toBe(state.months);
403+
expect(months.length).toBe(2);
404+
expect(storeMonthsDataStructure(months))['toHaveTheSameMonthDataStructureAs'](monthsStructure);
405+
406+
// previous month
407+
months = buildMonths(calendar, new NgbDate(2018, 5, 5), state, i18n, true);
408+
expect(months).toBe(state.months);
409+
expect(months.length).toBe(2);
410+
expect(storeMonthsDataStructure(months))['toHaveTheSameMonthDataStructureAs'](monthsStructure);
411+
412+
state.displayMonths = 5;
413+
months = buildMonths(calendar, new NgbDate(2018, 5, 5), state, i18n, true);
414+
expect(months).toBe(state.months);
415+
expect(months.length).toBe(5);
416+
monthsStructure.push(...storeMonthsDataStructure(months.slice(2)));
417+
expect(storeMonthsDataStructure(months))['toHaveTheSameMonthDataStructureAs'](monthsStructure);
418+
419+
// go to two months after
420+
months = buildMonths(calendar, new NgbDate(2018, 7, 5), state, i18n, true);
421+
expect(months).toBe(state.months);
422+
expect(months.length).toBe(5);
423+
expect(storeMonthsDataStructure(months))['toHaveTheSameMonthDataStructureAs'](monthsStructure);
424+
425+
// go to two months before
426+
months = buildMonths(calendar, new NgbDate(2018, 5, 5), state, i18n, true);
427+
expect(months).toBe(state.months);
428+
expect(months.length).toBe(5);
429+
expect(storeMonthsDataStructure(months))['toHaveTheSameMonthDataStructureAs'](monthsStructure);
430+
431+
// completely change the dates
432+
months = buildMonths(calendar, new NgbDate(2018, 10, 5), state, i18n, true);
433+
expect(months).toBe(state.months);
434+
expect(months.length).toBe(5);
435+
expect(storeMonthsDataStructure(months))['toHaveTheSameMonthDataStructureAs'](monthsStructure);
436+
437+
// keep 2 months
438+
state.displayMonths = 2;
439+
months = buildMonths(calendar, new NgbDate(2018, 11, 5), state, i18n, true);
440+
expect(months).toBe(state.months);
441+
expect(months.length).toBe(2);
442+
monthsStructure = monthsStructure.slice(0, 2);
443+
expect(storeMonthsDataStructure(months))['toHaveTheSameMonthDataStructureAs'](monthsStructure);
337444
});
338445
});
339446

0 commit comments

Comments
 (0)