diff --git a/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/IntegrationTests/TagHelpersIntegrationTest.cs b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/IntegrationTests/TagHelpersIntegrationTest.cs index 0959e0433c38..059332af935a 100644 --- a/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/IntegrationTests/TagHelpersIntegrationTest.cs +++ b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/IntegrationTests/TagHelpersIntegrationTest.cs @@ -5,7 +5,9 @@ using System.Collections.Immutable; using System.Collections.Generic; using System.Linq; +using Microsoft.AspNetCore.Razor.Language.Intermediate; using Microsoft.AspNetCore.Razor.Language.Syntax; +using Roslyn.Test.Utilities; using Xunit; namespace Microsoft.AspNetCore.Razor.Language.IntegrationTests; @@ -181,11 +183,237 @@ public void AddTagHelperDirective_StoresDirectiveTagHelperContributions() Assert.NotEmpty(contribution.ContributedTagHelpers); } + [Fact] + [WorkItem("https://github.com/dotnet/aspnetcore/issues/68193")] + public void ConsecutiveWithoutEndTagTagHelpers_AllBind() + { + // The HTML parser nests consecutive unclosed tags (`` becomes + // alpha > beta > gamma). Each of these is a WithoutEndTag tag helper, so all three + // must still bind as siblings rather than only the first. + TagHelperCollection tagHelpers = + [ + CreateTagHelperDescriptor( + tagName: "alpha", + typeName: "AlphaTagHelper", + assemblyName: "TestAssembly", + tagStructure: TagStructure.WithoutEndTag), + CreateTagHelperDescriptor( + tagName: "beta", + typeName: "BetaTagHelper", + assemblyName: "TestAssembly", + tagStructure: TagStructure.WithoutEndTag), + CreateTagHelperDescriptor( + tagName: "gamma", + typeName: "GammaTagHelper", + assemblyName: "TestAssembly", + tagStructure: TagStructure.WithoutEndTag), + ]; + + var projectEngine = CreateProjectEngine(builder => builder.SetTagHelpers(tagHelpers)); + var projectItem = AddProjectItemFromText(""" + @addTagHelper *, TestAssembly + + + + + + """, filePath: "Index.cshtml"); + + // Act + var codeDocument = projectEngine.Process(projectItem); + + // Assert + var documentNode = codeDocument.GetRequiredDocumentNode(); + var tagHelperNodes = documentNode.FindDescendantNodes(); + Assert.Collection(tagHelperNodes, + node => Assert.Equal("alpha", node.TagName), + node => Assert.Equal("beta", node.TagName), + node => Assert.Equal("gamma", node.TagName)); + + // They are siblings, not nested: each WithoutEndTag helper promoted its body out, so + // none of them contains another tag helper. + Assert.All(tagHelperNodes, node => Assert.Empty(node.FindDescendantNodes())); + } + + [Fact] + [WorkItem("https://github.com/dotnet/aspnetcore/issues/68193")] + public void MixedNestedStartTagOnlyAndHtmlTagHelpers_AllResolveCorrectly() + { + // A deliberately tangled mix: a nestable tag helper (wrapper) containing consecutive + // WithoutEndTag helpers (alpha, beta) and a normal one (bold), followed by a second + // WithoutEndTag helper whose parser-nested body wraps a real HTML element (
) and + // yet another WithoutEndTag helper. Every tag helper must bind, real HTML (
, + //
) must stay markup, and document order must be preserved. + // Note: custom (non-void) tag names are required -- the HTML parser self-terminates + // real void elements like /
, which wouldn't exercise the nesting-promotion path. + TagHelperCollection tagHelpers = + [ + CreateTagHelperDescriptor( + tagName: "wrapper", + typeName: "WrapperTagHelper", + assemblyName: "TestAssembly"), + CreateTagHelperDescriptor( + tagName: "bold", + typeName: "BoldTagHelper", + assemblyName: "TestAssembly"), + CreateTagHelperDescriptor( + tagName: "alpha", + typeName: "AlphaTagHelper", + assemblyName: "TestAssembly", + tagStructure: TagStructure.WithoutEndTag), + CreateTagHelperDescriptor( + tagName: "beta", + typeName: "BetaTagHelper", + assemblyName: "TestAssembly", + tagStructure: TagStructure.WithoutEndTag), + ]; + + var projectEngine = CreateProjectEngine(builder => builder.SetTagHelpers(tagHelpers)); + var projectItem = AddProjectItemFromText(""" + @addTagHelper *, TestAssembly +
+ + + + text + + +
plain html
+ +
+ """, filePath: "Index.cshtml"); + + // Act + var codeDocument = projectEngine.Process(projectItem); + + // Assert: every tag helper binds, in document order, with the WithoutEndTag helpers + // promoted to siblings rather than swallowing what follows them. + var documentNode = codeDocument.GetRequiredDocumentNode(); + var tagHelperNodes = documentNode.FindDescendantNodes(); + Assert.Collection(tagHelperNodes, + node => Assert.Equal("wrapper", node.TagName), + node => Assert.Equal("alpha", node.TagName), + node => Assert.Equal("beta", node.TagName), + node => Assert.Equal("bold", node.TagName), + node => Assert.Equal("alpha", node.TagName), + node => Assert.Equal("beta", node.TagName)); + + // Structure: wrapper genuinely contains alpha/beta/bold, while the WithoutEndTag helpers + // stay flat -- their bodies were promoted out, so none nests another tag helper. + Assert.Collection(tagHelperNodes[0].FindDescendantNodes(), + node => Assert.Equal("alpha", node.TagName), + node => Assert.Equal("beta", node.TagName), + node => Assert.Equal("bold", node.TagName)); + Assert.Empty(tagHelperNodes[1].FindDescendantNodes()); // alpha inside wrapper + Assert.Empty(tagHelperNodes[2].FindDescendantNodes()); // beta inside wrapper + Assert.Empty(tagHelperNodes[4].FindDescendantNodes()); // alpha at top level + Assert.Empty(tagHelperNodes[5].FindDescendantNodes()); // beta at top level + + // The real HTML elements must survive as literal markup, never bound as tag helpers. + var generatedCode = codeDocument.GetRequiredCSharpDocument().Text.ToString(); + Assert.Contains("
", generatedCode); + Assert.Contains("
", generatedCode); + Assert.Contains("plain html", generatedCode); + } + + [Fact] + [WorkItem("https://github.com/dotnet/aspnetcore/issues/68193")] + public void PromotedStartTagOnlySibling_BindsUsingParentTagHelperContext() + { + // `child` is a WithoutEndTag helper that only matches when its parent is `wrapper`. The + // parser nests it under the preceding WithoutEndTag helper `lead` (), so it + // is reached only after `lead` promotes it to a sibling inside wrapper's body. That + // re-resolution must carry wrapper as the parent-tag context, otherwise the + // RequireParentTag("wrapper") rule can't match and `child` silently fails to bind. + TagHelperCollection tagHelpers = + [ + CreateTagHelperDescriptor( + tagName: "wrapper", + typeName: "WrapperTagHelper", + assemblyName: "TestAssembly"), + CreateTagHelperDescriptor( + tagName: "lead", + typeName: "LeadTagHelper", + assemblyName: "TestAssembly", + tagStructure: TagStructure.WithoutEndTag), + CreateTagHelperDescriptor( + tagName: "child", + typeName: "ChildTagHelper", + assemblyName: "TestAssembly", + tagStructure: TagStructure.WithoutEndTag, + parentTag: "wrapper"), + ]; + + var projectEngine = CreateProjectEngine(builder => builder.SetTagHelpers(tagHelpers)); + var projectItem = AddProjectItemFromText(""" + @addTagHelper *, TestAssembly + + + + + """, filePath: "Index.cshtml"); + + // Act + var codeDocument = projectEngine.Process(projectItem); + + // Assert: child binds because the promoted sibling was resolved with wrapper as its + // parent-tag context. + var documentNode = codeDocument.GetRequiredDocumentNode(); + var tagHelperNodes = documentNode.FindDescendantNodes(); + Assert.Collection(tagHelperNodes, + node => Assert.Equal("wrapper", node.TagName), + node => Assert.Equal("lead", node.TagName), + node => Assert.Equal("child", node.TagName)); + } + + [Fact] + [WorkItem("https://github.com/dotnet/aspnetcore/issues/68193")] + public void PromotedWithoutEndTagTagHelpers_Baseline() + { + // Full-IR structural baseline for the promotion scenario: a nestable helper containing + // consecutive WithoutEndTag helpers plus a normal one, followed by a second WithoutEndTag + // helper whose parser-nested body wraps real HTML and another helper. The .ir.txt baseline + // captures the exact tree shape, proving the promoted helpers become siblings (not nested) + // and that the real HTML survives as markup. + TagHelperCollection tagHelpers = + [ + CreateTagHelperDescriptor( + tagName: "wrapper", + typeName: "WrapperTagHelper", + assemblyName: "TestAssembly"), + CreateTagHelperDescriptor( + tagName: "bold", + typeName: "BoldTagHelper", + assemblyName: "TestAssembly"), + CreateTagHelperDescriptor( + tagName: "alpha", + typeName: "AlphaTagHelper", + assemblyName: "TestAssembly", + tagStructure: TagStructure.WithoutEndTag), + CreateTagHelperDescriptor( + tagName: "beta", + typeName: "BetaTagHelper", + assemblyName: "TestAssembly", + tagStructure: TagStructure.WithoutEndTag), + ]; + + var projectEngine = CreateProjectEngine(builder => builder.SetTagHelpers(tagHelpers)); + var projectItem = CreateProjectItemFromFile(); + + // Act + var codeDocument = projectEngine.Process(projectItem); + + // Assert + AssertDocumentNodeMatchesBaseline(codeDocument.GetRequiredDocumentNode()); + } + private static TagHelperDescriptor CreateTagHelperDescriptor( string tagName, string typeName, string assemblyName, - IEnumerable>? attributes = null) + IEnumerable>? attributes = null, + TagStructure tagStructure = TagStructure.Unspecified, + string? parentTag = null) { var builder = TagHelperDescriptorBuilder.CreateTagHelper(typeName, assemblyName); builder.SetTypeName(typeName, typeNamespace: null, typeNameIdentifier: null); @@ -198,7 +426,15 @@ private static TagHelperDescriptor CreateTagHelperDescriptor( } } - builder.TagMatchingRuleDescriptor(ruleBuilder => ruleBuilder.RequireTagName(tagName)); + builder.TagMatchingRuleDescriptor(ruleBuilder => + { + ruleBuilder.RequireTagName(tagName).RequireTagStructure(tagStructure); + + if (parentTag != null) + { + ruleBuilder.RequireParentTag(parentTag); + } + }); var descriptor = builder.Build(); diff --git a/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/TagHelpersIntegrationTest/PromotedWithoutEndTagTagHelpers_Baseline.cshtml b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/TagHelpersIntegrationTest/PromotedWithoutEndTagTagHelpers_Baseline.cshtml new file mode 100644 index 000000000000..01c3dbc9a9c4 --- /dev/null +++ b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/TagHelpersIntegrationTest/PromotedWithoutEndTagTagHelpers_Baseline.cshtml @@ -0,0 +1,11 @@ +@addTagHelper *, TestAssembly +
+ + + +text + + +
plain html
+ +
diff --git a/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/TagHelpersIntegrationTest/PromotedWithoutEndTagTagHelpers_Baseline.ir.txt b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/TagHelpersIntegrationTest/PromotedWithoutEndTagTagHelpers_Baseline.ir.txt new file mode 100644 index 000000000000..78b6b4e0de66 --- /dev/null +++ b/src/Razor/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/TestFiles/IntegrationTests/TagHelpersIntegrationTest/PromotedWithoutEndTagTagHelpers_Baseline.ir.txt @@ -0,0 +1,63 @@ +Document - + RazorCompiledItemAttribute - + NamespaceDeclaration - - Razor + RazorSourceChecksumAttribute - + ClassDeclaration - - public - Template - - + DefaultTagHelperRuntime - + FieldDeclaration - - private - global::WrapperTagHelper - __WrapperTagHelper + FieldDeclaration - - private - global::AlphaTagHelper - __AlphaTagHelper + FieldDeclaration - - private - global::BetaTagHelper - __BetaTagHelper + FieldDeclaration - - private - global::BoldTagHelper - __BoldTagHelper + MethodDeclaration - - public async override - global::System.Threading.Tasks.Task - ExecuteAsync + HtmlContent - (31:1,0 [11] PromotedWithoutEndTagTagHelpers_Baseline.cshtml) + LazyIntermediateToken - (31:1,0 [8] PromotedWithoutEndTagTagHelpers_Baseline.cshtml) - Html -
+ LazyIntermediateToken - (40:1,9 [2] PromotedWithoutEndTagTagHelpers_Baseline.cshtml) - Html - \n + TagHelper - (42:2,0 [57] PromotedWithoutEndTagTagHelpers_Baseline.cshtml) - wrapper - TagMode.StartTagAndEndTag + DefaultTagHelperBody - + HtmlContent - (51:2,9 [2] PromotedWithoutEndTagTagHelpers_Baseline.cshtml) + LazyIntermediateToken - (51:2,9 [2] PromotedWithoutEndTagTagHelpers_Baseline.cshtml) - Html - \n + TagHelper - (53:3,0 [36] PromotedWithoutEndTagTagHelpers_Baseline.cshtml) - alpha - TagMode.StartTagOnly + DefaultTagHelperBody - + DefaultTagHelperCreate - - AlphaTagHelper + DefaultTagHelperExecute - + HtmlContent - (60:3,7 [2] PromotedWithoutEndTagTagHelpers_Baseline.cshtml) + LazyIntermediateToken - (60:3,7 [2] PromotedWithoutEndTagTagHelpers_Baseline.cshtml) - Html - \n + TagHelper - (62:4,0 [27] PromotedWithoutEndTagTagHelpers_Baseline.cshtml) - beta - TagMode.StartTagOnly + DefaultTagHelperBody - + DefaultTagHelperCreate - - BetaTagHelper + DefaultTagHelperExecute - + HtmlContent - (68:4,6 [2] PromotedWithoutEndTagTagHelpers_Baseline.cshtml) + LazyIntermediateToken - (68:4,6 [2] PromotedWithoutEndTagTagHelpers_Baseline.cshtml) - Html - \n + TagHelper - (70:5,0 [17] PromotedWithoutEndTagTagHelpers_Baseline.cshtml) - bold - TagMode.StartTagAndEndTag + DefaultTagHelperBody - + HtmlContent - (76:5,6 [4] PromotedWithoutEndTagTagHelpers_Baseline.cshtml) + LazyIntermediateToken - (76:5,6 [4] PromotedWithoutEndTagTagHelpers_Baseline.cshtml) - Html - text + DefaultTagHelperCreate - - BoldTagHelper + DefaultTagHelperExecute - + HtmlContent - (87:5,17 [2] PromotedWithoutEndTagTagHelpers_Baseline.cshtml) + LazyIntermediateToken - (87:5,17 [2] PromotedWithoutEndTagTagHelpers_Baseline.cshtml) - Html - \n + DefaultTagHelperCreate - - WrapperTagHelper + DefaultTagHelperExecute - + HtmlContent - (99:6,10 [2] PromotedWithoutEndTagTagHelpers_Baseline.cshtml) + LazyIntermediateToken - (99:6,10 [2] PromotedWithoutEndTagTagHelpers_Baseline.cshtml) - Html - \n + TagHelper - (101:7,0 [40] PromotedWithoutEndTagTagHelpers_Baseline.cshtml) - alpha - TagMode.StartTagOnly + DefaultTagHelperBody - + DefaultTagHelperCreate - - AlphaTagHelper + DefaultTagHelperExecute - + HtmlContent - (108:7,7 [25] PromotedWithoutEndTagTagHelpers_Baseline.cshtml) + LazyIntermediateToken - (108:7,7 [2] PromotedWithoutEndTagTagHelpers_Baseline.cshtml) - Html - \n + LazyIntermediateToken - (110:8,0 [4] PromotedWithoutEndTagTagHelpers_Baseline.cshtml) - Html -
+ LazyIntermediateToken - (115:8,5 [10] PromotedWithoutEndTagTagHelpers_Baseline.cshtml) - Html - plain html + LazyIntermediateToken - (125:8,15 [6] PromotedWithoutEndTagTagHelpers_Baseline.cshtml) - Html -
+ LazyIntermediateToken - (131:8,21 [2] PromotedWithoutEndTagTagHelpers_Baseline.cshtml) - Html - \n + TagHelper - (133:9,0 [8] PromotedWithoutEndTagTagHelpers_Baseline.cshtml) - beta - TagMode.StartTagOnly + DefaultTagHelperBody - + DefaultTagHelperCreate - - BetaTagHelper + DefaultTagHelperExecute - + HtmlContent - (139:9,6 [2] PromotedWithoutEndTagTagHelpers_Baseline.cshtml) + LazyIntermediateToken - (139:9,6 [2] PromotedWithoutEndTagTagHelpers_Baseline.cshtml) - Html - \n + HtmlContent - (141:10,0 [12] PromotedWithoutEndTagTagHelpers_Baseline.cshtml) + LazyIntermediateToken - (141:10,0 [10] PromotedWithoutEndTagTagHelpers_Baseline.cshtml) - Html -
+ LazyIntermediateToken - (151:10,10 [2] PromotedWithoutEndTagTagHelpers_Baseline.cshtml) - Html - \n diff --git a/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/DefaultTagHelperResolutionPhase.cs b/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/DefaultTagHelperResolutionPhase.cs index b1d53bc93549..80a857504776 100644 --- a/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/DefaultTagHelperResolutionPhase.cs +++ b/src/Razor/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/DefaultTagHelperResolutionPhase.cs @@ -197,6 +197,31 @@ private void ResolveElement( { parent.Children.Insert(insertIdx++, elementNode.Children[i]); } + + // The promoted nodes were parsed as body children of this element because the + // HTML parser nests unclosed tags (e.g. `` becomes a > b > c). Now that + // this element is bound as StartTagOnly, they are siblings that may themselves be + // tag helpers. The outer walker iterates in reverse and won't revisit these + // newly inserted positions, so resolve them here (in reverse, since resolving a + // promoted StartTagOnly sibling can insert further siblings after it). + for (var j = insertIdx - 1; j > index; j--) + { + if (j < parent.Children.Count) + { + if (parent.Children[j] is UnresolvedElementIntermediateNode promotedElement) + { + // Forward tagHelperParent: the promoted siblings live in the same + // container as this StartTagOnly element, so they share its parent-tag + // context. Dropping it would break bindings that depend on the parent + // (e.g. RequireParentTag or component child-content matching). + ResolveElement(parent, j, promotedElement, binder, prefix, usedHelpers, in context, tagHelperParent); + } + else + { + ResolveElements(parent.Children[j], binder, prefix, usedHelpers, in context); + } + } + } } } }