Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,11 @@ export const INPUT_INITIALIZER_FN: InitializerApiFunction = {
// Conceptually, the fields need to be publicly readable, but in practice,
// accessing `protected` or `private` members works at runtime, so we can allow
// cases where the input is intentionally not part of the public API, programmatically.
// Note: `private` is omitted intentionally as this would be a conceptual confusion point.
allowedAccessLevels: [
ClassMemberAccessLevel.PublicWritable,
ClassMemberAccessLevel.PublicReadonly,
ClassMemberAccessLevel.Protected,
ClassMemberAccessLevel.Private,
],
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export const MODEL_INITIALIZER_FN: InitializerApiFunction = {
ClassMemberAccessLevel.PublicWritable,
ClassMemberAccessLevel.PublicReadonly,
ClassMemberAccessLevel.Protected,
ClassMemberAccessLevel.Private,
],
};

Expand Down Expand Up @@ -56,8 +57,7 @@ export function tryParseSignalModelMapping(
validateAccessOfInitializerApiMember(model, member);

const optionsNode = (model.isRequired ? model.call.arguments[0] : model.call.arguments[1]) as
| ts.Expression
| undefined;
ts.Expression | undefined;
const options =
optionsNode !== undefined ? parseAndValidateInputAndOutputOptions(optionsNode) : null;
const classPropertyName = member.name;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,11 @@ import {parseAndValidateInputAndOutputOptions} from './input_output_parse_option
// accessing `protected` or `private` members works at runtime, so we can allow
// such outputs that may not want to expose the `OutputRef` as part of the
// component API, programmatically.
// Note: `private` is omitted intentionally as this would be a conceptual confusion point.
const allowedAccessLevels = [
ClassMemberAccessLevel.PublicWritable,
ClassMemberAccessLevel.PublicReadonly,
ClassMemberAccessLevel.Protected,
ClassMemberAccessLevel.Private,
];

/** Possible functions that can declare an output. */
Expand Down
23 changes: 16 additions & 7 deletions packages/compiler-cli/src/ngtsc/typecheck/src/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,15 @@ import {getSourceMapping, TypeCheckSourceResolver} from './tcb_util';
/**
* This function will the check the source text of the TCB instead of doing a more expensive AST traversal (via getTokenAtPosition)
*/
function isAccessOnThis(text: string, start: number): boolean {
return (
text.substring(start - 5, start) === 'this.' || text.substring(start - 7, start) === '(this).'
);
function isAccessAllowed(text: string, start: number): boolean {
if (
text.substring(start - 5, start) === 'this.' ||
text.substring(start - 7, start) === '(this).'
) {
return true;
}
const snippet = text.substring(Math.max(0, start - 15), start);
return /_t\d+\.$/.test(snippet);
}

/**
Expand All @@ -36,10 +41,14 @@ export function shouldReportDiagnostic(diagnostic: ts.Diagnostic): boolean {
return false;
} else if (code === 7006 /* Parameter '$event' implicitly has an 'any' type. */) {
return false;
} else if (code === 2341 /* Property 'X' is private and only accessible within class */) {
} else if (
code === 2341 /* Property 'X' is private and only accessible within class */ ||
code ===
2445 /* Property 'X' is protected and only accessible within class 'Y' and its subclasses. */
) {
if (diagnostic.file !== undefined && diagnostic.start !== undefined) {
// Here we're discarding private property reads error to allow them to be used in template expressions
if (isAccessOnThis(diagnostic.file.text, diagnostic.start)) {
// Here we're discarding private/protected property reads error to allow them to be used in template expressions
if (isAccessAllowed(diagnostic.file.text, diagnostic.start)) {
return false;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,24 +104,20 @@ runInEachFileSystem(() => {
},
// restricted fields
{
id: 'disallows access to private input',
id: 'allow access to private input',
inputs: {
pattern: {type: 'InputSignal<string>', isSignal: true, restrictionModifier: 'private'},
},
template: `<div dir [pattern]="'works'">`,
expected: [
`TestComponent.html(1, 11): Property 'pattern' is private and only accessible within class 'Dir'.`,
],
expected: [],
},
{
id: 'disallows access to protected input',
id: 'allow access to protected input',
inputs: {
pattern: {type: 'InputSignal<string>', isSignal: true, restrictionModifier: 'protected'},
},
template: `<div dir [pattern]="'works'">`,
expected: [
`TestComponent.html(1, 11): Property 'pattern' is protected and only accessible within class 'Dir' and its subclasses.`,
],
expected: [],
},
{
// NOTE FOR REVIEWER: This is something different with input signals. The framework
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,29 +101,25 @@ runInEachFileSystem(() => {
},
// restricted fields
{
id: 'disallows access to private model',
id: 'allow access to private model',
inputs: {
pattern: {type: 'ModelSignal<string>', isSignal: true, restrictionModifier: 'private'},
},
outputs: {patternChange: {type: 'ModelSignal<string>'}},
template: `<div dir [pattern]="'works'">`,
expected: [
`TestComponent.html(1, 11): Property 'pattern' is private and only accessible within class 'Dir'.`,
],
expected: [],
},
{
id: 'disallows access to protected model',
id: 'allow access to protected model',
inputs: {
pattern: {type: 'ModelSignal<string>', isSignal: true, restrictionModifier: 'protected'},
},
outputs: {patternChange: {type: 'ModelSignal<string>'}},
template: `<div dir [pattern]="'works'">`,
expected: [
`TestComponent.html(1, 11): Property 'pattern' is protected and only accessible within class 'Dir' and its subclasses.`,
],
expected: [],
},
{
id: 'allows access to readonly model by default',
id: 'allow access to readonly model by default',
inputs: {
pattern: {type: 'ModelSignal<string>', isSignal: true, restrictionModifier: 'readonly'},
},
Expand Down
11 changes: 2 additions & 9 deletions packages/compiler-cli/test/ngtsc/authoring_inputs_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,7 +435,7 @@ runInEachFileSystem(() => {
]);
});

it('should error when declared using a `private` field', () => {
it('should allow declaring using a `private` field', () => {
env.write(
'test.ts',
`
Expand All @@ -451,14 +451,7 @@ runInEachFileSystem(() => {
);

const diagnostics = env.driveDiagnostics();
expect(diagnostics.length).toBe(1);
expect(diagnostics).toEqual([
jasmine.objectContaining<ts.Diagnostic>({
messageText: jasmine.objectContaining<ts.DiagnosticMessageChain>({
messageText: `Cannot use "input" on a class member that is declared as private.`,
}),
}),
]);
expect(diagnostics.length).toBe(0);
});

it('should allow declaring using a `protected` field', () => {
Expand Down
11 changes: 2 additions & 9 deletions packages/compiler-cli/test/ngtsc/authoring_models_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -686,7 +686,7 @@ runInEachFileSystem(() => {
]);
});

it('should error when declared using a `private` field', () => {
it('should allow declaring using a `private` field', () => {
env.write(
'test.ts',
`
Expand All @@ -702,14 +702,7 @@ runInEachFileSystem(() => {
);

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.`,
}),
}),
]);
expect(diagnostics.length).toBe(0);
});

it('should allow using a `protected` field', () => {
Expand Down
11 changes: 2 additions & 9 deletions packages/compiler-cli/test/ngtsc/authoring_outputs_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ runInEachFileSystem(() => {
]);
});

it('should report an error when using a `private` field', () => {
it('should allow an output using a `private` field', () => {
env.write(
'test.ts',
`
Expand All @@ -295,14 +295,7 @@ runInEachFileSystem(() => {
);
const diagnostics = env.driveDiagnostics();

expect(diagnostics.length).toBe(1);
expect(diagnostics).toEqual([
jasmine.objectContaining<ts.Diagnostic>({
messageText: jasmine.objectContaining<ts.DiagnosticMessageChain>({
messageText: `Cannot use "output" on a class member that is declared as private.`,
}),
}),
]);
expect(diagnostics.length).toBe(0);
});

it('should allow an output using a `protected` field', () => {
Expand Down
40 changes: 35 additions & 5 deletions packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3623,27 +3623,57 @@ runInEachFileSystem(() => {
});
});

it('should produce diagnostics for inputs which assign to readonly, private, and protected fields', () => {
it('should produce a diagnostic for inputs which assign to a readonly field, but allow private and protected fields', () => {
env.write('test.ts', correctTypeInputsToRestrictedFields);
expectIllegalAssignmentErrors(env.driveDiagnostics());
});

it('should produce diagnostics for inputs which assign to readonly, private, and protected fields inherited from a base class', () => {
it('should produce a diagnostic for inputs which assign to a readonly field inherited from a base class, but allow private and protected fields', () => {
env.write('test.ts', correctInputsToRestrictedFieldsFromBaseClass);
expectIllegalAssignmentErrors(env.driveDiagnostics());
});

function expectIllegalAssignmentErrors(diags: ReadonlyArray<ts.Diagnostic>) {
expect(diags.length).toBe(3);
expect(diags.length).toBe(1);
const actualMessages = diags.map((d) => d.messageText).sort();
const expectedMessages = [
`Property 'protectedField' is protected and only accessible within class 'TestDir' and its subclasses.`,
`Property 'privateField' is private and only accessible within class 'TestDir'.`,
`Cannot assign to 'readonlyField' because it is a read-only property.`,
].sort();
expect(actualMessages).toEqual(expectedMessages);
}

it('should allow binding to private and protected signal inputs, models, and outputs', () => {
env.write(
'test.ts',
`
import {Component, Directive, input, model, output} from '@angular/core';

@Directive({
selector: '[dir]',
})
export class TestDir {
protected protectedInput = input<string>();
private privateInput = input<string>();
protected protectedModel = model<string>();
private privateModel = model<string>();
protected protectedOutput = output<string>();
private privateOutput = output<string>();
}

@Component({
selector: 'blah',
template: '<div dir [protectedInput]="value" [privateInput]="value" [(protectedModel)]="value" [(privateModel)]="value" (protectedOutput)="value = $event" (privateOutput)="value = $event"></div>',
imports: [TestDir],
})
export class FooCmp {
value = "value";
}
`,
);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(0);
});

it('should report invalid type assignment when field name is not a valid JS identifier', () => {
env.write(
'test.ts',
Expand Down