Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
a961766
Add IR node types for unresolved tag helper elements
chsienki Mar 26, 2026
2ddcd5f
Add shared infrastructure to base LoweringVisitor class
chsienki Mar 26, 2026
d9ca32c
Refactor visitor subclasses to produce unresolved tag helper nodes
chsienki Mar 26, 2026
cb887d3
Add resolution phase skeleton: entry point, tree walking, and element…
chsienki Mar 26, 2026
cfc9961
Implement legacy tag helper construction in resolution phase
chsienki Mar 26, 2026
73c7041
Implement component tag helper construction in resolution phase
chsienki Mar 26, 2026
31d1a11
Implement shared value lowering pipeline in resolution phase
chsienki Mar 26, 2026
c0e2886
Wire up resolution phase and fix downstream consumers
chsienki Mar 26, 2026
c97248b
Update tests and IR baselines for new pipeline
chsienki Mar 26, 2026
4e7d84d
Add source generator regression tests
chsienki Mar 26, 2026
a4d38f2
Address code review feedback
chsienki Mar 31, 2026
85e82bb
Address additional code review feedback (batch 2)
chsienki Mar 31, 2026
515e993
Address code review feedback (batch 3)
chsienki Mar 31, 2026
b7f3e89
Address code review feedback (batch 4)
chsienki Mar 31, 2026
cfa7a66
Address code review feedback (batch 5)
chsienki Apr 1, 2026
a15bd9f
Address code review feedback (batch 6)
chsienki Apr 1, 2026
711771a
Address code review feedback (batch 7)
chsienki Apr 2, 2026
182a23d
Address code review feedback (batch 8)
chsienki Apr 2, 2026
4a7f192
Address code review feedback (batch 9)
chsienki Apr 2, 2026
2f4392a
Merge main into deferred-tag-helper-lowering
chsienki Apr 2, 2026
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
@@ -0,0 +1,44 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.AspNetCore.Razor.Language.Intermediate;

/// <summary>
/// A unresolved intermediate node representing a dynamic/expression attribute value (e.g. the

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A

super nit: An

/// <c>@someExpr</c> in <c>Value="@someExpr"</c>) whose final IR form depends on whether
/// the containing attribute is a bound tag helper property. Produced by initial lowering when
/// inside an <see cref="ElementOrTagHelperIntermediateNode"/>.
///
/// <para>The resolution phase converts this to:</para>
/// <list type="bullet">
/// <item>Direct <see cref="CSharpIntermediateToken"/> children (for bound non-string tag helper properties)</item>
/// <item>A <see cref="CSharpExpressionAttributeValueIntermediateNode"/> or
/// <see cref="CSharpCodeAttributeValueIntermediateNode"/> (for unbound/plain HTML attributes)</item>
/// </list>
/// </summary>
internal sealed class CSharpOrTagHelperExpressionAttributeValueIntermediateNode : IntermediateNode

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CSharpOrTagHelperExpressionAttributeValueIntermediateNode

General question: It feels like roslyn node classes have ctors taking in data, rather than settable properties. Is it a conscious choice that razor node classes don't?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Razor used to do all sorts of tree mutation, so properties (conceptually anyway) made sense, but we generally don't anymore. I'd love to do a pass to change it to make them properly immutable that takes all the data in ctors at some point.

{
/// <summary>The whitespace/text prefix before the expression.</summary>
public string Prefix { get; set; } = string.Empty;

/// <summary>
/// Whether the dynamic value contains a top-level expression (implicit or explicit).
/// When true, the non-tag-helper path produces <see cref="CSharpExpressionAttributeValueIntermediateNode"/>;
/// when false, it produces <see cref="CSharpCodeAttributeValueIntermediateNode"/>.
/// </summary>
public bool ContainsExpression { get; set; }

public override IntermediateNodeCollection Children { get => field ??= []; }

public override void Accept(IntermediateNodeVisitor visitor)
{
visitor.VisitDefault(this);
}

public override void FormatNode(IntermediateNodeFormatter formatter)
{
formatter.WriteChildren(Children);
formatter.WriteProperty(nameof(Prefix), Prefix);
formatter.WriteProperty(nameof(ContainsExpression), ContainsExpression.ToString());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Collections.Generic;
using System.Collections.Immutable;

namespace Microsoft.AspNetCore.Razor.Language.Intermediate;

/// <summary>
/// An intermediate node that represents an HTML element which may or may not be a tag helper.
/// All syntax-tree-derived information is stored directly on this node during lowering,
/// so the resolution phase does not need access to the syntax tree.
/// </summary>
internal sealed class ElementOrTagHelperIntermediateNode : IntermediateNode

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ElementOrTagHelperIntermediateNode

not sure if it's better, but maybe something like UnresolvedElementIntermediateNode might be more obvious and not tied to the unresolved type potentially being a tag helper?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like this idea.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also like this idea, but I'll do it in a follow up so as not to churn this PR any further.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up issue: #12995

{
public string TagName { get; set; } = string.Empty;
public bool IsComponent { get; set; }

/// <summary>Whether the element is escaped with ! (e.g., &lt;!input&gt;).</summary>
public bool IsEscaped { get; set; }

/// <summary>Whether the start tag is self-closing (ends with /&gt;).</summary>
public bool IsSelfClosing { get; set; }

/// <summary>Whether the element has an end tag.</summary>
public bool HasEndTag { get; set; }

/// <summary>The end tag name (may differ in case from TagName for case-mismatch diagnostics).</summary>
public string? EndTagName { get; set; }

/// <summary>Source span of the end tag (for case-mismatch diagnostics).</summary>
public SourceSpan? EndTagSpan { get; set; }

/// <summary>Whether the element is a void element (e.g., input, br).</summary>
public bool IsVoidElement { get; set; }

/// <summary>Source span of the start tag's name (for component StartTagSpan).</summary>
public SourceSpan? StartTagNameSpan { get; set; }

/// <summary>Source span of the entire start tag (e.g., &lt;Component attr="val"&gt;), used for diagnostics.</summary>
public SourceSpan? StartTagSpan { get; set; }

/// <summary>Attribute name/value pairs for tag helper binding.</summary>
public ImmutableArray<KeyValuePair<string, string>> AttributeData { get; set; }

/// <summary>Whether the start tag is missing its close angle (&gt;).</summary>
public bool HasMissingCloseAngle { get; set; }

/// <summary>Whether the element has C# expression or code children in the start-tag region (e.g., &lt;div @expr&gt;).</summary>
public bool HasDynamicExpressionChild { get; set; }

/// <summary>Whether the end tag is missing its close angle (&gt;).</summary>
public bool HasMissingEndCloseAngle { get; set; }

/// <summary>
/// The index in <see cref="Children"/> where body children begin (one past the last
/// start-tag child). Children in <c>[0, StartTagEndIndex)</c> are start-tag children.
/// Set during lowering; -1 if not computed.
/// </summary>
public int StartTagEndIndex { get; set; } = -1;

/// <summary>
/// The index in <see cref="Children"/> where end-tag children begin (one past the last
/// body child). Children in <c>[StartTagEndIndex, BodyEndIndex)</c> are body children.
/// Set during lowering; -1 if not computed.
/// </summary>
public int BodyEndIndex { get; set; } = -1;

public override IntermediateNodeCollection Children { get => field ??= []; }

public override void Accept(IntermediateNodeVisitor visitor)
{
visitor.VisitDefault(this);
}

public override void FormatNode(IntermediateNodeFormatter formatter)
{
formatter.WriteContent(TagName);
formatter.WriteProperty(nameof(TagName), TagName);
formatter.WriteProperty(nameof(IsComponent), IsComponent.ToString());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.AspNetCore.Razor.Language.Intermediate;

/// <summary>
/// An intermediate node representing an attribute whose final form (plain HTML attribute
/// vs tag helper bound attribute) is not yet determined. Pre-lowered fallback forms are
/// stored so the resolution phase does not need syntax tree access.
/// </summary>
internal sealed class MarkupOrTagHelperAttributeIntermediateNode : IntermediateNode
{
/// <summary>The attribute name (e.g., "Value", "@bind-Value", "@onclick").</summary>
public string AttributeName { get; set; } = string.Empty;

/// <summary>Whether this is a minimized attribute (no value).</summary>
public bool IsMinimized { get; set; }

/// <summary>The raw value content of the attribute (for all-literal values only).</summary>
public string? ValueContent { get; set; }

/// <summary>
/// Source span covering the attribute value content (between quotes).
/// Computed from syntax tree positions during lowering so the resolution phase
/// can use it without needing syntax tree access.
/// </summary>
public SourceSpan? ValueSourceSpan { get; set; }

/// <summary>
/// The attribute structure (quote style). Pre-computed during initial lowering
/// so the resolution phase does not need to inspect syntax.
/// </summary>
public AttributeStructure AttributeStructure { get; set; }

/// <summary>
/// Source span of the attribute name only (not the whole attribute).
/// Pre-computed during initial lowering for tag helper property OriginalAttributeSpan.
/// </summary>
public SourceSpan? AttributeNameSpan { get; set; }

/// <summary>
/// Pre-lowered form of this attribute for when the element IS a tag helper but this
/// attribute is unbound (doesn't match any tag helper property). The tag helper runtime
/// represents unbound HTML attributes as <see cref="TagHelperHtmlAttributeIntermediateNode"/>
/// wrapping a structured <see cref="HtmlAttributeIntermediateNode"/> with merged value tokens.
/// Produced during lowering via <c>VisitAttributeValue</c>, which merges adjacent literal
/// tokens into a single <see cref="HtmlContentIntermediateNode"/>.
/// </summary>
public IntermediateNode? AsTagHelperAttribute { get; set; }

/// <summary>
/// Pre-lowered form of this attribute for when the element is NOT a tag helper and needs
/// to be unwrapped back to plain HTML markup. Plain HTML elements represent attributes with
/// individual value tokens preserving the original parse structure. Produced during lowering
/// via <c>LowerAttributeAsHtml</c>, which preserves individual tokens without merging.
/// Used by <c>ConvertToMarkupElement</c> and <c>UnwrapElement</c> to restore the element
/// to its non-tag-helper form.
/// </summary>
public IntermediateNode? AsMarkupAttribute { get; set; }

/// <summary>
/// The <see cref="HtmlAttributeIntermediateNode"/> child containing unresolved attribute
/// value children. Set during lowering to avoid linear scans in the resolution phase.
/// </summary>
public HtmlAttributeIntermediateNode? HtmlAttributeNode { get; set; }

public override IntermediateNodeCollection Children { get => field ??= []; }

public override void Accept(IntermediateNodeVisitor visitor)
{
visitor.VisitDefault(this);
}

public override void FormatNode(IntermediateNodeFormatter formatter)
{
formatter.WriteContent(AttributeName);
formatter.WriteProperty(nameof(AttributeName), AttributeName);
formatter.WriteProperty(nameof(IsMinimized), IsMinimized.ToString());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

namespace Microsoft.AspNetCore.Razor.Language.Intermediate;

/// <summary>
/// A unresolved intermediate node representing a literal attribute value (e.g. the <c>123</c> in
/// <c>IntProperty="123"</c>) whose final IR form depends on whether the containing attribute
/// is a bound tag helper property. Produced by initial lowering when inside an
/// <see cref="ElementOrTagHelperIntermediateNode"/>.
///
/// <para>The resolution phase converts this to:</para>
/// <list type="bullet">
/// <item>A <see cref="CSharpIntermediateToken"/> (for bound non-string tag helper properties)</item>
/// <item>An <see cref="HtmlContentIntermediateNode"/> (for bound string tag helper properties)</item>
/// <item>An <see cref="HtmlAttributeValueIntermediateNode"/> (for unbound/plain HTML attributes)</item>
/// </list>
/// </summary>
internal sealed class MarkupOrTagHelperAttributeValueIntermediateNode : IntermediateNode
{
/// <summary>The whitespace/text prefix before the value (from parser splitting on spaces).</summary>
public string Prefix { get; set; } = string.Empty;

public override IntermediateNodeCollection Children { get => field ??= []; }

public override void Accept(IntermediateNodeVisitor visitor)
{
visitor.VisitDefault(this);
}

public override void FormatNode(IntermediateNodeFormatter formatter)
{
formatter.WriteChildren(Children);
formatter.WriteProperty(nameof(Prefix), Prefix);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using Microsoft.AspNetCore.Razor.Language.Intermediate;

namespace Microsoft.AspNetCore.Razor.Language;

Expand Down Expand Up @@ -112,4 +113,28 @@ protected RazorProjectEngine CreateProjectEngine(Action<RazorProjectEngineBuilde
ConfigureProjectEngine(builder);
configure.Invoke(builder);
});

/// <summary>
/// Finds the first descendant node of the specified type using a depth-first search.
/// </summary>
protected static T FindDescendant<T>(IntermediateNode root) where T : IntermediateNode
{
var stack = new System.Collections.Generic.Stack<IntermediateNode>();
stack.Push(root);
while (stack.Count > 0)
{
var current = stack.Pop();
if (current is T match)
{
return match;
}

for (var i = current.Children.Count - 1; i >= 0; i--)
{
stack.Push(current.Children[i]);
}
}

throw new InvalidOperationException("Not found: " + typeof(T).Name);
}
}