Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 (`<alpha><beta><gamma>` 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
<head>
<alpha>
<beta>
<gamma>
</head>
""", filePath: "Index.cshtml");

// Act
var codeDocument = projectEngine.Process(projectItem);

// Assert
var documentNode = codeDocument.GetRequiredDocumentNode();
var tagHelperNodes = documentNode.FindDescendantNodes<TagHelperIntermediateNode>();
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<TagHelperIntermediateNode>()));
}

[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 (<div>) and
// yet another WithoutEndTag helper. Every tag helper must bind, real HTML (<section>,
// <div>) 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 <input>/<br>, 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
<section>
<wrapper>
<alpha>
<beta>
<bold>text</bold>
</wrapper>
<alpha>
<div>plain html</div>
<beta>
</section>
""", 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<TagHelperIntermediateNode>();
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<TagHelperIntermediateNode>(),
node => Assert.Equal("alpha", node.TagName),
node => Assert.Equal("beta", node.TagName),
node => Assert.Equal("bold", node.TagName));
Assert.Empty(tagHelperNodes[1].FindDescendantNodes<TagHelperIntermediateNode>()); // alpha inside wrapper
Assert.Empty(tagHelperNodes[2].FindDescendantNodes<TagHelperIntermediateNode>()); // beta inside wrapper
Assert.Empty(tagHelperNodes[4].FindDescendantNodes<TagHelperIntermediateNode>()); // alpha at top level
Assert.Empty(tagHelperNodes[5].FindDescendantNodes<TagHelperIntermediateNode>()); // 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("<section>", generatedCode);
Assert.Contains("<div>", 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` (<lead><child>), 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
<wrapper>
<lead>
<child>
</wrapper>
""", 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<TagHelperIntermediateNode>();
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<Action<BoundAttributeDescriptorBuilder>>? attributes = null)
IEnumerable<Action<BoundAttributeDescriptorBuilder>>? attributes = null,
TagStructure tagStructure = TagStructure.Unspecified,
string? parentTag = null)
{
var builder = TagHelperDescriptorBuilder.CreateTagHelper(typeName, assemblyName);
builder.SetTypeName(typeName, typeNamespace: null, typeNameIdentifier: null);
Expand All @@ -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();

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
@addTagHelper *, TestAssembly
<section>
<wrapper>
<alpha>
<beta>
<bold>text</bold>
</wrapper>
<alpha>
<div>plain html</div>
<beta>
</section>
Original file line number Diff line number Diff line change
@@ -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 - <section
LazyIntermediateToken - (39:1,8 [1] 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 - <div
LazyIntermediateToken - (114:8,4 [1] PromotedWithoutEndTagTagHelpers_Baseline.cshtml) - Html - >
LazyIntermediateToken - (115:8,5 [10] PromotedWithoutEndTagTagHelpers_Baseline.cshtml) - Html - plain html
LazyIntermediateToken - (125:8,15 [6] PromotedWithoutEndTagTagHelpers_Baseline.cshtml) - Html - </div>
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 - </section>
LazyIntermediateToken - (151:10,10 [2] PromotedWithoutEndTagTagHelpers_Baseline.cshtml) - Html - \n
Original file line number Diff line number Diff line change
Expand Up @@ -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. `<a><b><c>` 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);
}
}
}
}
}
}
Expand Down