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
1 change: 1 addition & 0 deletions goldens/public-api/compiler-cli/error_code.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export enum ErrorCode {
// (undocumented)
CONFIG_STRICT_TEMPLATES_IMPLIES_FULL_TEMPLATE_TYPECHECK = 4002,
CONFLICTING_INPUT_TRANSFORM = 2020,
CONTROL_FLOW_PREVENTING_CONTENT_PROJECTION = 8011,
// (undocumented)
DECORATOR_ARG_NOT_LITERAL = 1001,
// (undocumented)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

// @public
export enum ExtendedTemplateDiagnosticName {
// (undocumented)
CONTROL_FLOW_PREVENTING_CONTENT_PROJECTION = "controlFlowPreventingContentProjection",
// (undocumented)
INTERPOLATED_SIGNAL_NOT_INVOKED = "interpolatedSignalNotInvoked",
// (undocumented)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -463,10 +463,7 @@ export class ComponentDecoratorHandler implements
rawHostDirectives,
meta: {
...metadata,
template: {
nodes: template.nodes,
ngContentSelectors: template.ngContentSelectors,
},
template,
encapsulation,
changeDetection,
interpolation: template.interpolationConfig ?? DEFAULT_INTERPOLATION_CONFIG,
Expand Down Expand Up @@ -546,6 +543,8 @@ export class ComponentDecoratorHandler implements
schemas: analysis.schemas,
decorator: analysis.decorator,
assumedToExportProviders: false,
ngContentSelectors: analysis.template.ngContentSelectors,
preserveWhitespaces: analysis.template.preserveWhitespaces ?? false,
});

this.resourceRegistry.registerResources(analysis.resources, node);
Expand Down Expand Up @@ -614,7 +613,7 @@ export class ComponentDecoratorHandler implements
ctx.addTemplate(
new Reference(node), binder, meta.template.diagNodes, scope.pipes, scope.schemas,
meta.template.sourceMapping, meta.template.file, meta.template.errors,
meta.meta.isStandalone);
meta.meta.isStandalone, meta.meta.template.preserveWhitespaces ?? false);
}

extendedTemplateCheck(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,9 @@ export class DirectiveDecoratorHandler implements
isSignal: analysis.meta.isSignal,
imports: null,
schemas: null,
ngContentSelectors: null,
decorator: analysis.decorator,
preserveWhitespaces: false,
// Directives analyzed within our own compilation are not _assumed_ to export providers.
// Instead, we statically analyze their imports to make a direct determination.
assumedToExportProviders: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ runInEachFileSystem(() => {
selector: '[dir]',
isStructural: false,
animationTriggerNames: null,
ngContentSelectors: null,
preserveWhitespaces: false,
};
matcher.addSelectables(CssSelector.parse('[dir]'), [dirMeta]);

Expand Down
17 changes: 12 additions & 5 deletions packages/compiler-cli/src/ngtsc/core/src/compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import {StandaloneComponentScopeReader} from '../../scope/src/standalone';
import {aliasTransformFactory, CompilationMode, declarationTransformFactory, DecoratorHandler, DtsTransformRegistry, ivyTransformFactory, TraitCompiler} from '../../transform';
import {TemplateTypeCheckerImpl} from '../../typecheck';
import {OptimizeFor, TemplateTypeChecker, TypeCheckingConfig} from '../../typecheck/api';
import {ALL_DIAGNOSTIC_FACTORIES, ExtendedTemplateCheckerImpl} from '../../typecheck/extended';
import {ALL_DIAGNOSTIC_FACTORIES, ExtendedTemplateCheckerImpl, SUPPORTED_DIAGNOSTIC_NAMES} from '../../typecheck/extended';
import {ExtendedTemplateChecker} from '../../typecheck/extended/api';
import {getSourceFileOrNull, isDtsPath, toUnredirectedSourceFile} from '../../util/src/typescript';
import {Xi18nContext} from '../../xi18n';
Expand Down Expand Up @@ -782,6 +782,8 @@ export class NgCompiler {
// (providing the full TemplateTypeChecker API) and if strict mode is not enabled. In strict
// mode, the user is in full control of type inference.
suggestionsForSuboptimalTypeInference: this.enableTemplateTypeChecker && !strictTemplates,
controlFlowPreventingContentProjection:
this.options.extendedDiagnostics?.defaultCategory || DiagnosticCategoryLabel.Warning,
};
} else {
typeCheckingConfig = {
Expand Down Expand Up @@ -810,6 +812,8 @@ export class NgCompiler {
// In "basic" template type-checking mode, no warnings are produced since most things are
// not checked anyways.
suggestionsForSuboptimalTypeInference: false,
controlFlowPreventingContentProjection:
this.options.extendedDiagnostics?.defaultCategory || DiagnosticCategoryLabel.Warning,
};
}

Expand Down Expand Up @@ -848,6 +852,11 @@ export class NgCompiler {
if (this.options.strictLiteralTypes !== undefined) {
typeCheckingConfig.strictLiteralTypes = this.options.strictLiteralTypes;
}
if (this.options.extendedDiagnostics?.checks?.controlFlowPreventingContentProjection !==
undefined) {
typeCheckingConfig.controlFlowPreventingContentProjection =
this.options.extendedDiagnostics.checks.controlFlowPreventingContentProjection;
}

return typeCheckingConfig;
}
Expand Down Expand Up @@ -1284,18 +1293,16 @@ ${allowedCategoryLabels.join('\n')}
});
}

const allExtendedDiagnosticNames =
ALL_DIAGNOSTIC_FACTORIES.map((factory) => factory.name) as string[];
for (const [checkName, category] of Object.entries(options.extendedDiagnostics?.checks ?? {})) {
if (!allExtendedDiagnosticNames.includes(checkName)) {
if (!SUPPORTED_DIAGNOSTIC_NAMES.has(checkName)) {
yield makeConfigDiagnostic({
category: ts.DiagnosticCategory.Error,
code: ErrorCode.CONFIG_EXTENDED_DIAGNOSTICS_UNKNOWN_CHECK,
messageText: `
Angular compiler option "extendedDiagnostics.checks" has an unknown check: "${checkName}".

Allowed check names are:
${allExtendedDiagnosticNames.join('\n')}
${Array.from(SUPPORTED_DIAGNOSTIC_NAMES).join('\n')}
`.trim(),
});
}
Expand Down
15 changes: 15 additions & 0 deletions packages/compiler-cli/src/ngtsc/diagnostics/src/error_code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,21 @@ export enum ErrorCode {
*/
INACCESSIBLE_DEFERRED_TRIGGER_ELEMENT = 8010,

/**
* A control flow node is projected at the root of a component and is preventing its direct
* descendants from being projected, because it has more than one root node.
*
* ```
* <comp>
* @if (expr) {
* <div projectsIntoSlot></div>
* Text preventing the div from being projected
* }
* </comp>
* ```
*/
CONTROL_FLOW_PREVENTING_CONTENT_PROJECTION = 8011,

/**
* A two way binding in a template has an incorrect syntax,
* parentheses outside brackets. For example:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,6 @@ export enum ExtendedTemplateDiagnosticName {
MISSING_NGFOROF_LET = 'missingNgForOfLet',
SUFFIX_NOT_SUPPORTED = 'suffixNotSupported',
SKIP_HYDRATION_NOT_STATIC = 'skipHydrationNotStatic',
INTERPOLATED_SIGNAL_NOT_INVOKED = 'interpolatedSignalNotInvoked'
INTERPOLATED_SIGNAL_NOT_INVOKED = 'interpolatedSignalNotInvoked',
CONTROL_FLOW_PREVENTING_CONTENT_PROJECTION = 'controlFlowPreventingContentProjection',
}
2 changes: 2 additions & 0 deletions packages/compiler-cli/src/ngtsc/indexer/test/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ export function getBoundTemplate(
exportAs: null,
isStructural: false,
animationTriggerNames: null,
ngContentSelectors: null,
preserveWhitespaces: false,
}]);
});
const binder = new R3TargetBinder(matcher);
Expand Down
7 changes: 7 additions & 0 deletions packages/compiler-cli/src/ngtsc/metadata/src/dts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@ export class DtsMetadataReader implements MetadataReader {
param.typeValueReference.importedName === 'TemplateRef';
});

const ngContentSelectors =
def.type.typeArguments.length > 6 ? readStringArrayType(def.type.typeArguments[6]) : null;

const isStandalone =
def.type.typeArguments.length > 7 && (readBooleanType(def.type.typeArguments[7]) ?? false);

Expand Down Expand Up @@ -126,6 +129,7 @@ export class DtsMetadataReader implements MetadataReader {
isPoisoned: false,
isStructural,
animationTriggerNames: null,
ngContentSelectors,
isStandalone,
isSignal,
// Imports are tracked in metadata only for template type-checking purposes,
Expand All @@ -136,6 +140,9 @@ export class DtsMetadataReader implements MetadataReader {
decorator: null,
// Assume that standalone components from .d.ts files may export providers.
assumedToExportProviders: isComponent && isStandalone,
// `preserveWhitespaces` isn't encoded in the .d.ts and is only
// used to increase the accuracy of a diagnostic.
preserveWhitespaces: false,
};
}

Expand Down
2 changes: 2 additions & 0 deletions packages/compiler-cli/src/ngtsc/scope/test/local_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,8 @@ function fakeDirective(ref: Reference<ClassDeclaration>): DirectiveMeta {
decorator: null,
hostDirectives: null,
assumedToExportProviders: false,
ngContentSelectors: null,
preserveWhitespaces: false,
};
}

Expand Down
10 changes: 10 additions & 0 deletions packages/compiler-cli/src/ngtsc/typecheck/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@ export interface TypeCheckBlockMetadata {
* A boolean indicating whether the component is standalone.
*/
isStandalone: boolean;

/**
* A boolean indicating whether the component preserves whitespaces in its template.
*/
preserveWhitespaces: boolean;
}

export interface TypeCtorMetadata {
Expand Down Expand Up @@ -273,6 +278,11 @@ export interface TypeCheckingConfig {
*/
checkQueries: false;

/**
* Whether to check if control flow syntax will prevent a node from being projected.
*/
controlFlowPreventingContentProjection: 'error'|'warning'|'suppress';

/**
* Whether to use any generic types of the context component.
*
Expand Down
4 changes: 3 additions & 1 deletion packages/compiler-cli/src/ngtsc/typecheck/api/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,15 @@ export interface TypeCheckContext {
* @param file the `ParseSourceFile` associated with the template.
* @param parseErrors the `ParseError`'s associated with the template.
* @param isStandalone a boolean indicating whether the component is standalone.
* @param preserveWhitespaces a boolean indicating whether the component's template preserves
* whitespaces.
*/
addTemplate(
ref: Reference<ClassDeclaration<ts.ClassDeclaration>>,
binder: R3TargetBinder<TypeCheckableDirectiveMeta>, template: TmplAstNode[],
pipes: Map<string, Reference<ClassDeclaration<ts.ClassDeclaration>>>,
schemas: SchemaMetadata[], sourceMapping: TemplateSourceMapping, file: ParseSourceFile,
parseErrors: ParseError[]|null, isStandalone: boolean): void;
parseErrors: ParseError[]|null, isStandalone: boolean, preserveWhitespaces: boolean): void;
}

/**
Expand Down
6 changes: 6 additions & 0 deletions packages/compiler-cli/src/ngtsc/typecheck/extended/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,9 @@ export const ALL_DIAGNOSTIC_FACTORIES:
textAttributeNotBindingFactory, missingNgForOfLetFactory, suffixNotSupportedFactory,
interpolatedSignalNotInvoked
];


export const SUPPORTED_DIAGNOSTIC_NAMES = new Set<string>([
ExtendedTemplateDiagnosticName.CONTROL_FLOW_PREVENTING_CONTENT_PROJECTION,
...ALL_DIAGNOSTIC_FACTORIES.map(factory => factory.name)
]);
5 changes: 3 additions & 2 deletions packages/compiler-cli/src/ngtsc/typecheck/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ export class TypeCheckContextImpl implements TypeCheckContext {
binder: R3TargetBinder<TypeCheckableDirectiveMeta>, template: TmplAstNode[],
pipes: Map<string, Reference<ClassDeclaration<ts.ClassDeclaration>>>,
schemas: SchemaMetadata[], sourceMapping: TemplateSourceMapping, file: ParseSourceFile,
parseErrors: ParseError[]|null, isStandalone: boolean): void {
parseErrors: ParseError[]|null, isStandalone: boolean, preserveWhitespaces: boolean): void {
if (!this.host.shouldCheckComponent(ref.node)) {
return;
}
Expand Down Expand Up @@ -293,7 +293,8 @@ export class TypeCheckContextImpl implements TypeCheckContext {
boundTarget,
pipes,
schemas,
isStandalone
isStandalone,
preserveWhitespaces,
};
this.perf.eventCount(PerfEvent.GenerateTcb);
if (inliningRequirement !== TcbInliningRequirement.None &&
Expand Down
45 changes: 44 additions & 1 deletion packages/compiler-cli/src/ngtsc/typecheck/src/oob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/

import {BindingPipe, PropertyRead, PropertyWrite, TmplAstBoundAttribute, TmplAstBoundEvent, TmplAstElement, TmplAstForLoopBlock, TmplAstHoverDeferredTrigger, TmplAstInteractionDeferredTrigger, TmplAstReference, TmplAstTemplate, TmplAstVariable, TmplAstViewportDeferredTrigger} from '@angular/compiler';
import {BindingPipe, PropertyRead, PropertyWrite, TmplAstBoundAttribute, TmplAstBoundEvent, TmplAstElement, TmplAstForLoopBlock, TmplAstHoverDeferredTrigger, TmplAstIfBlockBranch, TmplAstInteractionDeferredTrigger, TmplAstReference, TmplAstTemplate, TmplAstVariable, TmplAstViewportDeferredTrigger} from '@angular/compiler';
import ts from 'typescript';

import {ErrorCode, makeDiagnostic, makeRelatedInformation, ngErrorCode} from '../../diagnostics';
Expand Down Expand Up @@ -100,6 +100,15 @@ export interface OutOfBandDiagnosticRecorder {
templateId: TemplateId,
trigger: TmplAstHoverDeferredTrigger|TmplAstInteractionDeferredTrigger|
TmplAstViewportDeferredTrigger): void;

/**
* Reports cases where control flow nodes prevent content projection.
*/
controlFlowPreventingContentProjection(
templateId: TemplateId, category: ts.DiagnosticCategory,
projectionNode: TmplAstElement|TmplAstTemplate, componentName: string, slotSelector: string,
controlFlowNode: TmplAstIfBlockBranch|TmplAstForLoopBlock,
preservesWhitespaces: boolean): void;
}

export class OutOfBandDiagnosticRecorderImpl implements OutOfBandDiagnosticRecorder {
Expand Down Expand Up @@ -340,6 +349,40 @@ export class OutOfBandDiagnosticRecorderImpl implements OutOfBandDiagnosticRecor
ts.DiagnosticCategory.Error, ngErrorCode(ErrorCode.INACCESSIBLE_DEFERRED_TRIGGER_ELEMENT),
message));
}

controlFlowPreventingContentProjection(
templateId: TemplateId, category: ts.DiagnosticCategory,
projectionNode: TmplAstElement|TmplAstTemplate, componentName: string, slotSelector: string,
controlFlowNode: TmplAstIfBlockBranch|TmplAstForLoopBlock,
preservesWhitespaces: boolean): void {
const blockName = controlFlowNode instanceof TmplAstIfBlockBranch ? '@if' : '@for';
const lines = [
`Node matches the "${slotSelector}" slot of the "${
componentName}" component, but will not be projected into the specific slot because the surrounding ${
blockName} has more than one node at its root. To project the node in the right slot, you can:\n`,
`1. Wrap the content of the ${blockName} block in an <ng-container/> that matches the "${
slotSelector}" selector.`,
`2. Split the content of the ${blockName} block across multiple ${
blockName} blocks such that each one only has a single projectable node at its root.`,
`3. Remove all content from the ${blockName} block, except for the node being projected.`
];

if (preservesWhitespaces) {
lines.push(
'Note: the host component has `preserveWhitespaces: true` which may ' +
'cause whitespace to affect content projection.');
}

lines.push(
'',
'This check can be disabled using the `extendedDiagnostics.checks.' +
'controlFlowPreventingContentProjection = "suppress" compiler option.`');

this._diagnostics.push(makeTemplateDiagnostic(
templateId, this.resolver.getSourceMapping(templateId), projectionNode.startSourceSpan,
category, ngErrorCode(ErrorCode.CONTROL_FLOW_PREVENTING_CONTENT_PROJECTION),
lines.join('\n')));
}
}

function makeInlineDiagnostic(
Expand Down
Loading