Skip to content

Commit

Permalink
Merge pull request #71 from cloudnc/feat/ng-on-changes-type-safety
Browse files Browse the repository at this point in the history
feat: add ngOnChanges type safety for inputs
  • Loading branch information
maxime1992 authored Feb 2, 2024
2 parents 5409e23 + 6da92b7 commit 1e90976
Show file tree
Hide file tree
Showing 3 changed files with 75 additions and 16 deletions.
23 changes: 20 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,8 @@ import { getObservableLifecycle } from 'ngx-observable-lifecycle';
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ChildComponent {
@Input() input: number | undefined | null;
@Input() input1: number | undefined | null;
@Input() input2: string | undefined | null;
constructor() {
const {
Expand All @@ -106,16 +107,32 @@ export class ChildComponent {
ngAfterViewInit,
ngAfterViewChecked,
ngOnDestroy,
} = getObservableLifecycle(this);
} =
// specifying the generics is only needed if you intend to
// use the `ngOnChanges` observable, this way you'll have
// typed input values instead of just a `SimpleChange`
getObservableLifecycle<ChildComponent, 'input1' | 'input2'>(this);
ngOnChanges.subscribe(() => console.count('onChanges'));
ngOnInit.subscribe(() => console.count('onInit'));
ngDoCheck.subscribe(() => console.count('doCheck'));
ngAfterContentInit.subscribe(() => console.count('afterContentInit'));
ngAfterContentChecked.subscribe(() => console.count('afterContentChecked'));
ngAfterViewInit.subscribe(() => console.count('afterViewInit'));
ngAfterViewChecked.subscribe(() => console.count('afterViewChecked'));
ngOnDestroy.subscribe(() => console.count('onDestroy'));
ngOnChanges.subscribe(changes => {
console.count('onChanges');
// do note that we have a type safe object here for `changes`
// with the inputs from our component and their associated values typed accordingly
changes.input1?.currentValue; // `number | null | undefined`
changes.input1?.previousValue; // `number | null | undefined`
changes.input2?.currentValue; // `string | null | undefined`
changes.input2?.previousValue; // `string | null | undefined`
});
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,24 +26,47 @@ export type LifecycleHookKey = keyof AllHooks;
type AllHookOptions = Record<LifecycleHookKey, true>;
type DecorateHookOptions = Partial<AllHookOptions>;

export interface TypedSimpleChange<Data> {
previousValue: Data;
currentValue: Data;
firstChange: boolean;
}

/**
* FIRST POINT:
* the key is made optional because an ngOnChanges will only give keys of inputs that have changed
* SECOND POINT:
* the value is associated with `| null` as if an input value is defined but actually retrieved with
* an `async` pipe, we'll initially get a `null` value
*
* For both point, feel free to check the following stackblitz that demo this
* https://stackblitz.com/edit/stackblitz-starters-s5uphw?file=src%2Fmain.ts
*/
export type TypedSimpleChanges<Component, Keys extends keyof Component> = {
[Key in Keys]?: TypedSimpleChange<Component[Key]> | null;
};

// 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 DecoratedHooks<Component = any, Keys extends keyof Component = any> = Record<
Exclude<LifecycleHookKey, 'ngOnChanges'>,
Observable<void>
> & {
ngOnChanges: Observable<TypedSimpleChanges<Component, Keys>>;
};
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>;
type PatchedComponentInstance<Hooks extends LifecycleHookKey = any> = Pick<AllHooks, Hooks> & {
[hookSubject]: Pick<DecoratedHooksSub, Hooks>;
constructor: {
prototype: {
[hooksPatched]: Pick<DecorateHookOptions, K>;
[hooksPatched]: Pick<DecorateHookOptions, Hooks>;
};
};
};

function getSubjectForHook(componentInstance: PatchedComponentInstance<any>, hook: LifecycleHookKey): Subject<void> {
function getSubjectForHook(componentInstance: PatchedComponentInstance, hook: LifecycleHookKey): Subject<void> {
if (!componentInstance[hookSubject]) {
componentInstance[hookSubject] = {};
}
Expand Down Expand Up @@ -87,10 +110,12 @@ function getSubjectForHook(componentInstance: PatchedComponentInstance<any>, hoo
/**
* Library authors should use this to create their own lifecycle-aware functionality
*/
export function getObservableLifecycle(classInstance: any): DecoratedHooks {
return new Proxy({} as DecoratedHooks, {
get(target: DecoratedHooks, p: LifecycleHookKey): Observable<void> {
return getSubjectForHook(classInstance, p).asObservable();
export function getObservableLifecycle<Component, Inputs extends keyof Component = never>(
classInstance: Component,
): DecoratedHooks<Component, Inputs> {
return new Proxy({} as DecoratedHooks<Component, Inputs>, {
get(target: DecoratedHooks<Component, Inputs>, p: LifecycleHookKey): Observable<void> {
return getSubjectForHook(classInstance as unknown as PatchedComponentInstance, p).asObservable();
},
});
}
23 changes: 20 additions & 3 deletions src/app/child/child.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import { getObservableLifecycle } from 'ngx-observable-lifecycle';
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ChildComponent {
@Input() input: number | undefined | null;
@Input() input1: number | undefined | null;
@Input() input2: string | undefined | null;

constructor() {
const {
Expand All @@ -19,15 +20,31 @@ export class ChildComponent {
ngAfterViewInit,
ngAfterViewChecked,
ngOnDestroy,
} = getObservableLifecycle(this);
} =
// specifying the generics is only needed if you intend to
// use the `ngOnChanges` observable, this way you'll have
// typed input values instead of just a `SimpleChange`
getObservableLifecycle<ChildComponent, 'input1' | 'input2'>(this);

ngOnChanges.subscribe(() => console.count('onChanges'));
ngOnInit.subscribe(() => console.count('onInit'));
ngDoCheck.subscribe(() => console.count('doCheck'));
ngAfterContentInit.subscribe(() => console.count('afterContentInit'));
ngAfterContentChecked.subscribe(() => console.count('afterContentChecked'));
ngAfterViewInit.subscribe(() => console.count('afterViewInit'));
ngAfterViewChecked.subscribe(() => console.count('afterViewChecked'));
ngOnDestroy.subscribe(() => console.count('onDestroy'));

ngOnChanges.subscribe(changes => {
console.count('onChanges');

// do note that we have a type safe object here for `changes`
// with the inputs from our component and their associated values typed accordingly

changes.input1?.currentValue; // `number | null | undefined`
changes.input1?.previousValue; // `number | null | undefined`

changes.input2?.currentValue; // `string | null | undefined`
changes.input2?.previousValue; // `string | null | undefined`
});
}
}

0 comments on commit 1e90976

Please sign in to comment.