Skip to content

Commit

Permalink
Merge pull request #63 from maxime1992/fix/ngonchanges
Browse files Browse the repository at this point in the history
fix(Hooks): original ngOnChanges not receiving the SimpleChanges and provide the SimpleChanges to our observable
  • Loading branch information
maxime1992 authored Jan 31, 2024
2 parents 17fe079 + 36eae24 commit a5a0135
Show file tree
Hide file tree
Showing 2 changed files with 95 additions and 19 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,17 @@ import {
ChangeDetectionStrategy,
Component,
DoCheck,
Input,
OnChanges,
OnDestroy,
OnInit,
SimpleChange,
SimpleChanges,
} from '@angular/core';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { getObservableLifecycle } from 'ngx-observable-lifecycle';
import { mapTo } from 'rxjs/operators';
import { map } from 'rxjs/operators';

describe('integration', () => {
type ObserverSpy = {
Expand Down Expand Up @@ -59,6 +62,8 @@ describe('integration', () => {
AfterContentChecked,
AfterContentInit
{
@Input() input: any;

public componentInstanceId = componentInstanceId++;

public ngAfterContentChecked(): void {
Expand All @@ -81,8 +86,8 @@ describe('integration', () => {
ngDoCheckSpy();
}

public ngOnChanges(): void {
ngOnChangesSpy();
public ngOnChanges(simpleChanges: SimpleChanges): void {
ngOnChangesSpy(simpleChanges);
}

public ngOnDestroy(): void {
Expand All @@ -105,14 +110,16 @@ describe('integration', () => {
ngOnDestroy,
} = getObservableLifecycle(this);

ngOnChanges.pipe(mapTo(this.componentInstanceId)).subscribe(onChanges$Spy);
ngOnInit.pipe(mapTo(this.componentInstanceId)).subscribe(onInit$Spy);
ngDoCheck.pipe(mapTo(this.componentInstanceId)).subscribe(doCheck$Spy);
ngAfterContentInit.pipe(mapTo(this.componentInstanceId)).subscribe(afterContentInit$Spy);
ngAfterContentChecked.pipe(mapTo(this.componentInstanceId)).subscribe(afterContentChecked$Spy);
ngAfterViewInit.pipe(mapTo(this.componentInstanceId)).subscribe(afterViewInit$Spy);
ngAfterViewChecked.pipe(mapTo(this.componentInstanceId)).subscribe(afterViewChecked$Spy);
ngOnDestroy.pipe(mapTo(this.componentInstanceId)).subscribe(onDestroy$Spy);
const instanceId = this.componentInstanceId;

ngOnChanges.pipe(map(value => ({ instanceId, value }))).subscribe(onChanges$Spy);
ngOnInit.pipe(map(() => ({ instanceId }))).subscribe(onInit$Spy);
ngDoCheck.pipe(map(() => ({ instanceId }))).subscribe(doCheck$Spy);
ngAfterContentInit.pipe(map(() => ({ instanceId }))).subscribe(afterContentInit$Spy);
ngAfterContentChecked.pipe(map(() => ({ instanceId }))).subscribe(afterContentChecked$Spy);
ngAfterViewInit.pipe(map(() => ({ instanceId }))).subscribe(afterViewInit$Spy);
ngAfterViewChecked.pipe(map(() => ({ instanceId }))).subscribe(afterViewChecked$Spy);
ngOnDestroy.pipe(map(() => ({ instanceId }))).subscribe(onDestroy$Spy);
}
}

Expand All @@ -131,14 +138,37 @@ describe('integration', () => {
}
}

@Component({
selector: 'lib-host-with-input-component',
template: `
<h1>Host with input Component</h1>
<lib-test-component *ngIf="testComponentVisible" [input]="inputValue"></lib-test-component>
`,
})
class HostWithInputComponent {
public testComponentVisible = false;

public inputValue = undefined;

public setTestComponentVisible(visible: boolean) {
this.testComponentVisible = visible;
}

public setInputValue(value: any) {
this.inputValue = value;
}
}

let component: HostComponent;
let componentWithInput: HostWithInputComponent;
let fixture: ComponentFixture<HostComponent>;
let fixtureWithInput: ComponentFixture<HostWithInputComponent>;

beforeEach(
waitForAsync(() => {
TestBed.configureTestingModule({
imports: [CommonModule],
declarations: [HostComponent, TestComponent],
declarations: [HostComponent, HostWithInputComponent, TestComponent],
}).compileComponents();
}),
);
Expand Down Expand Up @@ -167,6 +197,10 @@ describe('integration', () => {
fixture = TestBed.createComponent(HostComponent);
component = fixture.componentInstance;
fixture.detectChanges();

fixtureWithInput = TestBed.createComponent(HostWithInputComponent);
componentWithInput = fixtureWithInput.componentInstance;
fixtureWithInput.detectChanges();
});

it('should be created', () => {
Expand Down Expand Up @@ -239,10 +273,42 @@ describe('integration', () => {

newInstance.ngOnDestroy();

expect(onDestroy$Spy.next).toHaveBeenCalledWith(newInstance.componentInstanceId);
expect(onDestroy$Spy.next).toHaveBeenCalledWith({ instanceId: newInstance.componentInstanceId });

const componentUnderTest = fixture.debugElement.query(By.directive(TestComponent)).componentInstance;

expect(onDestroy$Spy.next).not.toHaveBeenCalledWith(componentUnderTest.componentInstanceId);
expect(onDestroy$Spy.next).not.toHaveBeenCalledWith({ instanceId: componentUnderTest.componentInstanceId });
});

it('should still receive the SimpleChanges object in the ngOnChanges original hook and provide the SimpleChanges into the stream as well', () => {
expect(onChanges$Spy.next).not.toHaveBeenCalled();
expect(ngOnChangesSpy).not.toHaveBeenCalled();
componentWithInput.setTestComponentVisible(true);
fixtureWithInput.detectChanges();

expect(onChanges$Spy.next).toHaveBeenCalledOnceWith({
instanceId: jasmine.anything(),
value: {
input: new SimpleChange(undefined, undefined, true),
},
});
expect(ngOnChangesSpy).toHaveBeenCalledOnceWith({
input: new SimpleChange(undefined, undefined, true),
});

componentWithInput.setInputValue('New value');
fixtureWithInput.detectChanges();

expect(onChanges$Spy.next).toHaveBeenCalledTimes(2);
expect(onChanges$Spy.next).toHaveBeenCalledWith({
instanceId: jasmine.anything(),
value: {
input: new SimpleChange(undefined, 'New value', false),
},
});
expect(ngOnChangesSpy).toHaveBeenCalledTimes(2);
expect(ngOnChangesSpy).toHaveBeenCalledWith({
input: new SimpleChange(undefined, 'New value', false),
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,13 @@ export type LifecycleHookKey = keyof AllHooks;
type AllHookOptions = Record<LifecycleHookKey, true>;
type DecorateHookOptions = Partial<AllHookOptions>;

export type DecoratedHooks = Record<LifecycleHookKey, Observable<void>>;
export type DecoratedHooksSub = Record<LifecycleHookKey, Subject<void>>;
// none of the hooks have arguments, EXCEPT ngOnChanges which we need to handle differently
export type DecoratedHooks = Record<Exclude<LifecycleHookKey, 'ngOnChanges'>, Observable<void>> & {
ngOnChanges: Observable<Parameters<OnChanges['ngOnChanges']>[0]>;
};
export type DecoratedHooksSub = {
[k in keyof DecoratedHooks]: DecoratedHooks[k] extends Observable<infer U> ? Subject<U> : never;
};

type PatchedComponentInstance<K extends LifecycleHookKey> = Pick<AllHooks, K> & {
[hookSubject]: Pick<DecoratedHooksSub, K>;
Expand Down Expand Up @@ -55,9 +60,14 @@ function getSubjectForHook(componentInstance: PatchedComponentInstance<any>, hoo
if (!proto[hooksPatched][hook]) {
const originalHook = proto[hook];

proto[hook] = function (this: PatchedComponentInstance<typeof hook>) {
(originalHook as () => void)?.call(this);
this[hookSubject]?.[hook]?.next();
proto[hook] = function (...args: any[]) {
originalHook?.call(this, ...args);

if (hook === 'ngOnChanges') {
this[hookSubject]?.[hook]?.next(args[0]);
} else {
this[hookSubject]?.[hook]?.next();
}
};

const originalOnDestroy = proto.ngOnDestroy;
Expand Down

0 comments on commit a5a0135

Please sign in to comment.