Skip to content

Commit bb66df7

Browse files
TrevorKarjanisandrewseguin
authored andcommitted
fix(breakpoint-observer): fix the breakpoint observer emit count and accuracy (#15964)
The breakpoint observer emits multiple and incorrect states when more than one query changes. Debounce the observer emissions to eliminate the incorrect states and emit once. References #10925
1 parent 453b1e8 commit bb66df7

File tree

2 files changed

+50
-25
lines changed

2 files changed

+50
-25
lines changed

src/cdk/layout/breakpoints-observer.spec.ts

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@
99
import {LayoutModule} from './layout-module';
1010
import {BreakpointObserver, BreakpointState} from './breakpoints-observer';
1111
import {MediaMatcher} from './media-matcher';
12-
import {fakeAsync, TestBed, inject, flush} from '@angular/core/testing';
12+
import {fakeAsync, TestBed, inject, flush, tick} from '@angular/core/testing';
1313
import {Injectable} from '@angular/core';
1414
import {Subscription} from 'rxjs';
15-
import {take} from 'rxjs/operators';
15+
import {skip, take} from 'rxjs/operators';
1616

1717
describe('BreakpointObserver', () => {
1818
let breakpointObserver: BreakpointObserver;
@@ -93,10 +93,10 @@ describe('BreakpointObserver', () => {
9393
queryMatchState = state.matches;
9494
});
9595

96-
flush();
96+
tick();
9797
expect(queryMatchState).toBeTruthy();
9898
mediaMatcher.setMatchesQuery(query, false);
99-
flush();
99+
tick();
100100
expect(queryMatchState).toBeFalsy();
101101
}));
102102

@@ -108,36 +108,54 @@ describe('BreakpointObserver', () => {
108108
breakpointObserver.observe([queryOne, queryTwo]).subscribe((breakpoint: BreakpointState) => {
109109
state = breakpoint;
110110
});
111+
expect(state.breakpoints).toEqual({[queryOne]: true, [queryTwo]: true});
111112

112113
mediaMatcher.setMatchesQuery(queryOne, false);
113114
mediaMatcher.setMatchesQuery(queryTwo, false);
114-
flush();
115+
tick();
115116
expect(state.breakpoints).toEqual({[queryOne]: false, [queryTwo]: false});
116117

117118
mediaMatcher.setMatchesQuery(queryOne, true);
118119
mediaMatcher.setMatchesQuery(queryTwo, false);
119-
flush();
120+
tick();
120121
expect(state.breakpoints).toEqual({[queryOne]: true, [queryTwo]: false});
121122
}));
122123

123124
it('emits a true matches state when the query is matched', fakeAsync(() => {
124125
const query = '(width: 999px)';
125126
breakpointObserver.observe(query).subscribe();
126127
mediaMatcher.setMatchesQuery(query, true);
128+
tick();
127129
expect(breakpointObserver.isMatched(query)).toBeTruthy();
128130
}));
129131

130132
it('emits a false matches state when the query is not matched', fakeAsync(() => {
131133
const query = '(width: 999px)';
132134
breakpointObserver.observe(query).subscribe();
133135
mediaMatcher.setMatchesQuery(query, false);
136+
tick();
134137
expect(breakpointObserver.isMatched(query)).toBeFalsy();
135138
}));
136139

140+
it('emits one event when multiple queries change', fakeAsync(() => {
141+
const observer = jasmine.createSpy('observer');
142+
const queryOne = '(width: 700px)';
143+
const queryTwo = '(width: 999px)';
144+
breakpointObserver.observe([queryOne, queryTwo])
145+
.pipe(skip(1))
146+
.subscribe(observer);
147+
148+
mediaMatcher.setMatchesQuery(queryOne, false);
149+
mediaMatcher.setMatchesQuery(queryTwo, false);
150+
151+
tick();
152+
expect(observer).toHaveBeenCalledTimes(1);
153+
}));
154+
137155
it('should not complete other subscribers when preceding subscriber completes', fakeAsync(() => {
138156
const queryOne = '(width: 700px)';
139157
const queryTwo = '(width: 999px)';
140-
const breakpoint = breakpointObserver.observe([queryOne, queryTwo]);
158+
const breakpoint = breakpointObserver.observe([queryOne, queryTwo]).pipe(skip(1));
141159
const subscriptions: Subscription[] = [];
142160
let emittedValues: number[] = [];
143161

@@ -148,14 +166,14 @@ describe('BreakpointObserver', () => {
148166

149167
mediaMatcher.setMatchesQuery(queryOne, true);
150168
mediaMatcher.setMatchesQuery(queryTwo, false);
151-
flush();
169+
tick();
152170

153171
expect(emittedValues).toEqual([1, 2, 3, 4]);
154172
emittedValues = [];
155173

156174
mediaMatcher.setMatchesQuery(queryOne, false);
157175
mediaMatcher.setMatchesQuery(queryTwo, true);
158-
flush();
176+
tick();
159177

160178
expect(emittedValues).toEqual([1, 3, 4]);
161179

@@ -172,7 +190,11 @@ export class FakeMediaQueryList {
172190
/** Toggles the matches state and "emits" a change event. */
173191
setMatches(matches: boolean) {
174192
this.matches = matches;
175-
this._listeners.forEach(listener => listener(this as any));
193+
194+
/** Simulate an asynchronous task. */
195+
setTimeout(() => {
196+
this._listeners.forEach(listener => listener(this as any));
197+
});
176198
}
177199

178200
/** Registers a callback method for change events. */

src/cdk/layout/breakpoints-observer.ts

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88

99
import {Injectable, NgZone, OnDestroy} from '@angular/core';
1010
import {MediaMatcher} from './media-matcher';
11-
import {asapScheduler, combineLatest, Observable, Subject, Observer} from 'rxjs';
12-
import {debounceTime, map, startWith, takeUntil} from 'rxjs/operators';
11+
import {combineLatest, concat, Observable, Subject, Observer} from 'rxjs';
12+
import {debounceTime, map, skip, startWith, take, takeUntil} from 'rxjs/operators';
1313
import {coerceArray} from '@angular/cdk/coercion';
1414

1515

@@ -75,19 +75,22 @@ export class BreakpointObserver implements OnDestroy {
7575
const queries = splitQueries(coerceArray(value));
7676
const observables = queries.map(query => this._registerQuery(query).observable);
7777

78-
return combineLatest(observables).pipe(
79-
debounceTime(0, asapScheduler),
80-
map((breakpointStates: InternalBreakpointState[]) => {
81-
const response: BreakpointState = {
82-
matches: false,
83-
breakpoints: {},
84-
};
85-
breakpointStates.forEach((state: InternalBreakpointState) => {
86-
response.matches = response.matches || state.matches;
87-
response.breakpoints[state.query] = state.matches;
88-
});
89-
return response;
90-
}));
78+
let stateObservable = combineLatest(observables);
79+
// Emit the first state immediately, and then debounce the subsequent emissions.
80+
stateObservable = concat(
81+
stateObservable.pipe(take(1)),
82+
stateObservable.pipe(skip(1), debounceTime(0)));
83+
return stateObservable.pipe(map((breakpointStates: InternalBreakpointState[]) => {
84+
const response: BreakpointState = {
85+
matches: false,
86+
breakpoints: {},
87+
};
88+
breakpointStates.forEach((state: InternalBreakpointState) => {
89+
response.matches = response.matches || state.matches;
90+
response.breakpoints[state.query] = state.matches;
91+
});
92+
return response;
93+
}));
9194
}
9295

9396
/** Registers a specific query to be listened for. */

0 commit comments

Comments
 (0)