Skip to content

Commit c7ef23c

Browse files
committed
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 angular#67340
1 parent a89b565 commit c7ef23c

7 files changed

Lines changed: 529 additions & 4 deletions

File tree

packages/core/schematics/BUILD.bazel

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,10 @@ bundle_entrypoints = [
137137
"incremental-hydration",
138138
"packages/core/schematics/migrations/incremental-hydration/index.js",
139139
],
140+
[
141+
"model-output",
142+
"packages/core/schematics/migrations/model-output/index.js",
143+
],
140144
]
141145

142146
rollup.rollup(
@@ -152,6 +156,7 @@ rollup.rollup(
152156
"//packages/core/schematics/migrations/change-detection-eager",
153157
"//packages/core/schematics/migrations/http-xhr-backend",
154158
"//packages/core/schematics/migrations/incremental-hydration",
159+
"//packages/core/schematics/migrations/model-output",
155160
"//packages/core/schematics/migrations/strict-templates-default",
156161
"//packages/core/schematics/ng-generate/cleanup-unused-imports",
157162
"//packages/core/schematics/ng-generate/common-to-standalone-migration",

packages/core/schematics/migrations.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@
2424
"version": "22.0.0",
2525
"description": "Adds withNoIncrementalHydration() opt out to provideClientHydration() when incremental hydration is not enabled to retain pre-v22 behavior-.",
2626
"factory": "./bundles/incremental-hydration.cjs#migrate"
27+
},
28+
"model-output": {
29+
"version": "22.0.0",
30+
"description": "Migrate broken duplicate outputs",
31+
"factory": "./bundles/model-output.cjs#migrate"
2732
}
2833
}
2934
}
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)