diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/oob.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/oob.ts index 774367b1721..107701f9238 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/oob.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/oob.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {BindingPipe, PropertyRead, PropertyWrite, TmplAstBoundAttribute, TmplAstBoundEvent, TmplAstElement, TmplAstForLoopBlock, TmplAstHoverDeferredTrigger, TmplAstIfBlockBranch, TmplAstInteractionDeferredTrigger, TmplAstReference, TmplAstTemplate, TmplAstVariable, TmplAstViewportDeferredTrigger} from '@angular/compiler'; +import {BindingPipe, PropertyRead, PropertyWrite, TmplAstBoundAttribute, TmplAstBoundEvent, TmplAstElement, TmplAstForLoopBlock, TmplAstForLoopBlockEmpty, TmplAstHoverDeferredTrigger, TmplAstIfBlockBranch, TmplAstInteractionDeferredTrigger, TmplAstReference, TmplAstTemplate, TmplAstVariable, TmplAstViewportDeferredTrigger} from '@angular/compiler'; import ts from 'typescript'; import {ErrorCode, makeDiagnostic, makeRelatedInformation, ngErrorCode} from '../../diagnostics'; @@ -107,7 +107,7 @@ export interface OutOfBandDiagnosticRecorder { controlFlowPreventingContentProjection( templateId: TemplateId, category: ts.DiagnosticCategory, projectionNode: TmplAstElement|TmplAstTemplate, componentName: string, slotSelector: string, - controlFlowNode: TmplAstIfBlockBranch|TmplAstForLoopBlock, + controlFlowNode: TmplAstIfBlockBranch|TmplAstForLoopBlock|TmplAstForLoopBlockEmpty, preservesWhitespaces: boolean): void; } @@ -353,9 +353,17 @@ export class OutOfBandDiagnosticRecorderImpl implements OutOfBandDiagnosticRecor controlFlowPreventingContentProjection( templateId: TemplateId, category: ts.DiagnosticCategory, projectionNode: TmplAstElement|TmplAstTemplate, componentName: string, slotSelector: string, - controlFlowNode: TmplAstIfBlockBranch|TmplAstForLoopBlock, + controlFlowNode: TmplAstIfBlockBranch|TmplAstForLoopBlock|TmplAstForLoopBlockEmpty, preservesWhitespaces: boolean): void { - const blockName = controlFlowNode instanceof TmplAstIfBlockBranch ? '@if' : '@for'; + let blockName: string; + if (controlFlowNode instanceof TmplAstForLoopBlockEmpty) { + blockName = '@empty'; + } else if (controlFlowNode instanceof TmplAstForLoopBlock) { + blockName = '@for'; + } else { + blockName = '@if'; + } + const lines = [ `Node matches the "${slotSelector}" slot of the "${ componentName}" component, but will not be projected into the specific slot because the surrounding ${ diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts index 7026b2ad5e1..6a31d33c821 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {AST, BindingPipe, BindingType, BoundTarget, Call, createCssSelectorFromNode, CssSelector, DYNAMIC_TYPE, ImplicitReceiver, ParsedEventType, ParseSourceSpan, PropertyRead, PropertyWrite, R3Identifiers, SafeCall, SafePropertyRead, SchemaMetadata, SelectorMatcher, ThisReceiver, TmplAstBoundAttribute, TmplAstBoundEvent, TmplAstBoundText, TmplAstDeferredBlock, TmplAstDeferredBlockTriggers, TmplAstElement, TmplAstForLoopBlock, TmplAstHoverDeferredTrigger, TmplAstIcu, TmplAstIfBlock, TmplAstIfBlockBranch, TmplAstInteractionDeferredTrigger, TmplAstNode, TmplAstReference, TmplAstSwitchBlock, TmplAstSwitchBlockCase, TmplAstTemplate, TmplAstText, TmplAstTextAttribute, TmplAstVariable, TmplAstViewportDeferredTrigger, TransplantedType} from '@angular/compiler'; +import {AST, BindingPipe, BindingType, BoundTarget, Call, createCssSelectorFromNode, CssSelector, DYNAMIC_TYPE, ImplicitReceiver, ParsedEventType, ParseSourceSpan, PropertyRead, PropertyWrite, R3Identifiers, SafeCall, SafePropertyRead, SchemaMetadata, SelectorMatcher, ThisReceiver, TmplAstBoundAttribute, TmplAstBoundEvent, TmplAstBoundText, TmplAstDeferredBlock, TmplAstDeferredBlockTriggers, TmplAstElement, TmplAstForLoopBlock, TmplAstForLoopBlockEmpty, TmplAstHoverDeferredTrigger, TmplAstIcu, TmplAstIfBlock, TmplAstIfBlockBranch, TmplAstInteractionDeferredTrigger, TmplAstNode, TmplAstReference, TmplAstSwitchBlock, TmplAstSwitchBlockCase, TmplAstTemplate, TmplAstText, TmplAstTextAttribute, TmplAstVariable, TmplAstViewportDeferredTrigger, TransplantedType} from '@angular/compiler'; import ts from 'typescript'; import {Reference} from '../../imports'; @@ -992,14 +992,19 @@ class TcbControlFlowContentProjectionOp extends TcbOp { } private findPotentialControlFlowNodes() { - const result: Array = []; + const result: Array = []; for (const child of this.element.children) { let eligibleNode: TmplAstForLoopBlock|TmplAstIfBlockBranch|null = null; // Only `@for` blocks and the first branch of `@if` blocks participate in content projection. if (child instanceof TmplAstForLoopBlock) { - eligibleNode = child; + if (this.shouldCheck(child)) { + result.push(child); + } + if (child.empty !== null && this.shouldCheck(child.empty)) { + result.push(child.empty); + } } else if (child instanceof TmplAstIfBlock) { eligibleNode = child.branches[0]; // @if blocks are guaranteed to have at least one branch. } @@ -1033,6 +1038,32 @@ class TcbControlFlowContentProjectionOp extends TcbOp { return result; } + + private shouldCheck(node: TmplAstNode&{children: TmplAstNode[]}): boolean { + // Skip nodes with less than two children since it's impossible + // for them to run into the issue that we're checking for. + if (node.children.length < 2) { + return false; + } + + // Count the number of root nodes while skipping empty text where relevant. + const rootNodeCount = node.children.reduce((count, node) => { + // Normally `preserveWhitspaces` would have been accounted for during parsing, however + // in `ngtsc/annotations/component/src/resources.ts#parseExtractedTemplate` we enable + // `preserveWhitespaces` to preserve the accuracy of source maps diagnostics. This means + // that we have to account for it here since the presence of text nodes affects the + // content projection behavior. + if (!(node instanceof TmplAstText) || this.tcb.hostPreserveWhitespaces || + node.value.trim().length > 0) { + count++; + } + + return count; + }, 0); + + // Content projection can only be affected if there is more than one root node. + return rootNodeCount > 1; + } } /** diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/GOLDEN_PARTIAL.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/GOLDEN_PARTIAL.js index ca395e33ba2..6c1d3c00177 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/GOLDEN_PARTIAL.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/GOLDEN_PARTIAL.js @@ -1740,6 +1740,8 @@ MyApp.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PL MyApp.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "0.0.0-PLACEHOLDER", type: MyApp, selector: "ng-component", ngImport: i0, template: ` @for (item of items; track item) {
{{item}}
+ } @empty { + Empty! } `, isInline: true }); i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyApp, decorators: [{ @@ -1748,6 +1750,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDE template: ` @for (item of items; track item) {
{{item}}
+ } @empty { + Empty! } `, }] @@ -1777,6 +1781,8 @@ MyApp.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PL MyApp.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "0.0.0-PLACEHOLDER", type: MyApp, selector: "ng-component", ngImport: i0, template: ` @for (item of items; track item) { {{item}} + } @empty { + Empty! } `, isInline: true }); i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyApp, decorators: [{ @@ -1785,6 +1791,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDE template: ` @for (item of items; track item) { {{item}} + } @empty { + Empty! } `, }] diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/TEST_CASES.json b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/TEST_CASES.json index b3f688adde1..b3ac5a42a9e 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/TEST_CASES.json +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/TEST_CASES.json @@ -550,6 +550,7 @@ }, { "description": "should generate a for block with an element root node", + "skipForTemplatePipeline": true, "inputFiles": [ "for_element_root_node.ts" ], @@ -567,6 +568,7 @@ }, { "description": "should generate a for block with an ng-template root node", + "skipForTemplatePipeline": true, "inputFiles": [ "for_template_root_node.ts" ], diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_element_root_node.ts b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_element_root_node.ts index 06aaa1c307c..fd1f1a52fb4 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_element_root_node.ts +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_element_root_node.ts @@ -4,6 +4,8 @@ import {Component} from '@angular/core'; template: ` @for (item of items; track item) {
{{item}}
+ } @empty { + Empty! } `, }) diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_element_root_node_template.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_element_root_node_template.js index dd8338a797c..1ab8f8ea748 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_element_root_node_template.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_element_root_node_template.js @@ -1,3 +1,3 @@ -consts: [["foo", "1", "bar", "2"]] +consts: [["foo", "1", "bar", "2"], ["empty-foo", "1", "empty-bar", "2"]] … -$r3$.ɵɵrepeaterCreate(0, MyApp_For_1_Template, 2, 1, "div", 0, i0.ɵɵrepeaterTrackByIdentity); \ No newline at end of file +$r3$.ɵɵrepeaterCreate(0, MyApp_For_1_Template, 2, 1, "div", 0, i0.ɵɵrepeaterTrackByIdentity, false, MyApp_ForEmpty_2_Template, 2, 0, "span", 1); \ No newline at end of file diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_template_root_node.ts b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_template_root_node.ts index 9d47c2a65aa..b473c1dd01a 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_template_root_node.ts +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_template_root_node.ts @@ -4,6 +4,8 @@ import {Component} from '@angular/core'; template: ` @for (item of items; track item) { {{item}} + } @empty { + Empty! } `, }) diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_template_root_node_template.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_template_root_node_template.js index d62ceea15f4..a35708b3948 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_template_root_node_template.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_control_flow/for_template_root_node_template.js @@ -1,3 +1,3 @@ -consts: [["foo", "1", "bar", "2"]] +consts: [["foo", "1", "bar", "2"], ["empty-foo", "1", "empty-bar", "2"]] … -$r3$.ɵɵrepeaterCreate(0, MyApp_For_1_Template, 1, 0, null, 0, i0.ɵɵrepeaterTrackByIdentity); \ No newline at end of file +$r3$.ɵɵrepeaterCreate(0, MyApp_For_1_Template, 1, 0, null, 0, i0.ɵɵrepeaterTrackByIdentity, false, MyApp_ForEmpty_2_Template, 1, 0, null, 1); \ No newline at end of file diff --git a/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts b/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts index 3c8ca1d30a4..3868e3d6b63 100644 --- a/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts +++ b/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts @@ -5196,6 +5196,42 @@ suppress `not be projected into the specific slot because the surrounding @for has more than one node at its root.`); }); + it('should report when an @empty block prevents content from being projected', () => { + env.write('test.ts', ` + import {Component} from '@angular/core'; + + @Component({ + selector: 'comp', + template: ' ', + standalone: true, + }) + class Comp {} + + @Component({ + standalone: true, + imports: [Comp], + template: \` + + @for (i of [1, 2, 3]; track i) { + + } @empty { +
+ breaks projection + } +
+ \`, + }) + class TestCmp {} + `); + + const diags = + env.driveDiagnostics().map(d => ts.flattenDiagnosticMessageText(d.messageText, '')); + expect(diags.length).toBe(1); + expect(diags[0]).toContain( + `Node matches the "bar, [foo]" slot of the "Comp" component, but will ` + + `not be projected into the specific slot because the surrounding @empty has more than one node at its root.`); + }); + it('should report nodes that are targeting different slots but cannot be projected', () => { env.write('test.ts', ` import {Component} from '@angular/core'; diff --git a/packages/compiler/src/render3/view/template.ts b/packages/compiler/src/render3/view/template.ts index f0186462094..cb976e70086 100644 --- a/packages/compiler/src/render3/view/template.ts +++ b/packages/compiler/src/render3/view/template.ts @@ -1498,7 +1498,8 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver * node. * @param node Node for which to infer the projection data. */ - private inferProjectionDataFromInsertionPoint(node: t.IfBlockBranch|t.ForLoopBlock) { + private inferProjectionDataFromInsertionPoint(node: t.IfBlockBranch|t.ForLoopBlock| + t.ForLoopBlockEmpty) { let root: t.Element|t.Template|null = null; let tagName: string|null = null; let attrsExprs: o.Expression[]|undefined; @@ -1560,8 +1561,13 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver const {expression: trackByExpression, usesComponentInstance: trackByUsesComponentInstance} = this.createTrackByFunction(block); let emptyData: TemplateData|null = null; + let emptyTagName: string|null = null; + let emptyAttrsExprs: o.Expression[]|undefined; if (block.empty !== null) { + const emptyInferred = this.inferProjectionDataFromInsertionPoint(block.empty); + emptyTagName = emptyInferred.tagName; + emptyAttrsExprs = emptyInferred.attrsExprs; emptyData = this.prepareEmbeddedTemplateFn( block.empty.children, '_ForEmpty', undefined, block.empty.i18n); // Allocate an extra slot for the empty block tracking. @@ -1585,13 +1591,14 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver if (emptyData !== null) { params.push( o.literal(trackByUsesComponentInstance), o.variable(emptyData.name), - o.literal(emptyData.getConstCount()), o.literal(emptyData.getVarCount())); + o.literal(emptyData.getConstCount()), o.literal(emptyData.getVarCount()), + o.literal(emptyTagName), this.addAttrsToConsts(emptyAttrsExprs || null)); } else if (trackByUsesComponentInstance) { // If the tracking function doesn't use the component instance, we can omit the flag. params.push(o.literal(trackByUsesComponentInstance)); } - return params; + return trimTrailingNulls(params); }); // Note: the expression needs to be processed *after* the template, diff --git a/packages/core/src/render3/instructions/control_flow.ts b/packages/core/src/render3/instructions/control_flow.ts index 5d32b5ef3f2..f3a0b7991a1 100644 --- a/packages/core/src/render3/instructions/control_flow.ts +++ b/packages/core/src/render3/instructions/control_flow.ts @@ -138,6 +138,8 @@ class RepeaterMetadata { * @param emptyTemplateFn Reference to the template function of the empty block. * @param emptyDecls The number of nodes, local refs, and pipes for the empty block. * @param emptyVars The number of bindings for the empty block. + * @param emptyTagName The name of the empty block container element, if applicable + * @param emptyAttrsIndex Index of the empty block template attributes in the `consts` array. * * @codeGenApi */ @@ -145,7 +147,8 @@ export function ɵɵrepeaterCreate( index: number, templateFn: ComponentTemplate, decls: number, vars: number, tagName: string|null, attrsIndex: number|null, trackByFn: TrackByFunction, trackByUsesComponentInstance?: boolean, emptyTemplateFn?: ComponentTemplate, - emptyDecls?: number, emptyVars?: number): void { + emptyDecls?: number, emptyVars?: number, emptyTagName?: string|null, + emptyAttrsIndex?: number|null): void { performanceMarkFeature('NgControlFlow'); const hasEmptyBlock = emptyTemplateFn !== undefined; const hostLView = getLView(); @@ -165,7 +168,7 @@ export function ɵɵrepeaterCreate( ngDevMode && assertDefined(emptyVars, 'Missing number of bindings for the empty repeater block.'); - ɵɵtemplate(index + 2, emptyTemplateFn, emptyDecls!, emptyVars!); + ɵɵtemplate(index + 2, emptyTemplateFn, emptyDecls!, emptyVars!, emptyTagName, emptyAttrsIndex); } } diff --git a/packages/core/test/acceptance/control_flow_for_spec.ts b/packages/core/test/acceptance/control_flow_for_spec.ts index cc03b21c248..fefef8b0119 100644 --- a/packages/core/test/acceptance/control_flow_for_spec.ts +++ b/packages/core/test/acceptance/control_flow_for_spec.ts @@ -388,6 +388,71 @@ describe('control flow - for', () => { expect(fixture.nativeElement.textContent).toBe('Main: Before After Slot: 123'); }); + it('should project an @empty block with a single root node into the root node slot', () => { + @Component({ + standalone: true, + selector: 'test', + template: 'Main: Slot: ', + }) + class TestComponent { + } + + @Component({ + standalone: true, + imports: [TestComponent], + template: ` + Before @for (item of items; track $index) {} @empty { + Empty + } After + ` + }) + class App { + items = []; + } + + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).toBe('Main: Before After Slot: Empty'); + }); + + it('should allow @for and @empty blocks to be projected into different slots', () => { + @Component({ + standalone: true, + selector: 'test', + template: + 'Main: Loop slot: Empty slot: ', + }) + class TestComponent { + } + + @Component({ + standalone: true, + imports: [TestComponent], + template: ` + Before @for (item of items; track $index) { + {{item}} + } @empty { + Empty + } After + ` + }) + class App { + items = [1, 2, 3]; + } + + const fixture = TestBed.createComponent(App); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent) + .toBe('Main: Before After Loop slot: 123 Empty slot: '); + + fixture.componentInstance.items = []; + fixture.detectChanges(); + expect(fixture.nativeElement.textContent) + .toBe('Main: Before After Loop slot: Empty slot: Empty'); + }); + it('should project an @for with multiple root nodes into the catch-all slot', () => { @Component({ standalone: true,