Skip to content

Commit 0d054db

Browse files
crisbetojelbourn
authored andcommitted
fix(layout): breakpoint observer completing unrelated subscribers when preceding subscriber unsubscribes (#14988)
Fixes the `BreakpointObserver` completing subscribers that come after the current one, if one of the preceding subscribers unsubscribes. The issue seems to come from the way we store the listener in order to remove it later on. These changes switch to creating the `Observable` ourselves, rather than going through `fromEventPattern` so that we have more control over the event handler. Fixes #14983.
1 parent 312cd9f commit 0d054db

File tree

3 files changed

+77
-42
lines changed

3 files changed

+77
-42
lines changed

src/cdk/layout/BUILD.bazel

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ ng_test_library(
2020
name = "layout_test_sources",
2121
srcs = glob(["**/*.spec.ts"]),
2222
deps = [
23+
"@rxjs",
24+
"@rxjs//operators",
2325
"//src/cdk/platform",
2426
":layout",
2527
],

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

Lines changed: 62 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@ import {BreakpointObserver, BreakpointState} from './breakpoints-observer';
1111
import {MediaMatcher} from './media-matcher';
1212
import {fakeAsync, TestBed, inject, flush} from '@angular/core/testing';
1313
import {Injectable} from '@angular/core';
14+
import {Subscription} from 'rxjs';
15+
import {take} from 'rxjs/operators';
1416

1517
describe('BreakpointObserver', () => {
16-
let breakpointManager: BreakpointObserver;
18+
let breakpointObserver: BreakpointObserver;
1719
let mediaMatcher: FakeMediaMatcher;
1820

1921
beforeEach(fakeAsync(() => {
@@ -26,7 +28,7 @@ describe('BreakpointObserver', () => {
2628
beforeEach(inject(
2729
[BreakpointObserver, MediaMatcher],
2830
(bm: BreakpointObserver, mm: FakeMediaMatcher) => {
29-
breakpointManager = bm;
31+
breakpointObserver = bm;
3032
mediaMatcher = mm;
3133
}));
3234

@@ -36,48 +38,48 @@ describe('BreakpointObserver', () => {
3638

3739
it('retrieves the whether a query is currently matched', fakeAsync(() => {
3840
const query = 'everything starts as true in the FakeMediaMatcher';
39-
expect(breakpointManager.isMatched(query)).toBeTruthy();
41+
expect(breakpointObserver.isMatched(query)).toBeTruthy();
4042
}));
4143

4244
it('reuses the same MediaQueryList for matching queries', fakeAsync(() => {
4345
expect(mediaMatcher.queryCount).toBe(0);
44-
breakpointManager.observe('query1');
46+
breakpointObserver.observe('query1');
4547
expect(mediaMatcher.queryCount).toBe(1);
46-
breakpointManager.observe('query1');
48+
breakpointObserver.observe('query1');
4749
expect(mediaMatcher.queryCount).toBe(1);
48-
breakpointManager.observe('query2');
50+
breakpointObserver.observe('query2');
4951
expect(mediaMatcher.queryCount).toBe(2);
50-
breakpointManager.observe('query1');
52+
breakpointObserver.observe('query1');
5153
expect(mediaMatcher.queryCount).toBe(2);
5254
}));
5355

5456
it('splits combined query strings into individual matchMedia listeners', fakeAsync(() => {
5557
expect(mediaMatcher.queryCount).toBe(0);
56-
breakpointManager.observe('query1, query2');
58+
breakpointObserver.observe('query1, query2');
5759
expect(mediaMatcher.queryCount).toBe(2);
58-
breakpointManager.observe('query1');
60+
breakpointObserver.observe('query1');
5961
expect(mediaMatcher.queryCount).toBe(2);
60-
breakpointManager.observe('query2, query3');
62+
breakpointObserver.observe('query2, query3');
6163
expect(mediaMatcher.queryCount).toBe(3);
6264
}));
6365

6466
it('accepts an array of queries', fakeAsync(() => {
6567
const queries = ['1 query', '2 query', 'red query', 'blue query'];
66-
breakpointManager.observe(queries);
68+
breakpointObserver.observe(queries);
6769
expect(mediaMatcher.queryCount).toBe(queries.length);
6870
}));
6971

7072
it('completes all events when the breakpoint manager is destroyed', fakeAsync(() => {
7173
const firstTest = jasmine.createSpy('test1');
72-
breakpointManager.observe('test1').subscribe(undefined, undefined, firstTest);
74+
breakpointObserver.observe('test1').subscribe(undefined, undefined, firstTest);
7375
const secondTest = jasmine.createSpy('test2');
74-
breakpointManager.observe('test2').subscribe(undefined, undefined, secondTest);
76+
breakpointObserver.observe('test2').subscribe(undefined, undefined, secondTest);
7577

7678
flush();
7779
expect(firstTest).not.toHaveBeenCalled();
7880
expect(secondTest).not.toHaveBeenCalled();
7981

80-
breakpointManager.ngOnDestroy();
82+
breakpointObserver.ngOnDestroy();
8183
flush();
8284

8385
expect(firstTest).toHaveBeenCalled();
@@ -87,7 +89,7 @@ describe('BreakpointObserver', () => {
8789
it('emits an event on the observable when values change', fakeAsync(() => {
8890
const query = '(width: 999px)';
8991
let queryMatchState = false;
90-
breakpointManager.observe(query).subscribe((state: BreakpointState) => {
92+
breakpointObserver.observe(query).subscribe((state: BreakpointState) => {
9193
queryMatchState = state.matches;
9294
});
9395

@@ -103,7 +105,7 @@ describe('BreakpointObserver', () => {
103105
const queryOne = '(width: 999px)';
104106
const queryTwo = '(width: 700px)';
105107
let state: BreakpointState = {matches: false, breakpoints: {}};
106-
breakpointManager.observe([queryOne, queryTwo]).subscribe((breakpoint: BreakpointState) => {
108+
breakpointObserver.observe([queryOne, queryTwo]).subscribe((breakpoint: BreakpointState) => {
107109
state = breakpoint;
108110
});
109111

@@ -120,38 +122,72 @@ describe('BreakpointObserver', () => {
120122

121123
it('emits a true matches state when the query is matched', fakeAsync(() => {
122124
const query = '(width: 999px)';
123-
breakpointManager.observe(query).subscribe();
125+
breakpointObserver.observe(query).subscribe();
124126
mediaMatcher.setMatchesQuery(query, true);
125-
expect(breakpointManager.isMatched(query)).toBeTruthy();
127+
expect(breakpointObserver.isMatched(query)).toBeTruthy();
126128
}));
127129

128130
it('emits a false matches state when the query is not matched', fakeAsync(() => {
129131
const query = '(width: 999px)';
130-
breakpointManager.observe(query).subscribe();
132+
breakpointObserver.observe(query).subscribe();
131133
mediaMatcher.setMatchesQuery(query, false);
132-
expect(breakpointManager.isMatched(query)).toBeFalsy();
134+
expect(breakpointObserver.isMatched(query)).toBeFalsy();
135+
}));
136+
137+
it('should not complete other subscribers when preceding subscriber completes', fakeAsync(() => {
138+
const queryOne = '(width: 700px)';
139+
const queryTwo = '(width: 999px)';
140+
const breakpoint = breakpointObserver.observe([queryOne, queryTwo]);
141+
const subscriptions: Subscription[] = [];
142+
let emittedValues: number[] = [];
143+
144+
subscriptions.push(breakpoint.subscribe(() => emittedValues.push(1)));
145+
subscriptions.push(breakpoint.pipe(take(1)).subscribe(() => emittedValues.push(2)));
146+
subscriptions.push(breakpoint.subscribe(() => emittedValues.push(3)));
147+
subscriptions.push(breakpoint.subscribe(() => emittedValues.push(4)));
148+
149+
mediaMatcher.setMatchesQuery(queryOne, true);
150+
mediaMatcher.setMatchesQuery(queryTwo, false);
151+
flush();
152+
153+
expect(emittedValues).toEqual([1, 2, 3, 4]);
154+
emittedValues = [];
155+
156+
mediaMatcher.setMatchesQuery(queryOne, false);
157+
mediaMatcher.setMatchesQuery(queryTwo, true);
158+
flush();
159+
160+
expect(emittedValues).toEqual([1, 3, 4]);
161+
162+
subscriptions.forEach(subscription => subscription.unsubscribe());
133163
}));
134164
});
135165

136166
export class FakeMediaQueryList {
137167
/** The callback for change events. */
138-
addListenerCallback?: (mql: MediaQueryListEvent) => void;
168+
private _listeners: ((mql: MediaQueryListEvent) => void)[] = [];
139169

140170
constructor(public matches: boolean, public media: string) {}
141171

142172
/** Toggles the matches state and "emits" a change event. */
143173
setMatches(matches: boolean) {
144174
this.matches = matches;
145-
this.addListenerCallback!(this as any);
175+
this._listeners.forEach(listener => listener(this as any));
146176
}
147177

148-
/** Registers the callback method for change events. */
178+
/** Registers a callback method for change events. */
149179
addListener(callback: (mql: MediaQueryListEvent) => void) {
150-
this.addListenerCallback = callback;
180+
this._listeners.push(callback);
151181
}
152182

153-
// Noop removal method for testing.
154-
removeListener() { }
183+
/** Removes a callback method from the change events. */
184+
removeListener(callback: (mql: MediaQueryListEvent) => void) {
185+
const index = this._listeners.indexOf(callback);
186+
187+
if (index > -1) {
188+
this._listeners.splice(index, 1);
189+
}
190+
}
155191
}
156192

157193
@Injectable()

src/cdk/layout/breakpoints-observer.ts

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

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

@@ -99,27 +99,24 @@ export class BreakpointObserver implements OnDestroy {
9999

100100
const mql: MediaQueryList = this.mediaMatcher.matchMedia(query);
101101

102-
// TODO(jelbourn): change this `any` to `MediaQueryListEvent` once Google has upgraded to
103-
// TypeScript 3.1 (the type is unavailable before then).
104-
let queryListener: any;
105-
106102
// Create callback for match changes and add it is as a listener.
107-
const queryObservable = fromEventPattern<MediaQueryList>(
103+
const queryObservable = new Observable<MediaQueryList>((observer: Observer<MediaQueryList>) => {
108104
// Listener callback methods are wrapped to be placed back in ngZone. Callbacks must be placed
109105
// back into the zone because matchMedia is only included in Zone.js by loading the
110106
// webapis-media-query.js file alongside the zone.js file. Additionally, some browsers do not
111107
// have MediaQueryList inherit from EventTarget, which causes inconsistencies in how Zone.js
112108
// patches it.
113-
(listener: Function) => {
114-
queryListener = (e: any) => this.zone.run(() => listener(e));
115-
mql.addListener(queryListener);
116-
},
117-
() => mql.removeListener(queryListener))
118-
.pipe(
119-
startWith(mql),
120-
map((nextMql: MediaQueryList) => ({query, matches: nextMql.matches})),
121-
takeUntil(this._destroySubject)
122-
);
109+
const handler = (e: any) => this.zone.run(() => observer.next(e));
110+
mql.addListener(handler);
111+
112+
return () => {
113+
mql.removeListener(handler);
114+
};
115+
}).pipe(
116+
startWith(mql),
117+
map((nextMql: MediaQueryList) => ({query, matches: nextMql.matches})),
118+
takeUntil(this._destroySubject)
119+
);
123120

124121
// Add the MediaQueryList to the set of queries.
125122
const output = {observable: queryObservable, mql};

0 commit comments

Comments
 (0)