fix(upgrade): support input signal bindings (#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 #56860.

PR Close #57020
This commit is contained in:
Alex Rickabaugh 2024-07-17 07:13:14 -07:00
parent 901c1e1a7f
commit e40a4fa3c7
7 changed files with 70 additions and 9 deletions

View file

@ -1006,7 +1006,7 @@ export interface InputSignalWithTransform<T, TransformT> extends Signal<T> {
// (undocumented)
[ɵINPUT_SIGNAL_BRAND_WRITE_TYPE]: TransformT;
// (undocumented)
[SIGNAL]: InputSignalNode<T, TransformT>;
[SIGNAL]: ɵInputSignalNode<T, TransformT>;
}
// @public
@ -1149,7 +1149,7 @@ export interface ModelOptions {
// @public
export interface ModelSignal<T> extends WritableSignal<T>, InputSignal<T>, OutputRef<T> {
// (undocumented)
[SIGNAL]: InputSignalNode<T, T>;
[SIGNAL]: ɵInputSignalNode<T, T>;
}
// @public @deprecated

View file

@ -137,3 +137,5 @@ export {isPromise as ɵisPromise, isSubscribable as ɵisSubscribable} from './ut
export {performanceMarkFeature as ɵperformanceMarkFeature} from './util/performance';
export {stringify as ɵstringify, truncateMiddle as ɵtruncateMiddle} from './util/stringify';
export {NOT_FOUND_CHECK_ONLY_ELEMENT_INJECTOR as ɵNOT_FOUND_CHECK_ONLY_ELEMENT_INJECTOR} from './view/provider_flags';
export {type InputSignalNode as ɵInputSignalNode} from './authoring/input/input_signal_node';

View file

@ -6,6 +6,8 @@
* found in the LICENSE file at https://angular.dev/license
*/
export {SIGNAL as ɵSIGNAL} from '@angular/core/primitives/signals';
export {isSignal, Signal, ValueEqualityFn} from './render3/reactivity/api';
export {computed, CreateComputedOptions} from './render3/reactivity/computed';
export {

View file

@ -105,6 +105,8 @@ export function downgradeComponent(info: {
$injector: IInjectorService,
$parse: IParseService,
): IDirective {
const unsafelyOverwriteSignalInputs =
(info as {unsafelyOverwriteSignalInputs?: boolean}).unsafelyOverwriteSignalInputs ?? false;
// When using `downgradeModule()`, we need to handle certain things specially. For example:
// - We always need to attach the component view to the `ApplicationRef` for it to be
// dirty-checked.
@ -216,6 +218,7 @@ export function downgradeComponent(info: {
$parse,
componentFactory,
wrapCallback,
unsafelyOverwriteSignalInputs,
);
const projectableNodes = facade.compileContents();

View file

@ -19,6 +19,8 @@ import {
StaticProvider,
Testability,
TestabilityRegistry,
type ɵInputSignalNode as InputSignalNode,
ɵSIGNAL as SIGNAL,
} from '@angular/core';
import {
@ -53,6 +55,7 @@ export class DowngradeComponentAdapter {
private $parse: IParseService,
private componentFactory: ComponentFactory<any>,
private wrapCallback: <T>(cb: () => T) => () => T,
private readonly unsafelyOverwriteSignalInputs: boolean,
) {
this.componentScope = scope.$new();
}
@ -131,7 +134,7 @@ export class DowngradeComponentAdapter {
let expr: string | null = null;
if (attrs.hasOwnProperty(inputBinding.attr)) {
const observeFn = ((prop) => {
const observeFn = ((prop, isSignal) => {
let prevValue = INITIAL_VALUE;
return (currValue: any) => {
// Initially, both `$observe()` and `$watch()` will call this function.
@ -140,11 +143,11 @@ export class DowngradeComponentAdapter {
prevValue = currValue;
}
this.updateInput(componentRef, prop, prevValue, currValue);
this.updateInput(componentRef, prop, prevValue, currValue, isSignal);
prevValue = currValue;
}
};
})(inputBinding.prop);
})(inputBinding.prop, input.isSignal);
attrs.$observe(inputBinding.attr, observeFn);
// Use `$watch()` (in addition to `$observe()`) in order to initialize the input in time
@ -166,9 +169,9 @@ export class DowngradeComponentAdapter {
}
if (expr != null) {
const watchFn = (
(prop) => (currValue: unknown, prevValue: unknown) =>
this.updateInput(componentRef, prop, prevValue, currValue)
)(inputBinding.prop);
(prop, isSignal) => (currValue: unknown, prevValue: unknown) =>
this.updateInput(componentRef, prop, prevValue, currValue, isSignal)
)(inputBinding.prop, input.isSignal);
this.componentScope.$watch(expr, watchFn);
}
}
@ -314,13 +317,19 @@ export class DowngradeComponentAdapter {
prop: string,
prevValue: any,
currValue: any,
isSignal: boolean,
) {
if (this.implementsOnChanges) {
this.inputChanges[prop] = new SimpleChange(prevValue, currValue, prevValue === currValue);
}
this.inputChangeCount++;
componentRef.instance[prop] = currValue;
if (isSignal && !this.unsafelyOverwriteSignalInputs) {
const node = componentRef.instance[prop][SIGNAL] as InputSignalNode<unknown, unknown>;
node.applyValueToInputSignal(node, currValue);
} else {
componentRef.instance[prop] = currValue;
}
}
private groupProjectableNodes() {

View file

@ -181,6 +181,7 @@ withEachNg1Version(() => {
$parse,
componentFactory,
wrapCallback,
/* unsafelyOverwriteSignalInputs */ false,
);
}

View file

@ -15,6 +15,7 @@ import {
ElementRef,
EventEmitter,
Injector,
input,
Input,
NgModule,
NgModuleRef,
@ -171,6 +172,49 @@ withEachNg1Version(() => {
});
}));
it('should bind properties to signal inputs', waitForAsync(() => {
const ng1Module = angular.module_('ng1', []).run(($rootScope: angular.IScope) => {
$rootScope['name'] = 'world';
});
@Component({
selector: 'ng2',
inputs: ['message'],
template: 'Message: {{message()}}',
})
class Ng2Component {
message = input<string>('');
}
@NgModule({declarations: [Ng2Component], imports: [BrowserModule, UpgradeModule]})
class Ng2Module {
ngDoBootstrap() {}
}
// Hack to wire up the `input()` signal correctly, since our JIT tests don't run with the
// transform which supports `input()`.
(Ng2Component as any).ɵcmp.inputs.message = ['message', /* InputFlags.SignalBased */ 1];
ng1Module.directive(
'ng2',
downgradeComponent({
component: Ng2Component,
}),
);
const element = html(`
<div>
<ng2 literal="Text" message="Hello {{name}}"></ng2>
</div>`);
bootstrap(platformBrowserDynamic(), Ng2Module, element, ng1Module).then((upgrade) => {
expect(multiTrim(document.body.textContent)).toEqual('Message: Hello world');
$apply(upgrade, 'name = "everyone"');
expect(multiTrim(document.body.textContent)).toEqual('Message: Hello everyone');
});
}));
it('should bind properties to onpush components', waitForAsync(() => {
const ng1Module = angular.module_('ng1', []).run(($rootScope: angular.IScope) => {
$rootScope['dataB'] = 'B';