Skip to content
Open
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
38 changes: 38 additions & 0 deletions packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4099,6 +4099,44 @@ runInEachFileSystem(() => {
3. To allow any property add 'NO_ERRORS_SCHEMA' to the '@Component.schemas' of this component.`);
});

it('should report an unknown property on ng-template when a standalone directive is not imported', () => {
env.write(
'test.ts',
`
import {Component, Directive, input} from '@angular/core';

@Directive({
selector: 'ng-template[auiTypedOption]',
standalone: true,
})
export class TypedOptionDirective {
auiTypedOption = input.required<any>();
}

@Component({
selector: 'test',
standalone: true,
imports: [],
template: \`
<ng-template [auiTypedOption]="searchResult()"></ng-template>
\`,
})
export class TestCmp {
searchResult() {
return {name: 'test'};
}
}
`,
);

const diags = env.driveDiagnostics();

expect(diags.length).toBe(1);
expect(diags[0].messageText).toContain(
`Can't bind to 'auiTypedOption' since it isn't a known property of 'ng-template'.`,
);
});

it('should have a descriptive error for unknown elements that contain a dash', () => {
env.write(
'test.ts',
Expand Down
28 changes: 22 additions & 6 deletions packages/compiler/src/typecheck/ops/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/

import {BindingType} from '../../expression_parser/ast';
import {Component, Element, HostElement} from '../../render3/r3_ast';
import {BoundAttribute, Component, Element, HostElement, Template} from '../../render3/r3_ast';
import {DomElementSchemaRegistry} from '../../schema/dom_element_schema_registry';
const REGISTRY = new DomElementSchemaRegistry();
import {TcbOp} from './base';
Expand All @@ -28,7 +28,7 @@ import {getComponentTagName} from './selectorless';
export class TcbDomSchemaCheckerOp extends TcbOp {
constructor(
private tcb: Context,
private element: Element | Component | HostElement,
private element: Element | Component | Template | HostElement,
private checkElement: boolean,
private claimedInputs: Set<string> | null,
) {
Expand All @@ -41,8 +41,16 @@ export class TcbDomSchemaCheckerOp extends TcbOp {

override execute(): TcbExpr | null {
const element = this.element;
const isTemplateElement = element instanceof Element || element instanceof Component;
const bindings = isTemplateElement ? element.inputs : element.bindings;
const isTemplateElement =
element instanceof Element || element instanceof Component || element instanceof Template;

let bindings = isTemplateElement ? element.inputs : element.bindings;
if (element instanceof Template) {
const boundAttrs = element.templateAttrs.filter(
(attr): attr is BoundAttribute => attr instanceof BoundAttribute,
);
bindings = [...bindings, ...boundAttrs];
}

if (this.checkElement && isTemplateElement) {
this.tcb.domSchemaChecker.checkElement(
Expand Down Expand Up @@ -91,7 +99,15 @@ export class TcbDomSchemaCheckerOp extends TcbOp {
return null;
}

private getTagName(node: Element | Component): string {
return node instanceof Element ? node.name : getComponentTagName(node);
private getTagName(node: Element | Component | Template): string {
if (node instanceof Element) {
return node.name;
}

if (node instanceof Template) {
return 'ng-template';
}

return getComponentTagName(node);
}
}
33 changes: 24 additions & 9 deletions packages/compiler/src/typecheck/ops/scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -558,19 +558,30 @@ export class Scope {
if (directives === null || directives.length === 0) {
// If there are no directives, then all inputs are unclaimed inputs, so queue an operation
// to add them if needed.
if (node instanceof Element) {
this.opQueue.push(
new TcbUnclaimedInputsOp(this.tcb, this, node.inputs, node, claimedInputs),
);
if (node instanceof Element || node instanceof Template) {
if (node instanceof Element) {
this.opQueue.push(
new TcbUnclaimedInputsOp(this.tcb, this, node.inputs, node, claimedInputs),
);
}

// Skip DOM schema checks for elements matched as foreign components.
// An element can never match both an Angular directive and a foreign component
// without throwing a fatal error, so we are guaranteed that directives is empty
// and we only need to intercept in this directiveless block.
const isForeign = this.tcb.boundTarget.getForeignComponent(node) !== null;
const isForeign =
node instanceof Element && this.tcb.boundTarget.getForeignComponent(node) !== null;
if (!isForeign) {
// Skip validating the structural 'ng-template' tag itself against the standard DOM element registry
const isTemplateTag = node instanceof Template;

this.opQueue.push(
new TcbDomSchemaCheckerOp(this.tcb, node, /* checkElement */ true, claimedInputs),
new TcbDomSchemaCheckerOp(
this.tcb,
node,
/* checkElement */ !isTemplateTag,
claimedInputs,
),
);
}
}
Expand Down Expand Up @@ -609,20 +620,24 @@ export class Scope {

// After expanding the directives, we might need to queue an operation to check any unclaimed
// inputs.
if (node instanceof Element) {
if (node instanceof Element || node instanceof Template) {
// Go through the directives and remove any inputs that it claims from `elementInputs`.
for (const dir of directives) {
for (const propertyName of dir.inputs.propertyNames) {
claimedInputs.add(propertyName);
}
}

this.opQueue.push(new TcbUnclaimedInputsOp(this.tcb, this, node.inputs, node, claimedInputs));
if (node instanceof Element) {
this.opQueue.push(
new TcbUnclaimedInputsOp(this.tcb, this, node.inputs, node, claimedInputs),
);
}
// If there are no directives which match this element, then it's a "plain" DOM element (or a
// web component), and should be checked against the DOM schema. If any directives match,
// we must assume that the element could be custom (either a component, or a directive like
// <router-outlet>) and shouldn't validate the element name itself.
const checkElement = directives.length === 0;
const checkElement = node instanceof Element && directives.length === 0;
this.opQueue.push(new TcbDomSchemaCheckerOp(this.tcb, node, checkElement, claimedInputs));
}
}
Expand Down
Loading