Skip to content

Commit 00a79d0

Browse files
committed
refactor(migrations): properly migrate inputs marked as optional via question mark (angular#58031)
Currently if inputs are marked as optional via the question mark syntax, we add `undefined` only if there is an explicit type. This is wrong as we should do the same if there is just an initializer. This commit fixes this. PR Close angular#58031
1 parent fb32196 commit 00a79d0

File tree

6 files changed

+91
-32
lines changed

6 files changed

+91
-32
lines changed

packages/core/schematics/migrations/signal-migration/src/convert-input/prepare_and_check.ts

Lines changed: 76 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -91,15 +91,33 @@ export function prepareAndCheckForConversion(
9191
// If the input is using `@Input() bla?: string;` with the "optional question mark",
9292
// then we try to explicitly add `undefined` as type, if it's not part of the type already.
9393
// This is ensuring correctness, as `bla?` automatically includes `undefined` currently.
94-
if (
95-
node.type !== undefined &&
96-
node.questionToken !== undefined &&
97-
!checker.isTypeAssignableTo(checker.getUndefinedType(), checker.getTypeFromTypeNode(node.type))
98-
) {
99-
typeToAdd = ts.factory.createUnionTypeNode([
100-
node.type,
101-
ts.factory.createKeywordTypeNode(ts.SyntaxKind.UndefinedKeyword),
102-
]);
94+
if (node.questionToken !== undefined) {
95+
// If there is no type, but we have an initial value, try inferring
96+
// it from the initializer.
97+
if (typeToAdd === undefined && initialValue !== undefined) {
98+
const inferredType = inferImportableTypeForInput(checker, node, initialValue);
99+
if (inferredType !== null) {
100+
typeToAdd = inferredType;
101+
}
102+
}
103+
if (typeToAdd === undefined) {
104+
return {
105+
context: node,
106+
reason: InputIncompatibilityReason.InputWithQuestionMarkButNoGoodExplicitTypeExtractable,
107+
};
108+
}
109+
110+
if (
111+
!checker.isTypeAssignableTo(
112+
checker.getUndefinedType(),
113+
checker.getTypeFromTypeNode(typeToAdd),
114+
)
115+
) {
116+
typeToAdd = ts.factory.createUnionTypeNode([
117+
typeToAdd,
118+
ts.factory.createKeywordTypeNode(ts.SyntaxKind.UndefinedKeyword),
119+
]);
120+
}
103121
}
104122

105123
let leadingTodoText: string | null = null;
@@ -128,25 +146,9 @@ export function prepareAndCheckForConversion(
128146
// Attempt to extract type from input initial value. No explicit type, but input is required.
129147
// Hence we need an explicit type, or fall back to `typeof`.
130148
if (typeToAdd === undefined && initialValue !== undefined && metadata.required) {
131-
const propertyType = checker.getTypeAtLocation(node);
132-
if (propertyType.flags & ts.TypeFlags.Boolean) {
133-
typeToAdd = ts.factory.createKeywordTypeNode(ts.SyntaxKind.BooleanKeyword);
134-
} else if (propertyType.flags & ts.TypeFlags.String) {
135-
typeToAdd = ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword);
136-
} else if (propertyType.flags & ts.TypeFlags.Number) {
137-
typeToAdd = ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword);
138-
} else if (ts.isIdentifier(initialValue)) {
139-
// @Input({required: true}) bla = SOME_DEFAULT;
140-
typeToAdd = ts.factory.createTypeQueryNode(initialValue);
141-
} else if (
142-
ts.isPropertyAccessExpression(initialValue) &&
143-
ts.isIdentifier(initialValue.name) &&
144-
ts.isIdentifier(initialValue.expression)
145-
) {
146-
// @Input({required: true}) bla = prop.SOME_DEFAULT;
147-
typeToAdd = ts.factory.createTypeQueryNode(
148-
ts.factory.createQualifiedName(initialValue.name, initialValue.expression),
149-
);
149+
const inferredType = inferImportableTypeForInput(checker, node, initialValue);
150+
if (inferredType !== null) {
151+
typeToAdd = inferredType;
150152
} else {
151153
// Note that we could use `typeToTypeNode` here but it's likely breaking because
152154
// the generated type might depend on imports that we cannot add here (nor want).
@@ -167,3 +169,49 @@ export function prepareAndCheckForConversion(
167169
leadingTodoText,
168170
};
169171
}
172+
173+
function inferImportableTypeForInput(
174+
checker: ts.TypeChecker,
175+
node: InputNode,
176+
initialValue: ts.Node,
177+
): ts.TypeNode | null {
178+
const propertyType = checker.getTypeAtLocation(node);
179+
180+
// If the resolved type is a primitive, or union of primitive types,
181+
// return a type node fully derived from the resolved type.
182+
if (
183+
isPrimitiveImportableTypeNode(propertyType) ||
184+
(propertyType.isUnion() && propertyType.types.every(isPrimitiveImportableTypeNode))
185+
) {
186+
return checker.typeToTypeNode(propertyType, node, ts.NodeBuilderFlags.NoTypeReduction) ?? null;
187+
}
188+
189+
// Alternatively, try to infer a simple importable type from\
190+
// the initializer.
191+
192+
if (ts.isIdentifier(initialValue)) {
193+
// @Input({required: true}) bla = SOME_DEFAULT;
194+
return ts.factory.createTypeQueryNode(initialValue);
195+
} else if (
196+
ts.isPropertyAccessExpression(initialValue) &&
197+
ts.isIdentifier(initialValue.name) &&
198+
ts.isIdentifier(initialValue.expression)
199+
) {
200+
// @Input({required: true}) bla = prop.SOME_DEFAULT;
201+
return ts.factory.createTypeQueryNode(
202+
ts.factory.createQualifiedName(initialValue.name, initialValue.expression),
203+
);
204+
}
205+
206+
return null;
207+
}
208+
209+
function isPrimitiveImportableTypeNode(type: ts.Type): boolean {
210+
return !!(
211+
type.flags & ts.TypeFlags.BooleanLike ||
212+
type.flags & ts.TypeFlags.StringLike ||
213+
type.flags & ts.TypeFlags.NumberLike ||
214+
type.flags & ts.TypeFlags.Undefined ||
215+
type.flags & ts.TypeFlags.Null
216+
);
217+
}

packages/core/schematics/migrations/signal-migration/src/input_detection/incompatibility.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,11 @@ export enum InputIncompatibilityReason {
2222
SpyOnThatOverwritesField = 5,
2323
PotentiallyNarrowedInTemplateButNoSupportYet = 6,
2424
RequiredInputButNoGoodExplicitTypeExtractable = 7,
25-
WriteAssignment = 8,
26-
Accessor = 9,
27-
OutsideOfMigrationScope = 10,
28-
SkippedViaConfigFilter = 11,
25+
InputWithQuestionMarkButNoGoodExplicitTypeExtractable = 8,
26+
WriteAssignment = 9,
27+
Accessor = 10,
28+
OutsideOfMigrationScope = 11,
29+
SkippedViaConfigFilter = 12,
2930
}
3031

3132
/** Reasons why a whole class and its inputs cannot be migrated. */

packages/core/schematics/migrations/signal-migration/src/input_detection/incompatibility_human.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,13 @@ export function getMessageForInputIncompatibility(reason: InputIncompatibilityRe
5353
short: `Input is required, but the migration cannot determine a good type for the input.`,
5454
extra: 'Consider adding an explicit type to make the migration possible.',
5555
};
56+
case InputIncompatibilityReason.InputWithQuestionMarkButNoGoodExplicitTypeExtractable:
57+
return {
58+
short: `Input is marked with a question mark. Migration could not determine a good type for the input.`,
59+
extra:
60+
'The migration needs to be able to resolve a type, so that it can include `undefined` in your type. ' +
61+
'Consider adding an explicit type to make the migration possible.',
62+
};
5663
case InputIncompatibilityReason.SkippedViaConfigFilter:
5764
return {
5865
short: `This input is not part of the current migration scope.`,

packages/core/schematics/migrations/signal-migration/test/golden-test/optional_inputs.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ import {Directive, Input} from '@angular/core';
55
@Directive()
66
class OptionalInput {
77
@Input() bla?: string;
8+
@Input() isLegacyHttpOnly? = false;
89
}

packages/core/schematics/migrations/signal-migration/test/golden.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -942,6 +942,7 @@ import {Directive, input} from '@angular/core';
942942
@Directive()
943943
class OptionalInput {
944944
readonly bla = input<string>();
945+
readonly isLegacyHttpOnly = input<boolean | undefined>(false);
945946
}
946947
@@@@@@ problematic_type_reference.ts @@@@@@
947948

packages/core/schematics/migrations/signal-migration/test/golden_best_effort.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -915,6 +915,7 @@ import {Directive, input} from '@angular/core';
915915
@Directive()
916916
class OptionalInput {
917917
readonly bla = input<string>();
918+
readonly isLegacyHttpOnly = input<boolean | undefined>(false);
918919
}
919920
@@@@@@ problematic_type_reference.ts @@@@@@
920921

0 commit comments

Comments
 (0)