Skip to content

Commit 5f56a65

Browse files
committed
fix(upgrade): support input signal bindings (angular#57020)
`@angular/upgrade` writes to inputs when downgrading an Angular 2+ component into an Angular.JS adapter. Previously, it wrote directly to the input property, which isn't compatible with input signals. It also handles `ngOnChanges` directly. The correct way to support input signals would be to refactor upgrade to use `ComponentRef.setInput`, which also handles `ngOnChanges` internally. However, this refactoring might be more breaking since it would change the timing of certain operations. Instead, this commit updates the code to recognize `InputSignal` and write it through the `InputSignalNode`. This avoids the above breaking changes for now, until a bigger refactoring can be tested. Fixes angular#56860. PR Close angular#57020
1 parent 39ccaf4 commit 5f56a65

File tree

7 files changed

+70
-9
lines changed

7 files changed

+70
-9
lines changed

goldens/public-api/core/index.api.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1014,7 +1014,7 @@ export interface InputSignalWithTransform<T, TransformT> extends Signal<T> {
10141014
// (undocumented)
10151015
INPUT_SIGNAL_BRAND_WRITE_TYPE]: TransformT;
10161016
// (undocumented)
1017-
[SIGNAL]: InputSignalNode<T, TransformT>;
1017+
[SIGNAL]: ɵInputSignalNode<T, TransformT>;
10181018
}
10191019

10201020
// @public
@@ -1157,7 +1157,7 @@ export interface ModelOptions {
11571157
// @public
11581158
export interface ModelSignal<T> extends WritableSignal<T>, InputSignal<T>, OutputRef<T> {
11591159
// (undocumented)
1160-
[SIGNAL]: InputSignalNode<T, T>;
1160+
[SIGNAL]: ɵInputSignalNode<T, T>;
11611161
}
11621162

11631163
// @public @deprecated

packages/core/src/core_private_export.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,3 +140,5 @@ export {isPromise as ɵisPromise, isSubscribable as ɵisSubscribable} from './ut
140140
export {performanceMarkFeature as ɵperformanceMarkFeature} from './util/performance';
141141
export {stringify as ɵstringify, truncateMiddle as ɵtruncateMiddle} from './util/stringify';
142142
export {NOT_FOUND_CHECK_ONLY_ELEMENT_INJECTOR as ɵNOT_FOUND_CHECK_ONLY_ELEMENT_INJECTOR} from './view/provider_flags';
143+
144+
export {type InputSignalNode as ɵInputSignalNode} from './authoring/input/input_signal_node';

packages/core/src/core_reactivity_export_internal.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9+
export {SIGNAL as ɵSIGNAL} from '@angular/core/primitives/signals';
10+
911
export {isSignal, Signal, ValueEqualityFn} from './render3/reactivity/api';
1012
export {computed, CreateComputedOptions} from './render3/reactivity/computed';
1113
export {

packages/upgrade/src/common/src/downgrade_component.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ export function downgradeComponent(info: {
105105
$injector: IInjectorService,
106106
$parse: IParseService,
107107
): IDirective {
108+
const unsafelyOverwriteSignalInputs =
109+
(info as {unsafelyOverwriteSignalInputs?: boolean}).unsafelyOverwriteSignalInputs ?? false;
108110
// When using `downgradeModule()`, we need to handle certain things specially. For example:
109111
// - We always need to attach the component view to the `ApplicationRef` for it to be
110112
// dirty-checked.
@@ -216,6 +218,7 @@ export function downgradeComponent(info: {
216218
$parse,
217219
componentFactory,
218220
wrapCallback,
221+
unsafelyOverwriteSignalInputs,
219222
);
220223

221224
const projectableNodes = facade.compileContents();

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

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import {
1919
StaticProvider,
2020
Testability,
2121
TestabilityRegistry,
22+
type ɵInputSignalNode as InputSignalNode,
23+
ɵSIGNAL as SIGNAL,
2224
} from '@angular/core';
2325

2426
import {
@@ -53,6 +55,7 @@ export class DowngradeComponentAdapter {
5355
private $parse: IParseService,
5456
private componentFactory: ComponentFactory<any>,
5557
private wrapCallback: <T>(cb: () => T) => () => T,
58+
private readonly unsafelyOverwriteSignalInputs: boolean,
5659
) {
5760
this.componentScope = scope.$new();
5861
}
@@ -131,7 +134,7 @@ export class DowngradeComponentAdapter {
131134
let expr: string | null = null;
132135

133136
if (attrs.hasOwnProperty(inputBinding.attr)) {
134-
const observeFn = ((prop) => {
137+
const observeFn = ((prop, isSignal) => {
135138
let prevValue = INITIAL_VALUE;
136139
return (currValue: any) => {
137140
// Initially, both `$observe()` and `$watch()` will call this function.
@@ -140,11 +143,11 @@ export class DowngradeComponentAdapter {
140143
prevValue = currValue;
141144
}
142145

143-
this.updateInput(componentRef, prop, prevValue, currValue);
146+
this.updateInput(componentRef, prop, prevValue, currValue, isSignal);
144147
prevValue = currValue;
145148
}
146149
};
147-
})(inputBinding.prop);
150+
})(inputBinding.prop, input.isSignal);
148151
attrs.$observe(inputBinding.attr, observeFn);
149152

150153
// Use `$watch()` (in addition to `$observe()`) in order to initialize the input in time
@@ -166,9 +169,9 @@ export class DowngradeComponentAdapter {
166169
}
167170
if (expr != null) {
168171
const watchFn = (
169-
(prop) => (currValue: unknown, prevValue: unknown) =>
170-
this.updateInput(componentRef, prop, prevValue, currValue)
171-
)(inputBinding.prop);
172+
(prop, isSignal) => (currValue: unknown, prevValue: unknown) =>
173+
this.updateInput(componentRef, prop, prevValue, currValue, isSignal)
174+
)(inputBinding.prop, input.isSignal);
172175
this.componentScope.$watch(expr, watchFn);
173176
}
174177
}
@@ -314,13 +317,19 @@ export class DowngradeComponentAdapter {
314317
prop: string,
315318
prevValue: any,
316319
currValue: any,
320+
isSignal: boolean,
317321
) {
318322
if (this.implementsOnChanges) {
319323
this.inputChanges[prop] = new SimpleChange(prevValue, currValue, prevValue === currValue);
320324
}
321325

322326
this.inputChangeCount++;
323-
componentRef.instance[prop] = currValue;
327+
if (isSignal && !this.unsafelyOverwriteSignalInputs) {
328+
const node = componentRef.instance[prop][SIGNAL] as InputSignalNode<unknown, unknown>;
329+
node.applyValueToInputSignal(node, currValue);
330+
} else {
331+
componentRef.instance[prop] = currValue;
332+
}
324333
}
325334

326335
private groupProjectableNodes() {

packages/upgrade/src/common/test/downgrade_component_adapter_spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ withEachNg1Version(() => {
181181
$parse,
182182
componentFactory,
183183
wrapCallback,
184+
/* unsafelyOverwriteSignalInputs */ false,
184185
);
185186
}
186187

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
ElementRef,
1616
EventEmitter,
1717
Injector,
18+
input,
1819
Input,
1920
NgModule,
2021
NgModuleRef,
@@ -171,6 +172,49 @@ withEachNg1Version(() => {
171172
});
172173
}));
173174

175+
it('should bind properties to signal inputs', waitForAsync(() => {
176+
const ng1Module = angular.module_('ng1', []).run(($rootScope: angular.IScope) => {
177+
$rootScope['name'] = 'world';
178+
});
179+
180+
@Component({
181+
selector: 'ng2',
182+
inputs: ['message'],
183+
template: 'Message: {{message()}}',
184+
})
185+
class Ng2Component {
186+
message = input<string>('');
187+
}
188+
189+
@NgModule({declarations: [Ng2Component], imports: [BrowserModule, UpgradeModule]})
190+
class Ng2Module {
191+
ngDoBootstrap() {}
192+
}
193+
194+
// Hack to wire up the `input()` signal correctly, since our JIT tests don't run with the
195+
// transform which supports `input()`.
196+
(Ng2Component as any).ɵcmp.inputs.message = ['message', /* InputFlags.SignalBased */ 1];
197+
198+
ng1Module.directive(
199+
'ng2',
200+
downgradeComponent({
201+
component: Ng2Component,
202+
}),
203+
);
204+
205+
const element = html(`
206+
<div>
207+
<ng2 literal="Text" message="Hello {{name}}"></ng2>
208+
</div>`);
209+
210+
bootstrap(platformBrowserDynamic(), Ng2Module, element, ng1Module).then((upgrade) => {
211+
expect(multiTrim(document.body.textContent)).toEqual('Message: Hello world');
212+
213+
$apply(upgrade, 'name = "everyone"');
214+
expect(multiTrim(document.body.textContent)).toEqual('Message: Hello everyone');
215+
});
216+
}));
217+
174218
it('should bind properties to onpush components', waitForAsync(() => {
175219
const ng1Module = angular.module_('ng1', []).run(($rootScope: angular.IScope) => {
176220
$rootScope['dataB'] = 'B';

0 commit comments

Comments
 (0)