Skip to content

Commit ea9293d

Browse files
authored
fix(scrolling): viewport ruler events being run inside zone (#15814)
Fixes the `resize` and `orientationchange` events of the `ViewportRuler` being run inside the `NgZone`, if the subscription comes from inside the zone. Fixes #18471.
1 parent b3099c8 commit ea9293d

File tree

2 files changed

+54
-35
lines changed

2 files changed

+54
-35
lines changed

src/cdk/scrolling/viewport-ruler.spec.ts

Lines changed: 29 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,11 @@ import {ScrollingModule} from './public-api';
33
import {ViewportRuler} from './viewport-ruler';
44
import {dispatchFakeEvent} from '@angular/cdk/testing/private';
55
import {NgZone} from '@angular/core';
6-
7-
// For all tests, we assume the browser window is 1024x786 (outerWidth x outerHeight).
8-
// The karma config has been set to this for local tests, and it is the default size
9-
// for tests on CI (both SauceLabs and Browserstack).
10-
11-
// While we know the *outer* window width/height, the innerWidth and innerHeight depend on the
12-
// the size of the individual browser's chrome, so we have to use window.innerWidth and
13-
// window.innerHeight in the unit test instead of hard-coded values.
6+
import {Subscription} from 'rxjs';
147

158
describe('ViewportRuler', () => {
16-
let ruler: ViewportRuler;
9+
let viewportRuler: ViewportRuler;
10+
let ngZone: NgZone;
1711

1812
let startingWindowWidth = window.innerWidth;
1913
let startingWindowHeight = window.innerHeight;
@@ -28,23 +22,24 @@ describe('ViewportRuler', () => {
2822
providers: [ViewportRuler]
2923
}));
3024

31-
beforeEach(inject([ViewportRuler], (viewportRuler: ViewportRuler) => {
32-
ruler = viewportRuler;
25+
beforeEach(inject([ViewportRuler, NgZone], (v: ViewportRuler, n: NgZone) => {
26+
viewportRuler = v;
27+
ngZone = n;
3328
scrollTo(0, 0);
3429
}));
3530

3631
afterEach(() => {
37-
ruler.ngOnDestroy();
32+
viewportRuler.ngOnDestroy();
3833
});
3934

4035
it('should get the viewport size', () => {
41-
let size = ruler.getViewportSize();
36+
let size = viewportRuler.getViewportSize();
4237
expect(size.width).toBe(window.innerWidth);
4338
expect(size.height).toBe(window.innerHeight);
4439
});
4540

4641
it('should get the viewport bounds when the page is not scrolled', () => {
47-
let bounds = ruler.getViewportRect();
42+
let bounds = viewportRuler.getViewportRect();
4843
expect(bounds.top).toBe(0);
4944
expect(bounds.left).toBe(0);
5045
expect(bounds.bottom).toBe(window.innerHeight);
@@ -56,7 +51,7 @@ describe('ViewportRuler', () => {
5651

5752
scrollTo(1500, 2000);
5853

59-
let bounds = ruler.getViewportRect();
54+
let bounds = viewportRuler.getViewportRect();
6055

6156
// In the iOS simulator (BrowserStack & SauceLabs), adding the content to the
6257
// body causes karma's iframe for the test to stretch to fit that content once we attempt to
@@ -82,7 +77,7 @@ describe('ViewportRuler', () => {
8277
});
8378

8479
it('should get the scroll position when the page is not scrolled', () => {
85-
let scrollPos = ruler.getViewportScrollPosition();
80+
let scrollPos = viewportRuler.getViewportScrollPosition();
8681
expect(scrollPos.top).toBe(0);
8782
expect(scrollPos.left).toBe(0);
8883
});
@@ -102,7 +97,7 @@ describe('ViewportRuler', () => {
10297
return;
10398
}
10499

105-
let scrollPos = ruler.getViewportScrollPosition();
100+
let scrollPos = viewportRuler.getViewportScrollPosition();
106101
expect(scrollPos.top).toBe(2000);
107102
expect(scrollPos.left).toBe(1500);
108103

@@ -112,7 +107,7 @@ describe('ViewportRuler', () => {
112107
describe('changed event', () => {
113108
it('should dispatch an event when the window is resized', () => {
114109
const spy = jasmine.createSpy('viewport changed spy');
115-
const subscription = ruler.change(0).subscribe(spy);
110+
const subscription = viewportRuler.change(0).subscribe(spy);
116111

117112
dispatchFakeEvent(window, 'resize');
118113
expect(spy).toHaveBeenCalled();
@@ -121,7 +116,7 @@ describe('ViewportRuler', () => {
121116

122117
it('should dispatch an event when the orientation is changed', () => {
123118
const spy = jasmine.createSpy('viewport changed spy');
124-
const subscription = ruler.change(0).subscribe(spy);
119+
const subscription = viewportRuler.change(0).subscribe(spy);
125120

126121
dispatchFakeEvent(window, 'orientationchange');
127122
expect(spy).toHaveBeenCalled();
@@ -130,7 +125,7 @@ describe('ViewportRuler', () => {
130125

131126
it('should be able to throttle the callback', fakeAsync(() => {
132127
const spy = jasmine.createSpy('viewport changed spy');
133-
const subscription = ruler.change(1337).subscribe(spy);
128+
const subscription = viewportRuler.change(1337).subscribe(spy);
134129

135130
dispatchFakeEvent(window, 'resize');
136131
expect(spy).not.toHaveBeenCalled();
@@ -143,12 +138,25 @@ describe('ViewportRuler', () => {
143138

144139
it('should run the resize event outside the NgZone', () => {
145140
const spy = jasmine.createSpy('viewport changed spy');
146-
const subscription = ruler.change(0).subscribe(() => spy(NgZone.isInAngularZone()));
141+
const subscription = viewportRuler.change(0).subscribe(() => spy(NgZone.isInAngularZone()));
147142

148143
dispatchFakeEvent(window, 'resize');
149144
expect(spy).toHaveBeenCalledWith(false);
150145
subscription.unsubscribe();
151146
});
152147

148+
it('should run events outside of the NgZone, even if the subcription is from inside', () => {
149+
const spy = jasmine.createSpy('viewport changed spy');
150+
let subscription: Subscription;
151+
152+
ngZone.run(() => {
153+
subscription = viewportRuler.change(0).subscribe(() => spy(NgZone.isInAngularZone()));
154+
dispatchFakeEvent(window, 'resize');
155+
});
156+
157+
expect(spy).toHaveBeenCalledWith(false);
158+
subscription!.unsubscribe();
159+
});
160+
153161
});
154162
});

src/cdk/scrolling/viewport-ruler.ts

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

99
import {Platform} from '@angular/cdk/platform';
1010
import {Injectable, NgZone, OnDestroy, Optional, Inject} from '@angular/core';
11-
import {merge, of as observableOf, fromEvent, Observable, Subscription} from 'rxjs';
11+
import {Observable, Subject} from 'rxjs';
1212
import {auditTime} from 'rxjs/operators';
1313
import {DOCUMENT} from '@angular/common';
1414

@@ -31,10 +31,12 @@ export class ViewportRuler implements OnDestroy {
3131
private _viewportSize: {width: number; height: number};
3232

3333
/** Stream of viewport change events. */
34-
private _change: Observable<Event>;
34+
private _change = new Subject<Event>();
3535

36-
/** Subscription to streams that invalidate the cached viewport dimensions. */
37-
private _invalidateCache: Subscription;
36+
/** Event listener that will be used to handle the viewport change events. */
37+
private _changeListener = (event: Event) => {
38+
this._change.next(event);
39+
}
3840

3941
/** Used to reference correct document/window */
4042
protected _document?: Document;
@@ -46,20 +48,29 @@ export class ViewportRuler implements OnDestroy {
4648
this._document = document;
4749

4850
ngZone.runOutsideAngular(() => {
49-
const window = this._getWindow();
50-
51-
this._change = _platform.isBrowser ?
52-
merge(fromEvent(window, 'resize'), fromEvent(window, 'orientationchange')) :
53-
observableOf();
54-
55-
// Note that we need to do the subscription inside `runOutsideAngular`
56-
// since subscribing is what causes the event listener to be added.
57-
this._invalidateCache = this.change().subscribe(() => this._updateViewportSize());
51+
if (_platform.isBrowser) {
52+
const window = this._getWindow();
53+
54+
// Note that bind the events ourselves, rather than going through something like RxJS's
55+
// `fromEvent` so that we can ensure that they're bound outside of the NgZone.
56+
window.addEventListener('resize', this._changeListener);
57+
window.addEventListener('orientationchange', this._changeListener);
58+
}
59+
60+
// We don't need to keep track of the subscription,
61+
// because we complete the `change` stream on destroy.
62+
this.change().subscribe(() => this._updateViewportSize());
5863
});
5964
}
6065

6166
ngOnDestroy() {
62-
this._invalidateCache.unsubscribe();
67+
if (this._platform.isBrowser) {
68+
const window = this._getWindow();
69+
window.removeEventListener('resize', this._changeListener);
70+
window.removeEventListener('orientationchange', this._changeListener);
71+
}
72+
73+
this._change.complete();
6374
}
6475

6576
/** Returns the viewport's width and height. */

0 commit comments

Comments
 (0)