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
3 changes: 3 additions & 0 deletions .github/instructions/Razor.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ their original sub-tree layout
fields through the constructor. When adding a new field, thread it through every existing
`With*` method. Prefer computing derived data via extension methods (e.g.,
`GetUnusedDirectives()`) rather than storing computed results as fields.
- **Razor engine concurrency**: Hosts can process multiple documents concurrently through one
`RazorProjectEngine`. Phase and pass instances are shared, so keep per-document state in locals
or an execution context rather than mutable instance fields.
- **Razor documents in Roslyn**: Stored as additional documents. Resolve via
`solution.GetDocumentIdsWithFilePath(filePath)` then `solution.GetAdditionalDocument(documentId)`.
- **Razor documents with virtual URIs**: Remote Razor document classification preserves the full
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,59 @@
// Licensed to the .NET Foundation under one or more agreements.
// 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.Immutable;
using System.Threading.Tasks;
using Xunit;

namespace Microsoft.AspNetCore.Razor.Language;

public class DefaultTagHelperResolutionPhaseTest
{
[Fact]
public void Process_ConcurrentMixedFileKinds_UsesCorrectResolver()
{
// Arrange
var configuration = new RazorConfiguration(
RazorLanguageVersion.Version_5_0,
"MVC-3.0",
Extensions: []);

var projectEngine = RazorProjectEngine.Create(
configuration,
RazorProjectFileSystem.Empty,
builder => builder.RegisterExtensions());

var inputs = new[]
{
(Source: RazorSourceDocument.Create("<h1>View</h1>", "/Views/Index.cshtml"), FileKind: RazorFileKind.Legacy),
(Source: RazorSourceDocument.Create("<p>Component</p>", "/Components/App.razor"), FileKind: RazorFileKind.Component),
};

// Act and assert: match classic rzc's concurrency while repeating enough to exercise the timing-sensitive overlap.
var parallelOptions = new ParallelOptions { MaxDegreeOfParallelism = 4 };
Parallel.For(0, 2_000, parallelOptions, iteration =>
{
var (source, fileKind) = inputs[iteration % inputs.Length];
var codeDocument = projectEngine.Process(
source,
fileKind,
ImmutableArray<RazorSourceDocument>.Empty,
tagHelpers: null);

var generatedCode = codeDocument.GetCSharpDocument().GeneratedCode.ToString();
Assert.Contains(
fileKind == RazorFileKind.Component ? "BuildRenderTree" : "ExecuteAsync",
generatedCode);
});
}

[Fact]
public void MergeSourceSpans_SameLine_ReturnsCorrectSpan()
{
// Arrange
var filePath = "test.razor";
var first = new SourceSpan(filePath, absoluteIndex: 10, lineIndex: 2, characterIndex: 5, length: 3, lineCount: 0, endCharacterIndex: 8);
var last = new SourceSpan(filePath, absoluteIndex: 15, lineIndex: 2, characterIndex: 10, length: 4, lineCount: 0, endCharacterIndex: 14);
var last = new SourceSpan(filePath, absoluteIndex: 15, lineIndex: 2, characterIndex: 10, length: 4, lineCount: 0, endCharacterIndex: 14);

// Act
var result = DefaultTagHelperResolutionPhase.MergeSourceSpans(first, last);
Expand All @@ -36,7 +76,7 @@ public void MergeSourceSpans_MultiLine_ReturnsCorrectSpan()
// first spans lines 1-2 (lineCount = 1 means it crosses into the next line)
var first = new SourceSpan(filePath, absoluteIndex: 0, lineIndex: 1, characterIndex: 0, length: 10, lineCount: 1, endCharacterIndex: 5);
// last is on line 3 (lineIndex = 3)
var last = new SourceSpan(filePath, absoluteIndex: 20, lineIndex: 3, characterIndex: 2, length: 5, lineCount: 0, endCharacterIndex: 7);
var last = new SourceSpan(filePath, absoluteIndex: 20, lineIndex: 3, characterIndex: 2, length: 5, lineCount: 0, endCharacterIndex: 7);

// Act
var result = DefaultTagHelperResolutionPhase.MergeSourceSpans(first, last);
Expand All @@ -58,7 +98,7 @@ public void MergeSourceSpans_AdjacentSpans_ReturnsCorrectSpan()
var filePath = "test.razor";
var first = new SourceSpan(filePath, absoluteIndex: 5, lineIndex: 0, characterIndex: 5, length: 3, lineCount: 0, endCharacterIndex: 8);
// last starts right where first ends
var last = new SourceSpan(filePath, absoluteIndex: 8, lineIndex: 0, characterIndex: 8, length: 4, lineCount: 0, endCharacterIndex: 12);
var last = new SourceSpan(filePath, absoluteIndex: 8, lineIndex: 0, characterIndex: 8, length: 4, lineCount: 0, endCharacterIndex: 12);

// Act
var result = DefaultTagHelperResolutionPhase.MergeSourceSpans(first, last);
Expand Down Expand Up @@ -94,7 +134,7 @@ public void MergeSourceSpans_NullFilePath_PreservesNullFilePath()
{
// Arrange — file path is null (e.g. for in-memory content)
var first = new SourceSpan(filePath: null, absoluteIndex: 0, lineIndex: 0, characterIndex: 0, length: 3, lineCount: 0, endCharacterIndex: 3);
var last = new SourceSpan(filePath: null, absoluteIndex: 5, lineIndex: 0, characterIndex: 5, length: 2, lineCount: 0, endCharacterIndex: 7);
var last = new SourceSpan(filePath: null, absoluteIndex: 5, lineIndex: 0, characterIndex: 5, length: 2, lineCount: 0, endCharacterIndex: 7);

// Act
var result = DefaultTagHelperResolutionPhase.MergeSourceSpans(first, last);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,6 @@ namespace Microsoft.AspNetCore.Razor.Language;
/// </summary>
internal partial class DefaultTagHelperResolutionPhase : RazorEnginePhaseBase
{
private TagHelperResolver _resolver;

/// <summary>
/// Entry point: resolves all unresolved <see cref="UnresolvedElementIntermediateNode"/> nodes
/// in the IR tree. For each, matches against tag helper bindings and either converts to a
Expand All @@ -50,15 +48,15 @@ protected override RazorCodeDocument ExecuteCore(RazorCodeDocument codeDocument,
// Choose resolver based on file kind and language version. Component features
// (MarkupElementIntermediateNode, RZ10012 diagnostics) require Version_3_0+ because
// the ComponentDocumentClassifierPass is only registered at that version.
_resolver = (codeDocument.FileKind.IsComponent() || codeDocument.FileKind.IsComponentImport())
TagHelperResolver resolver = (codeDocument.FileKind.IsComponent() || codeDocument.FileKind.IsComponentImport())
&& parserOptions.LanguageVersion >= RazorLanguageVersion.Version_3_0
? new ComponentTagHelperResolver()
: new LegacyTagHelperResolver();

if (tagHelperContext == null || tagHelperContext.TagHelpers is [])
{
// No tag helpers discovered - unwrap all UnresolvedElement nodes to their fallback.
UnwrapAllElements(documentNode, documentNode);
UnwrapAllElements(documentNode, resolver, documentNode);

// Still need to set referenced tag helpers for downstream phases.
return codeDocument.WithReferencedTagHelpers([]);
Expand All @@ -69,7 +67,7 @@ protected override RazorCodeDocument ExecuteCore(RazorCodeDocument codeDocument,

using var usedHelpers = new TagHelperCollection.Builder();
var sourceDocument = codeDocument.Source;
var context = new ResolutionContext(sourceDocument, documentNode);
var context = new ResolutionContext(sourceDocument, documentNode, resolver);
ResolveElements(documentNode, binder, prefix, usedHelpers, in context);

// Add tag helper descriptor validation diagnostics (e.g. RZ3003).
Expand All @@ -95,11 +93,16 @@ private readonly struct ResolutionContext
{
public readonly RazorSourceDocument SourceDocument;
public readonly DocumentIntermediateNode DocumentNode;
public readonly TagHelperResolver Resolver;

public ResolutionContext(RazorSourceDocument sourceDocument, DocumentIntermediateNode documentNode)
public ResolutionContext(
RazorSourceDocument sourceDocument,
DocumentIntermediateNode documentNode,
TagHelperResolver resolver)
{
SourceDocument = sourceDocument;
DocumentNode = documentNode;
Resolver = resolver;
}
}

Expand Down Expand Up @@ -160,7 +163,7 @@ private TagHelperIntermediateNode ResolveElement(
{
TryAddMalformedEndTagDiagnostic(elementNode, tagName, binder, attributes, parent, tagHelperParent, prefix);

_resolver.ConvertToPlainElement(parent, index, elementNode);
context.Resolver.ConvertToPlainElement(parent, index, elementNode);
return null;
}

Expand Down Expand Up @@ -264,7 +267,7 @@ private TagHelperIntermediateNode ResolveElement(

// Add resolver-specific diagnostics (e.g. RZ10012 for component-like elements,
// case mismatch between start/end tags).
_resolver.AddMatchedElementDiagnostics(tagHelperNode, elementNode, binding, in context);
context.Resolver.AddMatchedElementDiagnostics(tagHelperNode, elementNode, binding, in context);

// Check if resolved tag name is a void element (handles prefixed elements like th:input).
var isResolvedVoidElement = elementNode.IsVoidElement || Legacy.ParserHelpers.VoidElements.Contains(resolvedTagName);
Expand All @@ -281,7 +284,7 @@ private TagHelperIntermediateNode ResolveElement(

// Build body and attributes.
var bodyNode = new TagHelperBodyIntermediateNode();
_resolver.BuildTagHelper(tagHelperNode, bodyNode, elementNode, binding, context.SourceDocument, in context);
context.Resolver.BuildTagHelper(tagHelperNode, bodyNode, elementNode, binding, context.SourceDocument, in context);

return (tagHelperNode, bodyNode);
}
Expand Down Expand Up @@ -445,12 +448,12 @@ private void ConvertToPlainElementAndResolve(
bool emitDiagnostics = true)
{
var childCountBefore = parent.Children.Count;
_resolver.ConvertToPlainElement(parent, index, elementNode);
context.Resolver.ConvertToPlainElement(parent, index, elementNode);
var resultCount = parent.Children.Count - childCountBefore + 1; // +1 because the original was removed

if (emitDiagnostics && resultCount > 0)
{
_resolver.AddUnmatchedElementDiagnostic(parent.Children[index], elementNode, context.DocumentNode);
context.Resolver.AddUnmatchedElementDiagnostic(parent.Children[index], elementNode, context.DocumentNode);
}

for (var j = index + resultCount - 1; j >= index; j--)
Expand Down Expand Up @@ -785,7 +788,10 @@ private static void FlattenToDirectCSharpTokens(IntermediateNode source, Interme
/// resolved by tag helper matching. Converts each to a plain element using the resolver.
/// Recursively processes the tree to handle nested elements.
/// </summary>
private void UnwrapAllElements(IntermediateNode node, DocumentIntermediateNode documentNode = null)
private static void UnwrapAllElements(
IntermediateNode node,
TagHelperResolver resolver,
DocumentIntermediateNode documentNode = null)
{
if (node is DocumentIntermediateNode doc)
{
Expand All @@ -795,17 +801,17 @@ private void UnwrapAllElements(IntermediateNode node, DocumentIntermediateNode d
for (var i = node.Children.Count - 1; i >= 0; i--)
{
var child = node.Children[i];
UnwrapAllElements(child, documentNode);
UnwrapAllElements(child, resolver, documentNode);

if (child is UnresolvedElementIntermediateNode elementNode)
{
var countBefore = node.Children.Count;
_resolver.ConvertToPlainElement(node, i, elementNode);
resolver.ConvertToPlainElement(node, i, elementNode);
var resultCount = node.Children.Count - countBefore + 1;

if (resultCount > 0)
{
_resolver.AddUnmatchedElementDiagnostic(node.Children[i], elementNode, documentNode);
resolver.AddUnmatchedElementDiagnostic(node.Children[i], elementNode, documentNode);
}
}
}
Expand Down