Skip to content

Commit c2d376b

Browse files
crisbetothePunderWoman
authored andcommitted
feat(core): make SimpleChanges generic (#64535)
Currently it's easy to make a mistake when accessing properties on `SimpleChanges`, because the keys aren't typed. These changes add an optional generic to the interface so that users can get a compilation error if they make a typo. A few things to note: 1. The generic argument is optional and we revert to the old behavior if one isn't passed for backwards compatibility. 2. All of the keys are optional, because they aren't guaranteed to be present for any `ngOnChanges` invocation. 3. We unwrap the values of input signals to match the behavior at runtime. Fixes #17560. PR Close #64535
1 parent f1f24b9 commit c2d376b

File tree

6 files changed

+113
-16
lines changed

6 files changed

+113
-16
lines changed

goldens/public-api/core/index.api.md

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1726,22 +1726,25 @@ export type Signal<T> = (() => T) & {
17261726
export function signal<T>(initialValue: T, options?: CreateSignalOptions<T>): WritableSignal<T>;
17271727

17281728
// @public
1729-
export class SimpleChange {
1730-
constructor(previousValue: any, currentValue: any, firstChange: boolean);
1729+
export class SimpleChange<T = any> {
1730+
constructor(previousValue: T, currentValue: T, firstChange: boolean);
17311731
// (undocumented)
1732-
currentValue: any;
1732+
currentValue: T;
17331733
// (undocumented)
17341734
firstChange: boolean;
17351735
isFirstChange(): boolean;
17361736
// (undocumented)
1737-
previousValue: any;
1737+
previousValue: T;
17381738
}
17391739

17401740
// @public
1741-
export interface SimpleChanges {
1742-
// (undocumented)
1741+
export type SimpleChanges<T = unknown> = T extends object ? {
1742+
[Key in keyof T]?: SimpleChange<T[Key] extends {
1743+
INPUT_SIGNAL_BRAND_READ_TYPE]: infer V;
1744+
} ? V : T[Key]>;
1745+
} : {
17431746
[propName: string]: SimpleChange;
1744-
}
1747+
};
17451748

17461749
// @public
17471750
export interface SkipSelf {

packages/core/src/change_detection/simple_change.ts

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

9+
import type {ɵINPUT_SIGNAL_BRAND_READ_TYPE} from '../authoring/input/input_signal';
10+
911
/**
1012
* Represents a basic change from a previous to a new value for a single
1113
* property on a directive instance. Passed as a value in a
@@ -15,10 +17,10 @@
1517
*
1618
* @publicApi
1719
*/
18-
export class SimpleChange {
20+
export class SimpleChange<T = any> {
1921
constructor(
20-
public previousValue: any,
21-
public currentValue: any,
22+
public previousValue: T,
23+
public currentValue: T,
2224
public firstChange: boolean,
2325
) {}
2426
/**
@@ -32,12 +34,19 @@ export class SimpleChange {
3234
/**
3335
* A hashtable of changes represented by {@link SimpleChange} objects stored
3436
* at the declared property name they belong to on a Directive or Component. This is
35-
* the type passed to the `ngOnChanges` hook.
37+
* the type passed to the `ngOnChanges` hook. Pass the current class or `this` as the
38+
* first generic argument for stronger type checking (e.g. `SimpleChanges<YourComponent>`).
3639
*
3740
* @see {@link OnChanges}
3841
*
3942
* @publicApi
4043
*/
41-
export interface SimpleChanges {
42-
[propName: string]: SimpleChange;
43-
}
44+
export type SimpleChanges<T = unknown> = T extends object
45+
? {
46+
[Key in keyof T]?: SimpleChange<
47+
T[Key] extends {[ɵINPUT_SIGNAL_BRAND_READ_TYPE]: infer V} ? V : T[Key]
48+
>;
49+
}
50+
: {
51+
[propName: string]: SimpleChange; // Backwards-compatible signature.
52+
};

packages/core/test/authoring/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ ts_project(
88
"signal_input_signature_test.ts",
99
"signal_model_signature_test.ts",
1010
"signal_queries_signature_test.ts",
11+
"simple_changes_signature_test.ts",
1112
"unwrap_writable_signal_signature_test.ts",
1213
],
1314
deps = [
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/*!
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {Directive, input, signal, Input, model, SimpleChanges} from '@angular/core';
10+
11+
// import preserved to simplify `.d.ts` emit and simplify the `type_tester` logic.
12+
// tslint:disable-next-line no-duplicate-imports
13+
import {WritableSignal} from '@angular/core';
14+
15+
export class SimpleChangesSignatureTest {
16+
/** #ignore */
17+
private generic = TestDir.getGenericTypes();
18+
19+
/** #ignore */
20+
private nonGeneric = TestDir.getNonGenericTypes();
21+
22+
/** string | undefined */
23+
decoratorInput = this.generic.decoratorInput;
24+
25+
/** number | undefined */
26+
signalInput = this.generic.signalInput;
27+
28+
/** string | undefined */
29+
signalInputWithTransform = this.generic.signalInputWithTransform;
30+
31+
/** number | undefined */
32+
model = this.generic.model;
33+
34+
/** WritableSignal<string> */
35+
nonInputSignal = this.generic.nonInputSignal;
36+
37+
/** any */
38+
decoratorInputNonGeneric = this.nonGeneric.decoratorInput;
39+
40+
/** any */
41+
signalInputNonGeneric = this.nonGeneric.signalInput;
42+
}
43+
44+
@Directive()
45+
export class TestDir {
46+
@Input() decoratorInput = 'hello';
47+
48+
signalInput = input(1);
49+
50+
signalInputWithTransform = input('hello', {
51+
transform: (value: number) => value.toString(),
52+
});
53+
54+
model = model(1);
55+
56+
nonInputSignal = signal('hello');
57+
58+
static getGenericTypes() {
59+
const changes: SimpleChanges<TestDir> = null!;
60+
61+
return {
62+
decoratorInput: changes.decoratorInput?.currentValue,
63+
signalInput: changes.signalInput?.currentValue,
64+
signalInputWithTransform: changes.signalInputWithTransform?.currentValue,
65+
model: changes.model?.currentValue,
66+
nonInputSignal: changes.nonInputSignal!.currentValue,
67+
};
68+
}
69+
70+
static getNonGenericTypes() {
71+
const changes: SimpleChanges = null!;
72+
73+
return {
74+
decoratorInput: changes['decoratorInput'].currentValue,
75+
signalInput: changes['signalInput'].currentValue,
76+
};
77+
}
78+
}

packages/core/test/authoring/type_tester.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const TESTS = new Map<string, (value: string) => string>([
2121
['signal_queries_signature_test', (v) => `Signal<${v}>`],
2222
['signal_model_signature_test', (v) => `ModelSignal<${v}>`],
2323
['unwrap_writable_signal_signature_test', (v) => v],
24+
['simple_changes_signature_test', (v) => v],
2425
]);
2526

2627
const containingDir = path.dirname(url.fileURLToPath(import.meta.url));
@@ -61,6 +62,11 @@ function testFile(testFileName: string, getType: (v: string) => string): boolean
6162

6263
// strip comment start, and beginning (plus whitespace).
6364
const expectedTypeComment = leadingComments[0].replace(/(^\/\*\*?\s*|\s*\*+\/$)/g, '');
65+
66+
if (expectedTypeComment === '#ignore') {
67+
continue;
68+
}
69+
6470
const expectedType = getType(expectedTypeComment);
6571
// strip excess whitespace or newlines.
6672
const got = member.type?.getText().replace(/(\n+|\s\s+)/g, '');

packages/forms/test/directives_spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -706,15 +706,15 @@ describe('Form Directives', () => {
706706
tick();
707707
expect(ngModel.control.disabled).toEqual(false);
708708

709-
ngModel.ngOnChanges({isDisabled: new SimpleChange('', false, false)});
709+
ngModel.ngOnChanges({isDisabled: new SimpleChange('' as any, false, false)});
710710
tick();
711711
expect(ngModel.control.disabled).toEqual(false);
712712

713713
ngModel.ngOnChanges({isDisabled: new SimpleChange('', 'false', false)});
714714
tick();
715715
expect(ngModel.control.disabled).toEqual(false);
716716

717-
ngModel.ngOnChanges({isDisabled: new SimpleChange('', 0, false)});
717+
ngModel.ngOnChanges({isDisabled: new SimpleChange('' as any, 0, false)});
718718
tick();
719719
expect(ngModel.control.disabled).toEqual(false);
720720

0 commit comments

Comments
 (0)