Skip to content

Commit ba57852

Browse files
mmalerbatinayuangao
authored andcommitted
feat(observe-content): refactor so logic can be used without directive (#11170)
* feat(observe-content): refactor so logic can be used without directive * address comments * address comments * move @Ouput() back outside the zone to prevent breaking changes
1 parent efe37f5 commit ba57852

File tree

2 files changed

+212
-57
lines changed

2 files changed

+212
-57
lines changed

src/cdk/observers/observe-content.spec.ts

Lines changed: 92 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
import {Component} from '@angular/core';
2-
import {async, TestBed, ComponentFixture, fakeAsync, tick} from '@angular/core/testing';
3-
import {ObserversModule, MutationObserverFactory} from './observe-content';
1+
import {Component, ElementRef, ViewChild} from '@angular/core';
2+
import {async, ComponentFixture, fakeAsync, inject, TestBed, tick} from '@angular/core/testing';
3+
import {ContentObserver, MutationObserverFactory, ObserversModule} from './observe-content';
44

55
// TODO(elad): `ProxyZone` doesn't seem to capture the events raised by
66
// `MutationObserver` and needs to be investigated
77

8-
describe('Observe content', () => {
8+
describe('Observe content directive', () => {
99
describe('basic usage', () => {
1010
beforeEach(async(() => {
1111
TestBed.configureTestingModule({
@@ -120,6 +120,85 @@ describe('Observe content', () => {
120120
});
121121
});
122122

123+
describe('ContentObserver injectable', () => {
124+
describe('basic usage', () => {
125+
let callbacks: Function[];
126+
let invokeCallbacks = (args?: any) => callbacks.forEach(callback => callback(args));
127+
let contentObserver: ContentObserver;
128+
129+
beforeEach(fakeAsync(() => {
130+
callbacks = [];
131+
132+
TestBed.configureTestingModule({
133+
imports: [ObserversModule],
134+
declarations: [UnobservedComponentWithTextContent],
135+
providers: [{
136+
provide: MutationObserverFactory,
137+
useValue: {
138+
create: function(callback: Function) {
139+
callbacks.push(callback);
140+
141+
return {
142+
observe: () => {},
143+
disconnect: () => {}
144+
};
145+
}
146+
}
147+
}]
148+
});
149+
150+
TestBed.compileComponents();
151+
}));
152+
153+
beforeEach(inject([ContentObserver], (co: ContentObserver) => {
154+
contentObserver = co;
155+
}));
156+
157+
it('should trigger the callback when the content of the element changes', fakeAsync(() => {
158+
const spy = jasmine.createSpy('content observer');
159+
const fixture = TestBed.createComponent(UnobservedComponentWithTextContent);
160+
fixture.detectChanges();
161+
162+
contentObserver.observe(fixture.componentInstance.contentEl.nativeElement)
163+
.subscribe(() => spy());
164+
165+
expect(spy).not.toHaveBeenCalled();
166+
167+
fixture.componentInstance.text = 'text';
168+
invokeCallbacks();
169+
170+
expect(spy).toHaveBeenCalled();
171+
}));
172+
173+
it('should only create one MutationObserver when observing the same element twice',
174+
fakeAsync(inject([MutationObserverFactory], (mof: MutationObserverFactory) => {
175+
const spy = jasmine.createSpy('content observer');
176+
spyOn(mof, 'create').and.callThrough();
177+
const fixture = TestBed.createComponent(UnobservedComponentWithTextContent);
178+
fixture.detectChanges();
179+
180+
const sub1 = contentObserver.observe(fixture.componentInstance.contentEl.nativeElement)
181+
.subscribe(() => spy());
182+
contentObserver.observe(fixture.componentInstance.contentEl.nativeElement)
183+
.subscribe(() => spy());
184+
185+
expect(mof.create).toHaveBeenCalledTimes(1);
186+
187+
fixture.componentInstance.text = 'text';
188+
invokeCallbacks();
189+
190+
expect(spy).toHaveBeenCalledTimes(2);
191+
192+
spy.calls.reset();
193+
sub1.unsubscribe();
194+
fixture.componentInstance.text = 'text text';
195+
invokeCallbacks();
196+
197+
expect(spy).toHaveBeenCalledTimes(1);
198+
})));
199+
});
200+
});
201+
123202

124203
@Component({
125204
template: `
@@ -134,7 +213,7 @@ class ComponentWithTextContent {
134213
doSomething() {}
135214
}
136215

137-
@Component({ template: `<div (cdkObserveContent)="doSomething()"><div>{{text}}<div></div>` })
216+
@Component({ template: `<div (cdkObserveContent)="doSomething()"><div>{{text}}</div></div>` })
138217
class ComponentWithChildTextContent {
139218
text = '';
140219
doSomething() {}
@@ -147,3 +226,11 @@ class ComponentWithDebouncedListener {
147226
debounce = 500;
148227
spy = jasmine.createSpy('MutationObserver callback');
149228
}
229+
230+
@Component({
231+
template: `<div #contentEl>{{text}}</div>`
232+
})
233+
class UnobservedComponentWithTextContent {
234+
@ViewChild('contentEl') contentEl: ElementRef;
235+
text = '';
236+
}

src/cdk/observers/observe-content.ts

Lines changed: 120 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {coerceBooleanProperty} from '@angular/cdk/coercion';
9+
import {coerceBooleanProperty, coerceNumberProperty} from '@angular/cdk/coercion';
1010
import {
1111
AfterContentInit,
1212
Directive,
@@ -16,12 +16,10 @@ import {
1616
Input,
1717
NgModule,
1818
NgZone,
19-
OnChanges,
2019
OnDestroy,
2120
Output,
22-
SimpleChanges,
2321
} from '@angular/core';
24-
import {Subject} from 'rxjs';
22+
import {Observable, Subject, Subscription} from 'rxjs';
2523
import {debounceTime} from 'rxjs/operators';
2624

2725
/**
@@ -35,6 +33,88 @@ export class MutationObserverFactory {
3533
}
3634
}
3735

36+
37+
/** An injectable service that allows watching elements for changes to their content. */
38+
@Injectable({providedIn: 'root'})
39+
export class ContentObserver implements OnDestroy {
40+
/** Keeps track of the existing MutationObservers so they can be reused. */
41+
private _observedElements = new Map<Element, {
42+
observer: MutationObserver | null,
43+
stream: Subject<MutationRecord[]>,
44+
count: number
45+
}>();
46+
47+
constructor(private _mutationObserverFactory: MutationObserverFactory) {}
48+
49+
ngOnDestroy() {
50+
this._observedElements.forEach((_, element) => this._cleanupObserver(element));
51+
}
52+
53+
/**
54+
* Observe content changes on an element.
55+
* @param element The element to observe for content changes.
56+
*/
57+
observe(element: Element): Observable<MutationRecord[]> {
58+
return Observable.create(observer => {
59+
const stream = this._observeElement(element);
60+
const subscription = stream.subscribe(observer);
61+
62+
return () => {
63+
subscription.unsubscribe();
64+
this._unobserveElement(element);
65+
};
66+
});
67+
}
68+
69+
/**
70+
* Observes the given element by using the existing MutationObserver if available, or creating a
71+
* new one if not.
72+
*/
73+
private _observeElement(element: Element): Subject<MutationRecord[]> {
74+
if (!this._observedElements.has(element)) {
75+
const stream = new Subject<MutationRecord[]>();
76+
const observer = this._mutationObserverFactory.create(mutations => stream.next(mutations));
77+
if (observer) {
78+
observer.observe(element, {
79+
characterData: true,
80+
childList: true,
81+
subtree: true
82+
});
83+
}
84+
this._observedElements.set(element, {observer, stream, count: 1});
85+
} else {
86+
this._observedElements.get(element)!.count++;
87+
}
88+
return this._observedElements.get(element)!.stream;
89+
}
90+
91+
/**
92+
* Un-observes the given element and cleans up the underlying MutationObserver if nobody else is
93+
* observing this element.
94+
*/
95+
private _unobserveElement(element: Element) {
96+
if (this._observedElements.has(element)) {
97+
this._observedElements.get(element)!.count--;
98+
if (!this._observedElements.get(element)!.count) {
99+
this._cleanupObserver(element);
100+
}
101+
}
102+
}
103+
104+
/** Clean up the underlying MutationObserver for the specified element. */
105+
private _cleanupObserver(element: Element) {
106+
if (this._observedElements.has(element)) {
107+
const {observer, stream} = this._observedElements.get(element)!;
108+
if (observer) {
109+
observer.disconnect();
110+
}
111+
stream.complete();
112+
this._observedElements.delete(element);
113+
}
114+
}
115+
}
116+
117+
38118
/**
39119
* Directive that triggers a callback whenever the content of
40120
* its associated element has changed.
@@ -43,10 +123,7 @@ export class MutationObserverFactory {
43123
selector: '[cdkObserveContent]',
44124
exportAs: 'cdkObserveContent',
45125
})
46-
export class CdkObserveContent implements AfterContentInit, OnChanges, OnDestroy {
47-
private _observer: MutationObserver | null;
48-
private _disabled = false;
49-
126+
export class CdkObserveContent implements AfterContentInit, OnDestroy {
50127
/** Event emitted for each change in the element's content. */
51128
@Output('cdkObserveContent') event = new EventEmitter<MutationRecord[]>();
52129

@@ -58,64 +135,55 @@ export class CdkObserveContent implements AfterContentInit, OnChanges, OnDestroy
58135
get disabled() { return this._disabled; }
59136
set disabled(value: any) {
60137
this._disabled = coerceBooleanProperty(value);
138+
if (this._disabled) {
139+
this._unsubscribe();
140+
} else {
141+
this._subscribe();
142+
}
61143
}
62-
63-
/** Used for debouncing the emitted values to the observeContent event. */
64-
private _debouncer = new Subject<MutationRecord[]>();
144+
private _disabled = false;
65145

66146
/** Debounce interval for emitting the changes. */
67-
@Input() debounce: number;
68-
69-
constructor(
70-
private _mutationObserverFactory: MutationObserverFactory,
71-
private _elementRef: ElementRef,
72-
private _ngZone: NgZone) { }
147+
@Input()
148+
get debounce(): number { return this._debounce; }
149+
set debounce(value: number) {
150+
this._debounce = coerceNumberProperty(value);
151+
this._subscribe();
152+
}
153+
private _debounce: number;
73154

74-
ngAfterContentInit() {
75-
if (this.debounce > 0) {
76-
this._ngZone.runOutsideAngular(() => {
77-
this._debouncer.pipe(debounceTime(this.debounce))
78-
.subscribe((mutations: MutationRecord[]) => this.event.emit(mutations));
79-
});
80-
} else {
81-
this._debouncer.subscribe(mutations => this.event.emit(mutations));
82-
}
155+
private _currentSubscription: Subscription | null = null;
83156

84-
this._observer = this._ngZone.runOutsideAngular(() => {
85-
return this._mutationObserverFactory.create((mutations: MutationRecord[]) => {
86-
this._debouncer.next(mutations);
87-
});
88-
});
157+
constructor(private _contentObserver: ContentObserver, private _elementRef: ElementRef,
158+
private _ngZone: NgZone) {}
89159

90-
if (!this.disabled) {
91-
this._enable();
92-
}
93-
}
94-
95-
ngOnChanges(changes: SimpleChanges) {
96-
if (changes['disabled']) {
97-
changes['disabled'].currentValue ? this._disable() : this._enable();
160+
ngAfterContentInit() {
161+
if (!this._currentSubscription && !this.disabled) {
162+
this._subscribe();
98163
}
99164
}
100165

101166
ngOnDestroy() {
102-
this._disable();
103-
this._debouncer.complete();
167+
this._unsubscribe();
104168
}
105169

106-
private _disable() {
107-
if (this._observer) {
108-
this._observer.disconnect();
109-
}
170+
private _subscribe() {
171+
this._unsubscribe();
172+
const stream = this._contentObserver.observe(this._elementRef.nativeElement);
173+
174+
// TODO(mmalerba): We shouldn't be emitting on this @Output() outside the zone.
175+
// Consider brining it back inside the zone next time we're making breaking changes.
176+
// Bringing it back inside can cause things like infinite change detection loops and changed
177+
// after checked errors if people's code isn't handling it properly.
178+
this._ngZone.runOutsideAngular(() => {
179+
this._currentSubscription =
180+
(this.debounce ? stream.pipe(debounceTime(this.debounce)) : stream).subscribe(this.event);
181+
});
110182
}
111183

112-
private _enable() {
113-
if (this._observer) {
114-
this._observer.observe(this._elementRef.nativeElement, {
115-
characterData: true,
116-
childList: true,
117-
subtree: true
118-
});
184+
private _unsubscribe() {
185+
if (this._currentSubscription) {
186+
this._currentSubscription.unsubscribe();
119187
}
120188
}
121189
}

0 commit comments

Comments
 (0)