Visitar URL original
fix(upgrade): support model() signals in downgradeComponent · angular/angular@8d31b82 · GitHub
Skip to content

Commit 8d31b82

Browse files
arturovtkirjs
authored andcommitted
fix(upgrade): support model() signals in downgradeComponent
`model()` signals are special because they combine a signal input with a writable output through an internal `OutputEmitterRef`. During upgrade, `setupOutputs()` subscribes to that emitter to keep Angular → AngularJS two-way binding working. The issue was that `updateInput()` could overwrite the signal property directly when `isSignal` was `false` (which happens in JIT mode and when `unsafelyOverwriteSignalInputs` is enabled). Once that happened, the original `OutputEmitterRef` was lost, so the two-way binding stopped working. The fix detects `model()` signals at runtime by checking for both `[SIGNAL]` and a writable `.set()` method, which distinguishes them from read-only `input()` signals. When those traits are present, updates are always applied through `applyValueToInputSignal()` instead of replacing the property directly, regardless of the `unsafelyOverwriteSignalInputs` setting. Fixes #60599
1 parent 97a3fd6 commit 8d31b82

2 files changed

Lines changed: 102 additions & 3 deletions

File tree

‎packages/upgrade/src/common/src/downgrade_component_adapter.ts‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -328,9 +328,20 @@ export class DowngradeComponentAdapter {
328328
}
329329

330330
this.inputChangeCount++;
331-
if (isSignal && !this.unsafelyOverwriteSignalInputs) {
332-
const node = componentRef.instance[prop][SIGNAL] as InputSignalNode<unknown, unknown>;
333-
node.applyValueToInputSignal(node, currValue);
331+
const instanceProp = componentRef.instance[prop];
332+
const node = instanceProp?.[SIGNAL] as InputSignalNode<unknown, unknown> | undefined;
333+
// Model signals are writable signal inputs (they expose `.set()` and `applyValueToInputSignal`).
334+
// Overwriting them would destroy the internal OutputEmitterRef that setupOutputs() already
335+
// subscribed to, severing the Angular→AngularJS two-way binding. Always use
336+
// applyValueToInputSignal for model signals regardless of the unsafelyOverwriteSignalInputs
337+
// flag. Check applyValueToInputSignal (not just .set) to distinguish model() from a plain
338+
// WritableSignal used with @Input(), which also has .set() but does not support this method.
339+
const isModelSignal =
340+
node != null &&
341+
typeof instanceProp.set === 'function' &&
342+
typeof node.applyValueToInputSignal === 'function';
343+
if (isModelSignal || (isSignal && !this.unsafelyOverwriteSignalInputs)) {
344+
node!.applyValueToInputSignal(node!, currValue);
334345
} else {
335346
componentRef.instance[prop] = currValue;
336347
}

‎packages/upgrade/static/test/integration/downgrade_component_spec.ts‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
Injector,
1919
input,
2020
Input,
21+
model,
2122
NgModule,
2223
NgModuleRef,
2324
OnChanges,
@@ -220,6 +221,93 @@ withEachNg1Version(() => {
220221
});
221222
}));
222223

224+
it('should propagate AngularJS→Angular changes through model() signals', waitForAsync(() => {
225+
const ng1Module = angular.module_('ng1', []).run(($rootScope: angular.IScope) => {
226+
$rootScope['value'] = 'initial';
227+
});
228+
229+
@Component({
230+
selector: 'ng2',
231+
template: 'Value: {{value()}}',
232+
changeDetection: ChangeDetectionStrategy.Eager,
233+
standalone: false,
234+
})
235+
class Ng2Component {
236+
value = model<string>('');
237+
}
238+
239+
@NgModule({declarations: [Ng2Component], imports: [BrowserModule, UpgradeModule]})
240+
class Ng2Module {
241+
ngDoBootstrap() {}
242+
}
243+
244+
// Wire up the model() signal for JIT tests. AOT compilation handles this automatically.
245+
(Ng2Component as any).ɵcmp.inputs = {value: ['value', /* InputFlags.SignalBased */ 1]};
246+
(Ng2Component as any).ɵcmp.outputs = {valueChange: 'value'};
247+
248+
ng1Module.directive('ng2', downgradeComponent({component: Ng2Component}));
249+
250+
const element = html(`
251+
<div>
252+
<ng2 bindon-value="value"></ng2>
253+
| value: {{value}}
254+
</div>`);
255+
256+
bootstrap(platformBrowserDynamic(), Ng2Module, element, ng1Module).then((upgrade) => {
257+
expect(multiTrim(document.body.textContent)).toEqual('Value: initial | value: initial');
258+
259+
$apply(upgrade, 'value = "updated"');
260+
expect(multiTrim(document.body.textContent)).toEqual('Value: updated | value: updated');
261+
});
262+
}));
263+
264+
it('should propagate Angular→AngularJS changes through model() signals', waitForAsync(() => {
265+
const ng1Module = angular.module_('ng1', []).run(($rootScope: angular.IScope) => {
266+
$rootScope['value'] = 'initial';
267+
});
268+
269+
let componentInstance: Ng2Component;
270+
271+
@Component({
272+
selector: 'ng2',
273+
template: 'Value: {{value()}}',
274+
changeDetection: ChangeDetectionStrategy.Eager,
275+
standalone: false,
276+
})
277+
class Ng2Component {
278+
value = model<string>('');
279+
constructor() {
280+
componentInstance = this;
281+
}
282+
}
283+
284+
@NgModule({declarations: [Ng2Component], imports: [BrowserModule, UpgradeModule]})
285+
class Ng2Module {
286+
ngDoBootstrap() {}
287+
}
288+
289+
// Wire up the model() signal for JIT tests. AOT compilation handles this automatically.
290+
(Ng2Component as any).ɵcmp.inputs = {value: ['value', /* InputFlags.SignalBased */ 1]};
291+
(Ng2Component as any).ɵcmp.outputs = {valueChange: 'value'};
292+
293+
ng1Module.directive('ng2', downgradeComponent({component: Ng2Component}));
294+
295+
const element = html(`
296+
<div>
297+
<ng2 bindon-value="value"></ng2>
298+
| value: {{value}}
299+
</div>`);
300+
301+
bootstrap(platformBrowserDynamic(), Ng2Module, element, ng1Module).then((upgrade) => {
302+
expect(multiTrim(document.body.textContent)).toEqual('Value: initial | value: initial');
303+
304+
$apply(upgrade, () => componentInstance.value.set('from-angular'));
305+
expect(multiTrim(document.body.textContent)).toEqual(
306+
'Value: from-angular | value: from-angular',
307+
);
308+
});
309+
}));
310+
223311
it('should bind properties to onpush components', waitForAsync(() => {
224312
const ng1Module = angular.module_('ng1', []).run(($rootScope: angular.IScope) => {
225313
$rootScope['dataB'] = 'B';

0 commit comments

Comments
 (0)