From 33c228c80b72599923714c0dd5d775008ed77f4d Mon Sep 17 00:00:00 2001 From: arturovt Date: Sun, 17 May 2026 01:10:19 +0300 Subject: [PATCH] fix(upgrade): support model() signals in downgradeComponent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- .../common/src/downgrade_component_adapter.ts | 12 ++- .../integration/downgrade_component_spec.ts | 88 +++++++++++++++++++ 2 files changed, 97 insertions(+), 3 deletions(-) diff --git a/packages/upgrade/src/common/src/downgrade_component_adapter.ts b/packages/upgrade/src/common/src/downgrade_component_adapter.ts index 63dfffbb3bb6..7a762d6c2c3b 100644 --- a/packages/upgrade/src/common/src/downgrade_component_adapter.ts +++ b/packages/upgrade/src/common/src/downgrade_component_adapter.ts @@ -328,9 +328,15 @@ export class DowngradeComponentAdapter { } this.inputChangeCount++; - if (isSignal && !this.unsafelyOverwriteSignalInputs) { - const node = componentRef.instance[prop][SIGNAL] as InputSignalNode; - node.applyValueToInputSignal(node, currValue); + const instanceProp = componentRef.instance[prop]; + const node = instanceProp?.[SIGNAL] as InputSignalNode | undefined; + // Model signals are writable signal inputs (they expose a `.set()` method). Overwriting + // them would destroy the internal OutputEmitterRef that setupOutputs() already subscribed + // to, severing the Angular→AngularJS two-way binding. Always use applyValueToInputSignal + // for model signals regardless of the unsafelyOverwriteSignalInputs flag. + const isModelSignal = node != null && typeof instanceProp.set === 'function'; + if (isModelSignal || (isSignal && !this.unsafelyOverwriteSignalInputs)) { + node!.applyValueToInputSignal(node!, currValue); } else { componentRef.instance[prop] = currValue; } diff --git a/packages/upgrade/static/test/integration/downgrade_component_spec.ts b/packages/upgrade/static/test/integration/downgrade_component_spec.ts index 6311d13579f9..24b1ab21bb8f 100644 --- a/packages/upgrade/static/test/integration/downgrade_component_spec.ts +++ b/packages/upgrade/static/test/integration/downgrade_component_spec.ts @@ -18,6 +18,7 @@ import { Injector, input, Input, + model, NgModule, NgModuleRef, OnChanges, @@ -220,6 +221,93 @@ withEachNg1Version(() => { }); })); + it('should propagate AngularJS→Angular changes through model() signals', waitForAsync(() => { + const ng1Module = angular.module_('ng1', []).run(($rootScope: angular.IScope) => { + $rootScope['value'] = 'initial'; + }); + + @Component({ + selector: 'ng2', + template: 'Value: {{value()}}', + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, + }) + class Ng2Component { + value = model(''); + } + + @NgModule({declarations: [Ng2Component], imports: [BrowserModule, UpgradeModule]}) + class Ng2Module { + ngDoBootstrap() {} + } + + // Wire up the model() signal for JIT tests. AOT compilation handles this automatically. + (Ng2Component as any).ɵcmp.inputs = {value: ['value', /* InputFlags.SignalBased */ 1]}; + (Ng2Component as any).ɵcmp.outputs = {valueChange: 'value'}; + + ng1Module.directive('ng2', downgradeComponent({component: Ng2Component})); + + const element = html(` +
+ + | value: {{value}} +
`); + + bootstrap(platformBrowserDynamic(), Ng2Module, element, ng1Module).then((upgrade) => { + expect(multiTrim(document.body.textContent)).toEqual('Value: initial | value: initial'); + + $apply(upgrade, 'value = "updated"'); + expect(multiTrim(document.body.textContent)).toEqual('Value: updated | value: updated'); + }); + })); + + it('should propagate Angular→AngularJS changes through model() signals', waitForAsync(() => { + const ng1Module = angular.module_('ng1', []).run(($rootScope: angular.IScope) => { + $rootScope['value'] = 'initial'; + }); + + let componentInstance: Ng2Component; + + @Component({ + selector: 'ng2', + template: 'Value: {{value()}}', + changeDetection: ChangeDetectionStrategy.Eager, + standalone: false, + }) + class Ng2Component { + value = model(''); + constructor() { + componentInstance = this; + } + } + + @NgModule({declarations: [Ng2Component], imports: [BrowserModule, UpgradeModule]}) + class Ng2Module { + ngDoBootstrap() {} + } + + // Wire up the model() signal for JIT tests. AOT compilation handles this automatically. + (Ng2Component as any).ɵcmp.inputs = {value: ['value', /* InputFlags.SignalBased */ 1]}; + (Ng2Component as any).ɵcmp.outputs = {valueChange: 'value'}; + + ng1Module.directive('ng2', downgradeComponent({component: Ng2Component})); + + const element = html(` +
+ + | value: {{value}} +
`); + + bootstrap(platformBrowserDynamic(), Ng2Module, element, ng1Module).then((upgrade) => { + expect(multiTrim(document.body.textContent)).toEqual('Value: initial | value: initial'); + + $apply(upgrade, () => componentInstance.value.set('from-angular')); + expect(multiTrim(document.body.textContent)).toEqual( + 'Value: from-angular | value: from-angular', + ); + }); + })); + it('should bind properties to onpush components', waitForAsync(() => { const ng1Module = angular.module_('ng1', []).run(($rootScope: angular.IScope) => { $rootScope['dataB'] = 'B';