|
6 | 6 | * found in the LICENSE file at https://angular.io/license |
7 | 7 | */ |
8 | 8 |
|
9 | | -import {AST, BindingPipe, BindingType, BoundTarget, Call, DYNAMIC_TYPE, ImplicitReceiver, ParsedEventType, ParseSourceSpan, PropertyRead, PropertyWrite, SafeCall, SafePropertyRead, SchemaMetadata, ThisReceiver, TmplAstBoundAttribute, TmplAstBoundEvent, TmplAstBoundText, TmplAstDeferredBlock, TmplAstDeferredBlockTriggers, TmplAstElement, TmplAstForLoopBlock, TmplAstHoverDeferredTrigger, TmplAstIcu, TmplAstIfBlock, TmplAstIfBlockBranch, TmplAstInteractionDeferredTrigger, TmplAstNode, TmplAstReference, TmplAstSwitchBlock, TmplAstSwitchBlockCase, TmplAstTemplate, TmplAstTextAttribute, TmplAstVariable, TmplAstViewportDeferredTrigger, TransplantedType} from '@angular/compiler'; |
| 9 | +import {AST, BindingPipe, BindingType, BoundTarget, Call, createCssSelectorFromNode, CssSelector, DYNAMIC_TYPE, ImplicitReceiver, ParsedEventType, ParseSourceSpan, PropertyRead, PropertyWrite, 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'; |
10 | 10 | import ts from 'typescript'; |
11 | 11 |
|
12 | 12 | import {Reference} from '../../imports'; |
@@ -905,6 +905,100 @@ class TcbDomSchemaCheckerOp extends TcbOp { |
905 | 905 | } |
906 | 906 |
|
907 | 907 |
|
| 908 | +/** |
| 909 | + * A `TcbOp` that finds and flags control flow nodes that interfere with content projection. |
| 910 | + * |
| 911 | + * Context: |
| 912 | + * `@if` and `@for` try to emulate the content projection behavior of `*ngIf` and `*ngFor` |
| 913 | + * in order to reduce breakages when moving from one syntax to the other (see #52414), however the |
| 914 | + * approach only works if there's only one element at the root of the control flow expression. |
| 915 | + * This means that a stray sibling node (e.g. text) can prevent an element from being projected |
| 916 | + * into the right slot. The purpose of the `TcbOp` is to find any places where a node at the root |
| 917 | + * of a control flow expression *would have been projected* into a specific slot, if the control |
| 918 | + * flow node didn't exist. |
| 919 | + */ |
| 920 | +class TcbControlFlowContentProjectionOp extends TcbOp { |
| 921 | + constructor( |
| 922 | + private tcb: Context, private element: TmplAstElement, private ngContentSelectors: string[], |
| 923 | + private componentName: string) { |
| 924 | + super(); |
| 925 | + } |
| 926 | + |
| 927 | + override readonly optional = false; |
| 928 | + |
| 929 | + override execute(): null { |
| 930 | + const controlFlowToCheck = this.findPotentialControlFlowNodes(); |
| 931 | + |
| 932 | + if (controlFlowToCheck.length > 0) { |
| 933 | + const matcher = new SelectorMatcher<string>(); |
| 934 | + |
| 935 | + for (const selector of this.ngContentSelectors) { |
| 936 | + // `*` is a special selector for the catch-all slot. |
| 937 | + if (selector !== '*') { |
| 938 | + matcher.addSelectables(CssSelector.parse(selector), selector); |
| 939 | + } |
| 940 | + } |
| 941 | + |
| 942 | + for (const root of controlFlowToCheck) { |
| 943 | + for (const child of root.children) { |
| 944 | + if (child instanceof TmplAstElement || child instanceof TmplAstTemplate) { |
| 945 | + matcher.match(createCssSelectorFromNode(child), (_, originalSelector) => { |
| 946 | + this.tcb.oobRecorder.controlFlowPreventingContentProjection( |
| 947 | + this.tcb.id, child, this.componentName, originalSelector, root, |
| 948 | + this.tcb.hostPreserveWhitespaces); |
| 949 | + }); |
| 950 | + } |
| 951 | + } |
| 952 | + } |
| 953 | + } |
| 954 | + |
| 955 | + return null; |
| 956 | + } |
| 957 | + |
| 958 | + private findPotentialControlFlowNodes() { |
| 959 | + const result: Array<TmplAstIfBlockBranch|TmplAstForLoopBlock> = []; |
| 960 | + |
| 961 | + for (const child of this.element.children) { |
| 962 | + let eligibleNode: TmplAstForLoopBlock|TmplAstIfBlockBranch|null = null; |
| 963 | + |
| 964 | + // Only `@for` blocks and the first branch of `@if` blocks participate in content projection. |
| 965 | + if (child instanceof TmplAstForLoopBlock) { |
| 966 | + eligibleNode = child; |
| 967 | + } else if (child instanceof TmplAstIfBlock) { |
| 968 | + eligibleNode = child.branches[0]; // @if blocks are guaranteed to have at least one branch. |
| 969 | + } |
| 970 | + |
| 971 | + // Skip nodes with less than two children since it's impossible |
| 972 | + // for them to run into the issue that we're checking for. |
| 973 | + if (eligibleNode === null || eligibleNode.children.length < 2) { |
| 974 | + continue; |
| 975 | + } |
| 976 | + |
| 977 | + // Count the number of root nodes while skipping empty text where relevant. |
| 978 | + const rootNodeCount = eligibleNode.children.reduce((count, node) => { |
| 979 | + // Normally `preserveWhitspaces` would have been accounted for during parsing, however |
| 980 | + // in `ngtsc/annotations/component/src/resources.ts#parseExtractedTemplate` we enable |
| 981 | + // `preserveWhitespaces` to preserve the accuracy of source maps diagnostics. This means |
| 982 | + // that we have to account for it here since the presence of text nodes affects the |
| 983 | + // content projection behavior. |
| 984 | + if (!(node instanceof TmplAstText) || this.tcb.hostPreserveWhitespaces || |
| 985 | + node.value.trim().length > 0) { |
| 986 | + count++; |
| 987 | + } |
| 988 | + |
| 989 | + return count; |
| 990 | + }, 0); |
| 991 | + |
| 992 | + // Content projection can only be affected if there is more than one root node. |
| 993 | + if (rootNodeCount > 1) { |
| 994 | + result.push(eligibleNode); |
| 995 | + } |
| 996 | + } |
| 997 | + |
| 998 | + return result; |
| 999 | + } |
| 1000 | +} |
| 1001 | + |
908 | 1002 | /** |
909 | 1003 | * Mapping between attributes names that don't correspond to their element property names. |
910 | 1004 | * Note: this mapping has to be kept in sync with the equally named mapping in the runtime. |
@@ -1838,6 +1932,7 @@ class Scope { |
1838 | 1932 | if (node instanceof TmplAstElement) { |
1839 | 1933 | const opIndex = this.opQueue.push(new TcbElementOp(this.tcb, this, node)) - 1; |
1840 | 1934 | this.elementOpMap.set(node, opIndex); |
| 1935 | + this.appendContentProjectionCheckOp(node); |
1841 | 1936 | this.appendDirectivesAndInputsOfNode(node); |
1842 | 1937 | this.appendOutputsOfNode(node); |
1843 | 1938 | this.appendChildren(node); |
@@ -2034,6 +2129,22 @@ class Scope { |
2034 | 2129 | } |
2035 | 2130 | } |
2036 | 2131 |
|
| 2132 | + private appendContentProjectionCheckOp(root: TmplAstElement): void { |
| 2133 | + const meta = |
| 2134 | + this.tcb.boundTarget.getDirectivesOfNode(root)?.find(meta => meta.isComponent) || null; |
| 2135 | + |
| 2136 | + if (meta !== null && meta.ngContentSelectors !== null && meta.ngContentSelectors.length > 0) { |
| 2137 | + const selectors = meta.ngContentSelectors; |
| 2138 | + |
| 2139 | + // We don't need to generate anything for components that don't have projection |
| 2140 | + // slots, or they only have one catch-all slot (represented by `*`). |
| 2141 | + if (selectors.length > 1 || (selectors.length === 1 && selectors[0] !== '*')) { |
| 2142 | + this.opQueue.push( |
| 2143 | + new TcbControlFlowContentProjectionOp(this.tcb, root, selectors, meta.name)); |
| 2144 | + } |
| 2145 | + } |
| 2146 | + } |
| 2147 | + |
2037 | 2148 | private appendDeferredBlock(block: TmplAstDeferredBlock): void { |
2038 | 2149 | this.appendDeferredTriggers(block, block.triggers); |
2039 | 2150 | this.appendDeferredTriggers(block, block.prefetchTriggers); |
|
0 commit comments