Files
Kristiyan Kostadinov 39624c6b12 fix(compiler): output input flags as a literal (#55215)
Previously the input flags were being generated as a reference to an enum member for better readability and under the assumption that minifiers would inline the values. That doesn't appear to be the case so these changes switch to using the literal values instead.

PR Close #55215
2024-04-04 11:13:52 -07:00

662 lines
20 KiB
TypeScript

/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import ts from 'typescript';
import {runInEachFileSystem} from '../../src/ngtsc/file_system/testing';
import {loadStandardTestFiles} from '../../src/ngtsc/testing';
import {NgtscTestEnvironment} from './env';
const testFiles = loadStandardTestFiles();
runInEachFileSystem(() => {
describe('initializer-based model() API', () => {
let env!: NgtscTestEnvironment;
beforeEach(() => {
env = NgtscTestEnvironment.setup(testFiles);
env.tsconfig({strictTemplates: true});
});
it('should declare an input/output pair for a field initialized to a model()', () => {
env.write('test.ts', `
import {Directive, model} from '@angular/core';
@Directive()
export class TestDir {
value = model(1);
}
`);
env.driveMain();
const js = env.getContents('test.js');
const dts = env.getContents('test.d.ts');
expect(js).toContain('inputs: { value: [1, "value"] }');
expect(js).toContain('outputs: { value: "valueChange" }');
expect(dts).toContain(
'static ɵdir: i0.ɵɵDirectiveDeclaration<TestDir, never, never, ' +
'{ "value": { "alias": "value"; "required": false; "isSignal": true; }; }, ' +
'{ "value": "valueChange"; }, never, never, false, never>;');
});
it('should declare an input/output pair for a field initialized to an aliased model()', () => {
env.write('test.ts', `
import {Directive, model} from '@angular/core';
@Directive()
export class TestDir {
value = model(1, {alias: 'alias'});
}
`);
env.driveMain();
const js = env.getContents('test.js');
const dts = env.getContents('test.d.ts');
expect(js).toContain('inputs: { value: [1, "alias", "value"] }');
expect(js).toContain('outputs: { value: "aliasChange" }');
expect(dts).toContain(
'static ɵdir: i0.ɵɵDirectiveDeclaration<TestDir, never, never, ' +
'{ "value": { "alias": "alias"; "required": false; "isSignal": true; }; }, ' +
'{ "value": "aliasChange"; }, never, never, false, never>;');
});
it('should declare an input/output pair for a field initialized to a required model()', () => {
env.write('test.ts', `
import {Directive, model} from '@angular/core';
@Directive()
export class TestDir {
value = model.required<string>();
}
`);
env.driveMain();
const js = env.getContents('test.js');
const dts = env.getContents('test.d.ts');
expect(js).toContain('inputs: { value: [1, "value"] }');
expect(js).toContain('outputs: { value: "valueChange" }');
expect(dts).toContain(
'static ɵdir: i0.ɵɵDirectiveDeclaration<TestDir, never, never, ' +
'{ "value": { "alias": "value"; "required": true; "isSignal": true; }; }, ' +
'{ "value": "valueChange"; }, never, never, false, never>;');
});
it('should report a diagnostic if a model field is decorated with @Input', () => {
env.write('test.ts', `
import {Directive, Input, model} from '@angular/core';
@Directive()
export class TestDir {
@Input() value = model('test');
}
`);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
expect(diags[0].messageText).toBe('Using @Input with a model input is not allowed.');
});
it('should report a diagnostic if a model field is decorated with @Output', () => {
env.write('test.ts', `
import {Directive, Output, model} from '@angular/core';
@Directive()
export class TestDir {
@Output() value = model('test');
}
`);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
expect(diags[0].messageText).toBe('Using @Output with a model input is not allowed.');
});
it('should report a diagnostic if a model input is also declared in the `inputs` field', () => {
env.write('test.ts', `
import {Directive, model} from '@angular/core';
@Directive({
inputs: ['value'],
})
export class TestDir {
value = model('test');
}
`);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
expect(diags[0].messageText)
.toBe('Input "value" is also declared as non-signal in @Directive.');
});
it('should produce a diagnostic if the alias of a model cannot be analyzed', () => {
env.write('test.ts', `
import {Directive, model} from '@angular/core';
const ALIAS = 'bla';
@Directive()
export class TestDir {
value = model('test', {alias: ALIAS});
}
`);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
expect(diags[0].messageText)
.toBe('Alias needs to be a string that is statically analyzable.');
});
it('should report a diagnostic if the options of a model signal cannot be analyzed', () => {
env.write('test.ts', `
import {Directive, model} from '@angular/core';
const OPTIONS = {};
@Directive()
export class TestDir {
value = model('test', OPTIONS);
}
`);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
expect(diags[0].messageText)
.toBe('Argument needs to be an object literal that is statically analyzable.');
});
it('should report a diagnostic if a model input is declared on a static member', () => {
env.write('test.ts', `
import {Directive, model} from '@angular/core';
@Directive()
export class TestDir {
static value = model('test');
}
`);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
expect(diags[0].messageText)
.toBe('Input "value" is incorrectly declared as static member of "TestDir".');
});
it('should a diagnostic if a required model input declares an initial value', () => {
env.write('test.ts', `
import {Directive, model} from '@angular/core';
@Directive()
export class TestDir {
value = model.required({
initialValue: 'bla',
});
}
`);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
expect(diags[0].messageText)
.toBe(
`Object literal may only specify known properties, ` +
`and 'initialValue' does not exist in type 'ModelOptions'.`);
});
it('should report if a signal getter is invoked in a two-way binding', () => {
env.write('test.ts', `
import {Component, Directive, model, signal} from '@angular/core';
@Directive({
selector: '[dir]',
standalone: true,
})
export class TestDir {
value = model(1);
}
@Component({
standalone: true,
template: \`<div dir [(value)]="value()"></div>\`,
imports: [TestDir],
})
export class TestComp {
value = signal(0);
}
`);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
expect(diags[0].messageText).toBe('Unsupported expression in a two-way binding');
});
describe('type checking', () => {
it('should check a primitive value bound to a model input', () => {
env.write('test.ts', `
import {Component, Directive, model} from '@angular/core';
@Directive({
selector: '[dir]',
standalone: true,
})
export class TestDir {
value = model(1);
}
@Component({
standalone: true,
template: \`<div dir [(value)]="value"></div>\`,
imports: [TestDir],
})
export class TestComp {
value = false;
}
`);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
expect(diags[0].messageText).toBe(`Type 'boolean' is not assignable to type 'number'.`);
});
it('should check a signal value bound to a model input via a two-way binding', () => {
env.write('test.ts', `
import {Component, Directive, model, signal} from '@angular/core';
@Directive({
selector: '[dir]',
standalone: true,
})
export class TestDir {
value = model(1);
}
@Component({
standalone: true,
template: \`<div dir [(value)]="value"></div>\`,
imports: [TestDir],
})
export class TestComp {
value = signal(false);
}
`);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
expect(diags[0].messageText).toBe(`Type 'boolean' is not assignable to type 'number'.`);
});
it('should check two-way binding of a signal to a decorator-based input/output pair', () => {
env.write('test.ts', `
import {Component, Directive, Input, Output, signal, EventEmitter} from '@angular/core';
@Directive({
selector: '[dir]',
standalone: true,
})
export class TestDir {
@Input() value = 0;
@Output() valueChange = new EventEmitter<number>();
}
@Component({
standalone: true,
template: \`<div dir [(value)]="value"></div>\`,
imports: [TestDir],
})
export class TestComp {
value = signal(false);
}
`);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
expect(diags[0].messageText).toBe(`Type 'boolean' is not assignable to type 'number'.`);
});
it('should not allow a non-writable signal to be assigned to a model', () => {
env.write('test.ts', `
import {Component, Directive, model, input} from '@angular/core';
@Directive({
selector: '[dir]',
standalone: true,
})
export class TestDir {
value = model(1);
}
@Component({
standalone: true,
template: \`<div dir [(value)]="value"></div>\`,
imports: [TestDir],
})
export class TestComp {
value = input(0);
}
`);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
expect(diags[0].messageText)
.toBe(`Type 'InputSignal<number>' is not assignable to type 'number'.`);
});
it('should allow a model signal to be bound to another model signal', () => {
env.write('test.ts', `
import {Component, Directive, model} from '@angular/core';
@Directive({
selector: '[dir]',
standalone: true,
})
export class TestDir {
value = model(1);
}
@Component({
standalone: true,
template: \`<div dir [(value)]="value"></div>\`,
imports: [TestDir],
})
export class TestComp {
value = model(0);
}
`);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(0);
});
it('should check the event declared by a model input', () => {
env.write('test.ts', `
import {Component, Directive, model} from '@angular/core';
@Directive({
selector: '[dir]',
standalone: true,
})
export class TestDir {
value = model(1);
}
@Component({
standalone: true,
template: \`<div dir (valueChange)="acceptsString($event)"></div>\`,
imports: [TestDir],
})
export class TestComp {
acceptsString(value: string) {}
}
`);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
expect(diags[0].messageText)
.toBe(`Argument of type 'number' is not assignable to parameter of type 'string'.`);
});
it('should report unset required model inputs', () => {
env.write('test.ts', `
import {Component, Directive, model} from '@angular/core';
@Directive({
selector: '[dir]',
standalone: true,
})
export class TestDir {
value = model.required<number>();
}
@Component({
standalone: true,
template: \`<div dir></div>\`,
imports: [TestDir],
})
export class TestComp {
}
`);
const diagnostics = env.driveDiagnostics();
expect(diagnostics.length).toBe(1);
expect(diagnostics[0].messageText)
.toBe(`Required input 'value' from directive TestDir must be specified.`);
});
it('should check generic two-way model binding with a primitive value', () => {
env.write('test.ts', `
import {Component, Directive, model} from '@angular/core';
@Directive({
selector: '[dir]',
standalone: true,
})
export class TestDir<T extends {id: string}> {
value = model.required<T>();
}
@Component({
standalone: true,
template: \`<div dir [(value)]="value"></div>\`,
imports: [TestDir],
})
export class TestComp {
value = {id: 1};
}
`);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
expect(diags[0].messageText)
.toBe(`Type '{ id: number; }' is not assignable to type '{ id: string; }'.`);
});
it('should check generic two-way model binding with a signal value', () => {
env.write('test.ts', `
import {Component, Directive, model, signal} from '@angular/core';
@Directive({
selector: '[dir]',
standalone: true,
})
export class TestDir<T extends {id: string}> {
value = model.required<T>();
}
@Component({
standalone: true,
template: \`<div dir [(value)]="value"></div>\`,
imports: [TestDir],
})
export class TestComp {
value = signal({id: 1});
}
`);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
expect(diags[0].messageText)
.toEqual(`Type '{ id: number; }' is not assignable to type '{ id: string; }'.`);
});
it('should report unwrapped signals assigned to a model in a one-way binding', () => {
env.write('test.ts', `
import {Component, Directive, model, signal} from '@angular/core';
@Directive({
selector: '[dir]',
standalone: true,
})
export class TestDir {
value = model(0);
}
@Component({
standalone: true,
template: \`<div dir [value]="value"></div>\`,
imports: [TestDir],
})
export class TestComp {
value = signal(1);
}
`);
const diagnostics = env.driveDiagnostics();
expect(diagnostics.length).toBe(1);
expect(diagnostics[0].messageText)
.toBe(`Type 'WritableSignal<number>' is not assignable to type 'number'.`);
});
});
it('should allow two-way binding to a generic model input', () => {
env.write('test.ts', `
import {Component, Directive, model, signal} from '@angular/core';
@Directive({
selector: '[dir]',
standalone: true,
})
export class TestDir<T> {
value = model.required<T>();
}
@Component({
standalone: true,
template: \`<div dir [(value)]="value"></div>\`,
imports: [TestDir],
})
export class TestComp {
value = signal(1);
}
`);
const diags = env.driveDiagnostics();
expect(diags).toEqual([]);
});
// TODO(atscott): fix this test which was broken by #54711
xit('should not widen the type of two-way bindings on Angular versions less than 17.2', () => {
env.tsconfig({_angularCoreVersion: '16.50.60', strictTemplates: true});
env.write('test.ts', `
import {Component, Directive, Input, Output, EventEmitter, signal} from '@angular/core';
@Directive({
selector: '[dir]',
standalone: true,
})
export class TestDir {
@Input() value = 0;
@Output() valueChange = new EventEmitter<number>();
}
@Component({
standalone: true,
template: \`<div dir [(value)]="value"></div>\`,
imports: [TestDir],
})
export class TestComp {
value = signal(1);
}
`);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
expect(diags[0].messageText)
.toBe(`Type 'WritableSignal<number>' is not assignable to type 'number'.`);
});
it('should widen the type of two-way bindings on supported Angular versions', () => {
['17.2.0', '17.2.0-rc.0', '0.0.0-PLACEHOLDER', '18.0.0'].forEach(version => {
env.tsconfig({_angularCoreVersion: version, strictTemplates: true});
env.write('test.ts', `
import {Component, Directive, Input, Output, EventEmitter, signal} from '@angular/core';
@Directive({
selector: '[dir]',
standalone: true,
})
export class TestDir {
@Input() value = 0;
@Output() valueChange = new EventEmitter<number>();
}
@Component({
standalone: true,
template: \`<div dir [(value)]="value"></div>\`,
imports: [TestDir],
})
export class TestComp {
value = signal(1);
}
`);
const diags = env.driveDiagnostics();
expect(diags).withContext(`On version ${version}`).toEqual([]);
});
});
describe('diagnostics', () => {
it('should error when declared using an ES private field', () => {
env.write('test.ts', `
import {Directive, model} from '@angular/core';
@Directive({
selector: '[directiveName]',
standalone: true,
})
export class TestDir {
#data = model.required<boolean>();
}
`);
const diagnostics = env.driveDiagnostics();
expect(diagnostics.length).toBe(1);
expect(diagnostics).toEqual([jasmine.objectContaining<ts.Diagnostic>({
messageText: jasmine.objectContaining<ts.DiagnosticMessageChain>({
messageText: `Cannot use "model" on a class member that is declared as ES private.`,
}),
})]);
});
it('should error when declared using a `private` field', () => {
env.write('test.ts', `
import {Directive, model} from '@angular/core';
@Directive({
selector: '[directiveName]',
standalone: true,
})
export class TestDir {
private data = model.required<boolean>();
}
`);
const diagnostics = env.driveDiagnostics();
expect(diagnostics.length).toBe(1);
expect(diagnostics).toEqual([jasmine.objectContaining<ts.Diagnostic>({
messageText: jasmine.objectContaining<ts.DiagnosticMessageChain>({
messageText: `Cannot use "model" on a class member that is declared as private.`,
}),
})]);
});
it('should allow using a `protected` field', () => {
env.write('test.ts', `
import {Directive, model} from '@angular/core';
@Directive({
selector: '[directiveName]',
standalone: true,
})
export class TestDir {
protected data = model.required<boolean>();
}
`);
const diagnostics = env.driveDiagnostics();
expect(diagnostics.length).toBe(0);
});
});
});
});