Skip to content

feat(observers): allow for mutation observer options to be customized in content observer #13842

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 35 additions & 4 deletions src/cdk/observers/observe-content.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,10 +174,8 @@ describe('ContentObserver injectable', () => {
const fixture = TestBed.createComponent(UnobservedComponentWithTextContent);
fixture.detectChanges();

const sub1 = contentObserver.observe(fixture.componentInstance.contentEl)
.subscribe(() => spy());
contentObserver.observe(fixture.componentInstance.contentEl)
.subscribe(() => spy());
const sub1 = contentObserver.observe(fixture.componentInstance.contentEl).subscribe(spy);
const sub2 = contentObserver.observe(fixture.componentInstance.contentEl).subscribe(spy);

expect(mof.create).toHaveBeenCalledTimes(1);

Expand All @@ -192,7 +190,40 @@ describe('ContentObserver injectable', () => {
invokeCallbacks();

expect(spy).toHaveBeenCalledTimes(1);
sub2.unsubscribe();
})));


it('should create multiple observers when observing with different options',
fakeAsync(inject([MutationObserverFactory], (mof: MutationObserverFactory) => {
const spy = jasmine.createSpy('content observer');
spyOn(mof, 'create').and.callThrough();
const fixture = TestBed.createComponent(UnobservedComponentWithTextContent);
fixture.detectChanges();

const sub1 = contentObserver.observe(fixture.componentInstance.contentEl, {
characterData: true
}).subscribe(spy);
const sub2 = contentObserver.observe(fixture.componentInstance.contentEl, {
childList: true
}).subscribe(spy);

expect(mof.create).toHaveBeenCalledTimes(2);

fixture.componentInstance.text = 'text';
invokeCallbacks();

expect(spy).toHaveBeenCalledTimes(2);

fixture.componentInstance.text = 'text text';
invokeCallbacks();

expect(spy).toHaveBeenCalledTimes(4);

sub1.unsubscribe();
sub2.unsubscribe();
})));

});
});

Expand Down
113 changes: 85 additions & 28 deletions src/cdk/observers/observe-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,40 +38,49 @@ export class MutationObserverFactory {
@Injectable({providedIn: 'root'})
export class ContentObserver implements OnDestroy {
/** Keeps track of the existing MutationObservers so they can be reused. */
private _observedElements = new Map<Element, {
private _observedElements = new Map<Element, Map<string, {
observer: MutationObserver | null,
stream: Subject<MutationRecord[]>,
count: number
}>();
}>>();

constructor(private _mutationObserverFactory: MutationObserverFactory) {}

ngOnDestroy() {
this._observedElements.forEach((_, element) => this._cleanupObserver(element));
this._observedElements.forEach((cache, element) => {
cache.forEach((_, key) => this._cleanupObserver(element, key));
});
}

/**
* Observe content changes on an element.
* @param element The element to observe for content changes.
* @param options Options that can be used to configure what is being observed,
*/
observe(element: Element): Observable<MutationRecord[]>;
observe(element: Element, options?: MutationObserverInit): Observable<MutationRecord[]>;

/**
* Observe content changes on an element.
* @param element The element to observe for content changes.
* @param options Options that can be used to configure what is being observed,
*/
observe(element: ElementRef<Element>): Observable<MutationRecord[]>;
observe(element: ElementRef<Element>, options?: MutationObserverInit):
Observable<MutationRecord[]>;

observe(elementOrRef: Element | ElementRef<Element>): Observable<MutationRecord[]> {
observe(elementOrRef: Element | ElementRef<Element>, options: MutationObserverInit = {
characterData: true,
childList: true,
subtree: true
}): Observable<MutationRecord[]> {
const element = coerceElement(elementOrRef);

return new Observable((observer: Observer<MutationRecord[]>) => {
const stream = this._observeElement(element);
const stream = this._observeElement(element, options);
const subscription = stream.subscribe(observer);

return () => {
subscription.unsubscribe();
this._unobserveElement(element);
this._unobserveElement(element, options);
};
});
}
Expand All @@ -80,47 +89,95 @@ export class ContentObserver implements OnDestroy {
* Observes the given element by using the existing MutationObserver if available, or creating a
* new one if not.
*/
private _observeElement(element: Element): Subject<MutationRecord[]> {
if (!this._observedElements.has(element)) {
private _observeElement(element: Element, options: MutationObserverInit):
Subject<MutationRecord[]> {

const observedElements = this._observedElements;
const cacheKey = this._getCacheKey(options);
let elementEntry = observedElements.get(element);

if (!elementEntry) {
elementEntry = new Map();
observedElements.set(element, elementEntry);
}

const cachedConfig = elementEntry.get(cacheKey);

if (cachedConfig) {
cachedConfig.count++;
return cachedConfig.stream;
} else {
const stream = new Subject<MutationRecord[]>();
const observer = this._mutationObserverFactory.create(mutations => stream.next(mutations));

if (observer) {
observer.observe(element, {
characterData: true,
childList: true,
subtree: true
});
observer.observe(element, options);
}
this._observedElements.set(element, {observer, stream, count: 1});
} else {
this._observedElements.get(element)!.count++;

elementEntry.set(cacheKey, {observer, stream, count: 1});
return stream;
}
return this._observedElements.get(element)!.stream;
}

/**
* Un-observes the given element and cleans up the underlying MutationObserver if nobody else is
* observing this element.
*/
private _unobserveElement(element: Element) {
if (this._observedElements.has(element)) {
this._observedElements.get(element)!.count--;
if (!this._observedElements.get(element)!.count) {
this._cleanupObserver(element);
private _unobserveElement(element: Element, options: MutationObserverInit) {
const cacheKey = this._getCacheKey(options);
const cachedConfig = this._getConfig(element, cacheKey);

if (cachedConfig) {
cachedConfig.count--;

if (cachedConfig.count < 1) {
this._cleanupObserver(element, cacheKey);
}
}
}

/** Clean up the underlying MutationObserver for the specified element. */
private _cleanupObserver(element: Element) {
if (this._observedElements.has(element)) {
const {observer, stream} = this._observedElements.get(element)!;
private _cleanupObserver(element: Element, cacheKey: string) {
const cachedConfig = this._getConfig(element, cacheKey);

if (cachedConfig) {
const {observer, stream} = cachedConfig;

if (observer) {
observer.disconnect();
}

stream.complete();
this._observedElements.delete(element);
this._observedElements.get(element)!.delete(cacheKey);

if (this._observedElements.get(element)!.size < 1) {
this._observedElements.delete(element);
}
}
}

/** Gets the cached config for an element, based on a cache key. */
private _getConfig(element: Element, cacheKey: string) {
const elementEntry = this._observedElements.get(element);

if (elementEntry) {
return elementEntry.get(cacheKey);
}

return undefined;
}

/** Generates a key for the element cache from a MutationObserver configuration object. */
private _getCacheKey(options: MutationObserverInit): string {
return [
options.attributeFilter ? options.attributeFilter.join(',') : '',
!!options.attributeOldValue,
!!options.attributes,
!!options.characterData,
!!options.characterDataOldValue,
!!options.childList,
!!options.subtree
].join('|');
}
}

Expand Down
4 changes: 2 additions & 2 deletions tools/public_api_guard/cdk/observers.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ export declare class CdkObserveContent implements AfterContentInit, OnDestroy {
export declare class ContentObserver implements OnDestroy {
constructor(_mutationObserverFactory: MutationObserverFactory);
ngOnDestroy(): void;
observe(element: Element): Observable<MutationRecord[]>;
observe(element: ElementRef<Element>): Observable<MutationRecord[]>;
observe(element: Element, options?: MutationObserverInit): Observable<MutationRecord[]>;
observe(element: ElementRef<Element>, options?: MutationObserverInit): Observable<MutationRecord[]>;
static ɵfac: i0.ɵɵFactoryDef<ContentObserver>;
static ɵprov: i0.ɵɵInjectableDef<ContentObserver>;
}
Expand Down