Skip to content

Commit 8f8972b

Browse files
aparziamishne
authored andcommitted
feat(migrations): model + output migrations
becomes input + linkedSignal When a component has both a model() property and a conflicting output property (e.g., foo model + fooChange output), this migration converts the model() to an input() + linkedSignal() pattern to avoid naming conflicts. Fixes #67340
1 parent 97cac1c commit 8f8972b

7 files changed

Lines changed: 538 additions & 4 deletions

File tree

packages/core/schematics/BUILD.bazel

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,10 @@ bundle_entrypoints = [
141141
"strict-safe-navigation-narrow",
142142
"packages/core/schematics/migrations/strict-safe-navigation-narrow/index.js",
143143
],
144+
[
145+
"model-output",
146+
"packages/core/schematics/migrations/model-output/index.js",
147+
],
144148
]
145149

146150
rollup.rollup(
@@ -156,6 +160,7 @@ rollup.rollup(
156160
"//packages/core/schematics/migrations/change-detection-eager",
157161
"//packages/core/schematics/migrations/http-xhr-backend",
158162
"//packages/core/schematics/migrations/incremental-hydration",
163+
"//packages/core/schematics/migrations/model-output",
159164
"//packages/core/schematics/migrations/strict-safe-navigation-narrow",
160165
"//packages/core/schematics/migrations/strict-templates-default",
161166
"//packages/core/schematics/ng-generate/cleanup-unused-imports",

packages/core/schematics/migrations.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@
2929
"version": "22.0.0",
3030
"description": "Disables the 'nullishCoalescingNotNullable & optionalChainNotNullable extended diagnostics.",
3131
"factory": "./bundles/strict-safe-navigation-narrow.cjs#migrate"
32+
},
33+
"model-output": {
34+
"version": "22.0.0",
35+
"description": "Migrate broken duplicate outputs",
36+
"factory": "./bundles/model-output.cjs#migrate"
3237
}
3338
}
3439
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
load("//tools:defaults.bzl", "ts_project", "zoneless_jasmine_test")
2+
3+
package(
4+
default_visibility = [
5+
"//packages/core/schematics:__pkg__",
6+
"//packages/core/schematics/test:__pkg__",
7+
],
8+
)
9+
10+
ts_project(
11+
name = "model-output",
12+
srcs = glob(
13+
["**/*.ts"],
14+
exclude = ["*.spec.ts"],
15+
),
16+
deps = [
17+
"//:node_modules/@angular-devkit/schematics",
18+
"//:node_modules/@types/node",
19+
"//:node_modules/typescript",
20+
"//packages/compiler",
21+
"//packages/compiler-cli",
22+
"//packages/compiler-cli/private",
23+
"//packages/core/schematics/utils",
24+
"//packages/core/schematics/utils/tsurge",
25+
"//packages/core/schematics/utils/tsurge/helpers/angular_devkit",
26+
],
27+
)
28+
29+
ts_project(
30+
name = "test_lib",
31+
testonly = True,
32+
srcs = glob(["*.spec.ts"]),
33+
deps = [
34+
":model-output",
35+
"//:node_modules/typescript",
36+
"//packages/compiler-cli",
37+
"//packages/compiler-cli/private",
38+
"//packages/core/schematics/utils/tsurge",
39+
],
40+
)
41+
42+
zoneless_jasmine_test(
43+
name = "test",
44+
data = [":test_lib"],
45+
)
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
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 {Rule} from '@angular-devkit/schematics';
10+
import {ModelOutputMigration} from './migration';
11+
import {runMigrationInDevkit} from '../../utils/tsurge/helpers/angular_devkit';
12+
13+
interface Options {
14+
path: string;
15+
}
16+
17+
export function migrate(options: Options): Rule {
18+
return async (tree, context) => {
19+
await runMigrationInDevkit({
20+
tree,
21+
getMigration: (fs) => new ModelOutputMigration(),
22+
});
23+
};
24+
}
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
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 {absoluteFrom} from '@angular/compiler-cli';
10+
import {initMockFileSystem} from '@angular/compiler-cli/private/testing';
11+
import {runTsurgeMigration} from '../../utils/tsurge/testing';
12+
import {ModelOutputMigration} from './migration';
13+
14+
describe('ModelOutput migration', () => {
15+
beforeEach(() => {
16+
initMockFileSystem('Native');
17+
});
18+
19+
it('should migrate model() to input() + linkedSignal() when there is a conflicting output', async () => {
20+
const {fs} = await runTsurgeMigration(new ModelOutputMigration(), [
21+
{
22+
name: absoluteFrom('/index.ts'),
23+
isProgramRootFile: true,
24+
contents: `
25+
import { Component, model, output } from '@angular/core';
26+
27+
@Component({
28+
selector: 'my-comp',
29+
template: ''
30+
})
31+
export class MyComp {
32+
foo = model(0);
33+
fooChange = output<number>();
34+
}
35+
`,
36+
},
37+
]);
38+
39+
const content = fs.readFile(absoluteFrom('/index.ts'));
40+
expect(content).toContain("fooInput = input(0, {alias: 'foo'});");
41+
expect(content).toContain('foo = linkedSignal(this.fooInput);');
42+
expect(content).toContain('fooChange = output<number>();');
43+
expect(content).toContain(
44+
"import { Component, model, output, input, linkedSignal } from '@angular/core';",
45+
);
46+
});
47+
48+
it('should handle generic types', async () => {
49+
const {fs} = await runTsurgeMigration(new ModelOutputMigration(), [
50+
{
51+
name: absoluteFrom('/index.ts'),
52+
isProgramRootFile: true,
53+
contents: `
54+
import { Component, model, output } from '@angular/core';
55+
56+
@Component({
57+
selector: 'my-comp',
58+
template: ''
59+
})
60+
export class MyComp {
61+
bar = model<string>('initial');
62+
barChange = output<string>();
63+
}
64+
`,
65+
},
66+
]);
67+
68+
const content = fs.readFile(absoluteFrom('/index.ts'));
69+
expect(content).toContain("barInput = input<string>('initial', {alias: 'bar'});");
70+
expect(content).toContain('bar = linkedSignal(this.barInput);');
71+
});
72+
73+
it('should preserve modifiers', async () => {
74+
const {fs} = await runTsurgeMigration(new ModelOutputMigration(), [
75+
{
76+
name: absoluteFrom('/index.ts'),
77+
isProgramRootFile: true,
78+
contents: `
79+
import { Component, model, output } from '@angular/core';
80+
81+
@Component({
82+
selector: 'my-comp',
83+
template: ''
84+
})
85+
export class MyComp {
86+
public val = model(123);
87+
public valChange = output<number>();
88+
}
89+
`,
90+
},
91+
]);
92+
93+
const content = fs.readFile(absoluteFrom('/index.ts'));
94+
expect(content).toContain("public valInput = input(123, {alias: 'val'});");
95+
expect(content).toContain('public val = linkedSignal(this.valInput);');
96+
});
97+
98+
it('should handle model.required()', async () => {
99+
const {fs} = await runTsurgeMigration(new ModelOutputMigration(), [
100+
{
101+
name: absoluteFrom('/index.ts'),
102+
isProgramRootFile: true,
103+
contents: `
104+
import { Component, model, output } from '@angular/core';
105+
106+
@Component({
107+
selector: 'my-comp',
108+
template: ''
109+
})
110+
export class MyComp {
111+
foo = model.required<number>();
112+
fooChange = output<number>();
113+
}
114+
`,
115+
},
116+
]);
117+
118+
const content = fs.readFile(absoluteFrom('/index.ts'));
119+
expect(content).toContain("fooInput = input.required<number>({alias: 'foo'});");
120+
expect(content).toContain('foo = linkedSignal(this.fooInput);');
121+
});
122+
123+
it('should handle @Output() decorator as conflict', async () => {
124+
const {fs} = await runTsurgeMigration(new ModelOutputMigration(), [
125+
{
126+
name: absoluteFrom('/index.ts'),
127+
isProgramRootFile: true,
128+
contents: `
129+
import { Component, model, Output, EventEmitter } from '@angular/core';
130+
131+
@Component({
132+
selector: 'my-comp',
133+
template: ''
134+
})
135+
export class MyComp {
136+
baz = model(true);
137+
@Output() bazChange = new EventEmitter<boolean>();
138+
}
139+
`,
140+
},
141+
]);
142+
143+
const content = fs.readFile(absoluteFrom('/index.ts'));
144+
expect(content).toContain("bazInput = input(true, {alias: 'baz'});");
145+
expect(content).toContain('baz = linkedSignal(this.bazInput);');
146+
});
147+
148+
it('should NOT migrate model() if there is no conflicting output', async () => {
149+
const originalContent = `
150+
import { Component, model } from '@angular/core';
151+
152+
@Component({
153+
selector: 'my-comp',
154+
template: ''
155+
})
156+
export class MyComp {
157+
foo = model(0);
158+
}
159+
`;
160+
const {fs} = await runTsurgeMigration(new ModelOutputMigration(), [
161+
{
162+
name: absoluteFrom('/index.ts'),
163+
isProgramRootFile: true,
164+
contents: originalContent,
165+
},
166+
]);
167+
168+
const content = fs.readFile(absoluteFrom('/index.ts'));
169+
// It might normalize some spaces or imports, so check the core logic
170+
expect(content).not.toContain('input(');
171+
expect(content).not.toContain('linkedSignal(');
172+
});
173+
174+
it('should merge existing options in model()', async () => {
175+
const {fs} = await runTsurgeMigration(new ModelOutputMigration(), [
176+
{
177+
name: absoluteFrom('/index.ts'),
178+
isProgramRootFile: true,
179+
contents: `
180+
import { Component, model, output } from '@angular/core';
181+
182+
@Component({
183+
selector: 'my-comp',
184+
template: ''
185+
})
186+
export class MyComp {
187+
foo = model(0, {debugName: 'my-foo'});
188+
fooChange = output<number>();
189+
}
190+
`,
191+
},
192+
]);
193+
194+
const content = fs.readFile(absoluteFrom('/index.ts'));
195+
expect(content).toContain("fooInput = input(0, {alias: 'foo', debugName: 'my-foo'});");
196+
});
197+
});

0 commit comments

Comments
 (0)