From 81286b48a4915ea44c651f49c8bc0e4b60156716 Mon Sep 17 00:00:00 2001 From: Chris Sienkiewicz Date: Tue, 14 Apr 2026 20:53:42 -0700 Subject: [PATCH 1/9] Add support for writing HTML literals using UTF-8 strings Implement auto-detection of UTF-8 WriteLiteral support for legacy .cshtml code generation. When a page's @inherits base class has a callable WriteLiteral(ReadOnlySpan) overload, HTML literals are emitted as C# UTF-8 string literals ("..."u8). --- .../CodeGenerationIntegrationTest.cs | 94 +++++++ .../CodeGeneration/RuntimeNodeWriterTest.cs | 46 ++++ .../test/RazorProjectEngineTest.cs | 2 + .../src/CSharp/CompilationExtensions.cs | 53 +++- .../CSharp/DefaultUtf8WriteLiteralFeature.cs | 110 ++++++++ .../src/CSharp/IUtf8WriteLiteralFeature.cs | 20 ++ .../CSharp/Utf8WriteLiteralDetectionPass.cs | 49 ++++ .../CodeGeneration/CodeWriterExtensions.cs | 24 +- .../CodeGeneration/RuntimeNodeWriter.cs | 2 +- .../DefaultRazorCSharpLoweringPhase.cs | 2 +- .../Language/RazorCodeDocumentExtensions.cs | 34 +++ .../RazorCodeGenerationOptions.Builder.cs | 9 + .../RazorCodeGenerationOptions.Flags.cs | 1 + .../Language/RazorCodeGenerationOptions.cs | 14 +- .../src/Language/RazorProjectEngine.cs | 2 + .../RazorSourceGenerator.Helpers.cs | 3 + .../SourceGenerators/RazorSourceGenerator.cs | 25 +- .../SourceGeneratorProjectEngine.cs | 9 +- .../RazorSourceGeneratorCshtmlTests.cs | 237 ++++++++++++++++++ .../Pages/Index_cshtml.g.cs | 59 +++++ .../Pages/Index_cshtml.g.cs | 59 +++++ .../TestCodeRenderingContext.cs | 8 +- 22 files changed, 845 insertions(+), 17 deletions(-) create mode 100644 src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/DefaultUtf8WriteLiteralFeature.cs create mode 100644 src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/IUtf8WriteLiteralFeature.cs create mode 100644 src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/Utf8WriteLiteralDetectionPass.cs create mode 100644 src/Compiler/test/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/TestFiles/RazorSourceGeneratorCshtmlTests/Utf8HtmlLiterals_AutoDetectedFromInherits/Pages/Index_cshtml.g.cs create mode 100644 src/Compiler/test/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/TestFiles/RazorSourceGeneratorCshtmlTests/Utf8HtmlLiterals_WithoutOverload_UsesStringLiterals/Pages/Index_cshtml.g.cs diff --git a/src/Compiler/Microsoft.AspNetCore.Mvc.Razor.Extensions/test/IntegrationTests/CodeGenerationIntegrationTest.cs b/src/Compiler/Microsoft.AspNetCore.Mvc.Razor.Extensions/test/IntegrationTests/CodeGenerationIntegrationTest.cs index 01c1f17d866..0c91d7d70d3 100644 --- a/src/Compiler/Microsoft.AspNetCore.Mvc.Razor.Extensions/test/IntegrationTests/CodeGenerationIntegrationTest.cs +++ b/src/Compiler/Microsoft.AspNetCore.Mvc.Razor.Extensions/test/IntegrationTests/CodeGenerationIntegrationTest.cs @@ -3,11 +3,15 @@ #nullable disable +using System; +using System.Collections.Generic; +using System.Collections.Immutable; using System.Linq; using Microsoft.AspNetCore.Razor.Language; using Microsoft.AspNetCore.Razor.Language.IntegrationTests; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Razor.Compiler.CSharp; using Microsoft.CodeAnalysis.Test.Utilities; using Roslyn.Test.Utilities; using Xunit; @@ -30,6 +34,20 @@ public CodeGenerationIntegrationTest() protected override RazorConfiguration Configuration => _configuration; + protected override void ConfigureProjectEngine(RazorProjectEngineBuilder builder) + { + base.ConfigureProjectEngine(builder); + + // Register the UTF-8 WriteLiteral feature with a pre-computed support map. + var supportMap = new DefaultUtf8WriteLiteralFeature.Utf8SupportMap( + ImmutableSortedDictionary.CreateRange(StringComparer.Ordinal, new[] + { + new KeyValuePair("MyUtf8PageBase", true), + new KeyValuePair("MyPageBase", false), + })); + builder.Features.Add(new DefaultUtf8WriteLiteralFeature { SupportMap = supportMap }); + } + #region Runtime [Fact] @@ -926,6 +944,82 @@ public void InvalidCode_EmptyImplicitExpression_Runtime() CompileToAssembly(generated, throwOnFailure: false, ignoreRazorDiagnostics: true); } + [Fact, WorkItem("https://github.com/dotnet/razor/issues/8429")] + public void Utf8HtmlLiterals_AutoDetectedFromInherits_Runtime() + { + // Arrange + _configuration = new(RazorLanguageVersion.Preview, "MVC-3.0", Extensions: []); + + AddCSharpSyntaxTree(""" + + using System; + using System.Threading.Tasks; + using Microsoft.AspNetCore.Mvc.Razor; + + public abstract class MyUtf8PageBase : RazorPage + { + public void WriteLiteral(ReadOnlySpan value) + { + WriteLiteral(System.Text.Encoding.UTF8.GetString(value)); + } + } + + """); + + // Act + var generated = CompileToCSharp(""" + @inherits MyUtf8PageBase + + + +

Hello World

+

This is UTF-8 encoded HTML content.

+ + + """); + + // Assert + CompileToAssembly(generated); + + var generatedCode = generated.CodeDocument.GetCSharpDocument().Text.ToString(); + Assert.Contains("u8)", generatedCode); + } + + [Fact, WorkItem("https://github.com/dotnet/razor/issues/8429")] + public void Utf8HtmlLiterals_WithoutOverload_UsesStringLiterals_Runtime() + { + // Arrange + _configuration = new(RazorLanguageVersion.Preview, "MVC-3.0", Extensions: []); + + AddCSharpSyntaxTree(""" + + using System.Threading.Tasks; + using Microsoft.AspNetCore.Mvc.Razor; + + public abstract class MyPageBase : RazorPage + { + } + + """); + + // Act + var generated = CompileToCSharp(""" + @inherits MyPageBase + + + +

Hello World

+ + + """); + + // Assert + CompileToAssembly(generated); + + var generatedCode = generated.CodeDocument.GetCSharpDocument().Text.ToString(); + Assert.DoesNotContain("u8)", generatedCode); + } + #endregion #region DesignTime diff --git a/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/CodeGeneration/RuntimeNodeWriterTest.cs b/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/CodeGeneration/RuntimeNodeWriterTest.cs index 90bf233f38d..798ecbfad9a 100644 --- a/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/CodeGeneration/RuntimeNodeWriterTest.cs +++ b/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/CodeGeneration/RuntimeNodeWriterTest.cs @@ -445,6 +445,52 @@ public void WriteHtmlContent_RendersContentCorrectly() ignoreLineEndingDifferences: true); } + [Fact] + public void WriteHtmlContent_Utf8_RendersContentCorrectly() + { + // Arrange + var writer = RuntimeNodeWriter.Instance; + using var context = TestCodeRenderingContext.CreateRuntime(writeHtmlUtf8StringLiterals: true); + + var node = new HtmlContentIntermediateNode(); + node.Children.Add(IntermediateNodeFactory.HtmlToken("SomeContent")); + + // Act + writer.WriteHtmlContent(context, node); + + // Assert + var csharp = context.CodeWriter.GetText().ToString(); + Assert.Equal( +@"WriteLiteral(""SomeContent""u8); +", + csharp, + ignoreLineEndingDifferences: true); + } + + [Fact] + public void WriteHtmlContent_Utf8_LargeStringLiteral_UsesMultipleWrites() + { + // Arrange + var writer = RuntimeNodeWriter.Instance; + using var context = TestCodeRenderingContext.CreateRuntime(writeHtmlUtf8StringLiterals: true); + + var node = new HtmlContentIntermediateNode(); + node.Children.Add(IntermediateNodeFactory.HtmlToken(new string('*', 2000))); + + // Act + writer.WriteHtmlContent(context, node); + + // Assert + var csharp = context.CodeWriter.GetText().ToString(); + Assert.Equal(string.Format( + CultureInfo.InvariantCulture, +@"WriteLiteral(@""{0}""u8); +WriteLiteral(@""{1}""u8); +", new string('*', 1024), new string('*', 976)), + csharp, + ignoreLineEndingDifferences: true); + } + [Fact] public void WriteHtmlContent_LargeStringLiteral_UsesMultipleWrites() { diff --git a/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/RazorProjectEngineTest.cs b/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/RazorProjectEngineTest.cs index 7075d6e6ae1..b226f27da5a 100644 --- a/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/RazorProjectEngineTest.cs +++ b/src/Compiler/Microsoft.AspNetCore.Razor.Language/test/RazorProjectEngineTest.cs @@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Razor.Language.Components; using Microsoft.AspNetCore.Razor.Language.Extensions; using Microsoft.AspNetCore.Razor.PooledObjects; +using Microsoft.CodeAnalysis.Razor.Compiler.CSharp; using Moq; using Xunit; @@ -91,6 +92,7 @@ private static void AssertDefaultFeatures(RazorProjectEngine engine) feature => Assert.IsType(feature), feature => Assert.IsType(feature), feature => Assert.IsType(feature), + feature => Assert.IsType(feature), feature => Assert.IsType(feature)); } diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/CompilationExtensions.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/CompilationExtensions.cs index dc5dcd44c86..546b039da08 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/CompilationExtensions.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/CompilationExtensions.cs @@ -1,7 +1,6 @@ // 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.Linq; namespace Microsoft.CodeAnalysis.Razor.Compiler.CSharp; @@ -16,4 +15,56 @@ public static bool HasAddComponentParameter(this Compilation compilation) t.GetMembers("AddComponentParameter") .Any(static m => m.DeclaredAccessibility == Accessibility.Public)); } + + /// + /// Determines whether the type identified by has a callable + /// instance WriteLiteral(ReadOnlySpan<byte>) overload accessible from that type. + /// + public static bool HasCallableUtf8WriteLiteralOverload(this Compilation compilation, string typeMetadataName) + { + var type = compilation.GetTypeByMetadataName(typeMetadataName); + if (type is null || type.TypeKind == TypeKind.Error) + { + return false; + } + + return compilation.HasCallableUtf8WriteLiteralOverload(type); + } + + /// + /// Determines whether the given has a callable + /// instance WriteLiteral(ReadOnlySpan<byte>) overload accessible from that type. + /// + public static bool HasCallableUtf8WriteLiteralOverload(this Compilation compilation, INamedTypeSymbol type) + { + var readOnlySpanType = compilation.GetTypeByMetadataName("System.ReadOnlySpan`1"); + var byteType = compilation.GetSpecialType(SpecialType.System_Byte); + if (readOnlySpanType is not INamedTypeSymbol readOnlySpanNamedType || + byteType.TypeKind == TypeKind.Error) + { + return false; + } + + var readOnlySpanOfByte = readOnlySpanNamedType.Construct(byteType); + + for (var currentType = type; currentType is not null; currentType = currentType.BaseType) + { + foreach (var member in currentType.GetMembers("WriteLiteral")) + { + if (member is IMethodSymbol + { + IsStatic: false, + ReturnsVoid: true, + Parameters: [{ Type: var paramType }] + } method && + SymbolEqualityComparer.Default.Equals(paramType, readOnlySpanOfByte) && + compilation.IsSymbolAccessibleWithin(method, type)) + { + return true; + } + } + } + + return false; + } } diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/DefaultUtf8WriteLiteralFeature.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/DefaultUtf8WriteLiteralFeature.cs new file mode 100644 index 00000000000..b9a1515f832 --- /dev/null +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/DefaultUtf8WriteLiteralFeature.cs @@ -0,0 +1,110 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Microsoft.AspNetCore.Razor.Language; + +namespace Microsoft.CodeAnalysis.Razor.Compiler.CSharp; + +/// +/// Default implementation of backed by a pre-computed +/// . The map is set by the source generator before code generation runs. +/// +/// +/// This type implements directly (rather than extending +/// ) because the same instance is shared across multiple +/// per-file instances in the source generator, and +/// does not allow re-initialization. +/// +internal sealed class DefaultUtf8WriteLiteralFeature : IUtf8WriteLiteralFeature +{ + private RazorEngine? _engine; + + public RazorEngine Engine + { + get => _engine!; + init => _engine = value; + } + + public Utf8SupportMap SupportMap { get; set; } = Utf8SupportMap.Empty; + + public void Initialize(RazorEngine engine) + { + _engine = engine; + } + + public bool IsSupported(string baseTypeName) + => SupportMap.IsSupported(baseTypeName); + + /// + /// A value-comparable map of base type names to whether they support UTF-8 WriteLiteral. + /// Used to flow pre-computed results through the incremental pipeline without carrying a + /// reference. + /// + internal sealed class Utf8SupportMap : IEquatable + { + public static readonly Utf8SupportMap Empty = new(ImmutableSortedDictionary.Empty); + + private readonly ImmutableSortedDictionary _entries; + + internal Utf8SupportMap(ImmutableSortedDictionary entries) + { + _entries = entries; + } + + /// + /// Builds a by checking each base type name against the compilation. + /// Null and duplicate entries are filtered out. + /// + public static Utf8SupportMap Create(ImmutableArray baseTypeNames, Compilation compilation) + { + var builder = ImmutableSortedDictionary.CreateBuilder(StringComparer.Ordinal); + + foreach (var name in baseTypeNames) + { + if (!string.IsNullOrEmpty(name) && !builder.ContainsKey(name)) + { + builder[name] = compilation.HasCallableUtf8WriteLiteralOverload(name); + } + } + + return builder.Count == 0 ? Empty : new Utf8SupportMap(builder.ToImmutable()); + } + + public bool IsSupported(string baseTypeName) + => _entries.TryGetValue(baseTypeName, out var supported) && supported; + + public bool Equals(Utf8SupportMap? other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + return _entries.SequenceEqual(other._entries); + } + + public override bool Equals(object? obj) => Equals(obj as Utf8SupportMap); + + public override int GetHashCode() + { + var hash = 17; + + foreach (var kvp in _entries) + { + hash = hash * 31 + StringComparer.Ordinal.GetHashCode(kvp.Key); + hash = hash * 31 + kvp.Value.GetHashCode(); + } + + return hash; + } + } +} diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/IUtf8WriteLiteralFeature.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/IUtf8WriteLiteralFeature.cs new file mode 100644 index 00000000000..24d0e6d0adc --- /dev/null +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/IUtf8WriteLiteralFeature.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Razor.Language; + +namespace Microsoft.CodeAnalysis.Razor.Compiler.CSharp; + +/// +/// An that determines whether a given base type +/// supports calling WriteLiteral(ReadOnlySpan<byte>) for UTF-8 HTML literal emission. +/// +internal interface IUtf8WriteLiteralFeature : IRazorEngineFeature +{ + /// + /// Returns if the specified base type has a callable + /// WriteLiteral(ReadOnlySpan<byte>) overload. + /// + /// The fully-qualified metadata name of the base type to check. + bool IsSupported(string baseTypeName); +} diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/Utf8WriteLiteralDetectionPass.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/Utf8WriteLiteralDetectionPass.cs new file mode 100644 index 00000000000..9b2db316b9e --- /dev/null +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/Utf8WriteLiteralDetectionPass.cs @@ -0,0 +1,49 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Threading; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.Language.Intermediate; + +namespace Microsoft.CodeAnalysis.Razor.Compiler.CSharp; + +internal sealed class Utf8WriteLiteralDetectionPass : IntermediateNodePassBase, IRazorOptimizationPass +{ + private IUtf8WriteLiteralFeature? _utf8Feature; + + protected override void OnInitialized() + { + Engine.TryGetFeature(out _utf8Feature); + } + + protected override void ExecuteCore( + RazorCodeDocument codeDocument, + DocumentIntermediateNode documentNode, + CancellationToken cancellationToken) + { + if (_utf8Feature is null || + !codeDocument.FileKind.IsLegacy() || + documentNode.Options is null || + documentNode.Options.DesignTime || + documentNode.Options.WriteHtmlUtf8StringLiterals) + { + return; + } + + var @class = documentNode.FindPrimaryClass(); + var baseType = @class?.BaseType; + if (baseType is null || string.IsNullOrWhiteSpace(baseType.BaseType.Content)) + { + // No explicit @inherits directive. The default Razor base classes don't currently + // support WriteLiteral(ReadOnlySpan). When they do, this check should be + // expanded to also probe the default base type. + return; + } + + var baseTypeName = baseType.BaseType.Content; + if (_utf8Feature.IsSupported(baseTypeName)) + { + documentNode.Options = documentNode.Options.WithFlags(writeHtmlUtf8StringLiterals: true); + } + } +} diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/CodeGeneration/CodeWriterExtensions.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/CodeGeneration/CodeWriterExtensions.cs index 6741b8799e5..945763d0345 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/CodeGeneration/CodeWriterExtensions.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/CodeGeneration/CodeWriterExtensions.cs @@ -265,18 +265,18 @@ public static CodeWriter WriteStartNewObject(this CodeWriter writer, string type return writer.Write("new ").Write(typeName).Write("("); } - public static CodeWriter WriteStringLiteral(this CodeWriter writer, string literal) - => writer.WriteStringLiteral(literal.AsMemory()); + public static CodeWriter WriteStringLiteral(this CodeWriter writer, string literal, bool utf8 = false) + => writer.WriteStringLiteral(literal.AsMemory(), utf8); - public static CodeWriter WriteStringLiteral(this CodeWriter writer, ReadOnlyMemory literal) + public static CodeWriter WriteStringLiteral(this CodeWriter writer, ReadOnlyMemory literal, bool utf8 = false) { if (literal.Length >= 256 && literal.Length <= 1500 && literal.Span.IndexOf('\0') == -1) { - WriteVerbatimStringLiteral(writer, literal); + WriteVerbatimStringLiteral(writer, literal, utf8); } else { - WriteCStyleStringLiteral(writer, literal); + WriteCStyleStringLiteral(writer, literal, utf8); } return writer; @@ -900,7 +900,7 @@ public static CodeWriter WriteSeparatedList(this CodeWriter writer, string se return writer; } - private static void WriteVerbatimStringLiteral(CodeWriter writer, ReadOnlyMemory literal) + private static void WriteVerbatimStringLiteral(CodeWriter writer, ReadOnlyMemory literal, bool utf8 = false) { writer.Write("@\""); @@ -926,10 +926,15 @@ private static void WriteVerbatimStringLiteral(CodeWriter writer, ReadOnlyMemory writer.Write("\""); + if (utf8) + { + writer.Write("u8"); + } + writer.CurrentIndent = oldIndent; } - private static void WriteCStyleStringLiteral(CodeWriter writer, ReadOnlyMemory literal) + private static void WriteCStyleStringLiteral(CodeWriter writer, ReadOnlyMemory literal, bool utf8 = false) { // From CSharpCodeGenerator.QuoteSnippetStringCStyle in CodeDOM writer.Write("\""); @@ -983,6 +988,11 @@ private static void WriteCStyleStringLiteral(CodeWriter writer, ReadOnlyMemory content) { context.CodeWriter .WriteStartMethodInvocation(WriteHtmlContentMethod) - .WriteStringLiteral(content) + .WriteStringLiteral(content, context.Options.WriteHtmlUtf8StringLiterals) .WriteEndMethodInvocation(); } } diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/DefaultRazorCSharpLoweringPhase.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/DefaultRazorCSharpLoweringPhase.cs index 00abcedbdb2..2b058c97f1b 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/DefaultRazorCSharpLoweringPhase.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/DefaultRazorCSharpLoweringPhase.cs @@ -41,7 +41,7 @@ private static RazorCSharpDocument WriteDocument(RazorCodeDocument codeDocument, codeTarget.CreateNodeWriter(), codeDocument.Source, documentNode, - codeDocument.CodeGenerationOptions); + documentNode.Options ?? codeDocument.CodeGenerationOptions); context.SetVisitor(new Visitor(context, codeTarget, cancellationToken)); diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorCodeDocumentExtensions.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorCodeDocumentExtensions.cs index 081928c989a..00f0ce531cc 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorCodeDocumentExtensions.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorCodeDocumentExtensions.cs @@ -6,6 +6,7 @@ using System.Diagnostics.CodeAnalysis; using System.IO; using Microsoft.AspNetCore.Mvc.Razor.Extensions; +using Microsoft.AspNetCore.Razor.Language.Extensions; using Microsoft.AspNetCore.Razor.Language.Legacy; using Microsoft.AspNetCore.Razor.Language.Syntax; @@ -43,6 +44,39 @@ internal static bool IsImportsFile(this RazorCodeDocument codeDocument) => codeDocument.FileKind.IsComponentImport() || (codeDocument.FileKind.IsLegacy() && string.Equals(Path.GetFileName(codeDocument.Source.FilePath), MvcImportProjectFeature.ImportsFileName, StringComparison.OrdinalIgnoreCase)); + /// + /// Returns the content of the @inherits directive if present in a legacy .cshtml + /// document's syntax tree, or for non-legacy files or when absent. + /// + internal static string? GetInheritsDirectiveContent(this RazorCodeDocument codeDocument) + { + if (!codeDocument.FileKind.IsLegacy()) + { + return null; + } + + var syntaxTree = codeDocument.GetSyntaxTree(); + if (syntaxTree is null) + { + return null; + } + + foreach (var node in syntaxTree.Root.DescendantNodes()) + { + if (node is RazorDirectiveSyntax + { + DirectiveDescriptor: var descriptor, + Body: RazorDirectiveBodySyntax { CSharpCode: { } csharpCode } + } && + descriptor == InheritsDirective.Directive) + { + return csharpCode.GetContent()?.Trim(); + } + } + + return null; + } + /// /// Returns whether the directive specified was involved in tag helper binding /// diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorCodeGenerationOptions.Builder.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorCodeGenerationOptions.Builder.cs index abacfbde00d..f587cb4422f 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorCodeGenerationOptions.Builder.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorCodeGenerationOptions.Builder.cs @@ -163,6 +163,15 @@ public bool RemapLinePragmaPathsOnWindows set => _flags.UpdateFlag(Flags.RemapLinePragmaPathsOnWindows, value); } + /// + /// Gets or sets a value that determines if HTML literals should be written as C# UTF-8 string literals. + /// + public bool WriteHtmlUtf8StringLiterals + { + get => _flags.IsFlagSet(Flags.WriteHtmlUtf8StringLiterals); + set => _flags.UpdateFlag(Flags.WriteHtmlUtf8StringLiterals, value); + } + public RazorCodeGenerationOptions ToOptions() => new(IndentSize, NewLine, RootNamespace, CssScope, SuppressUniqueIds, RazorWarningLevel, _flags); } diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorCodeGenerationOptions.Flags.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorCodeGenerationOptions.Flags.cs index 71a3641b612..4ff206c8e8d 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorCodeGenerationOptions.Flags.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorCodeGenerationOptions.Flags.cs @@ -22,6 +22,7 @@ private enum Flags UseEnhancedLinePragma = 1 << 9, SuppressAddComponentParameter = 1 << 10, RemapLinePragmaPathsOnWindows = 1 << 11, + WriteHtmlUtf8StringLiterals = 1 << 12, DefaultFlags = UseEnhancedLinePragma, DefaultDesignTimeFlags = DesignTime | SuppressMetadataAttributes | UseEnhancedLinePragma | RemapLinePragmaPathsOnWindows diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorCodeGenerationOptions.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorCodeGenerationOptions.cs index eb190417843..64d977769b2 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorCodeGenerationOptions.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorCodeGenerationOptions.cs @@ -170,6 +170,12 @@ public bool SuppressAddComponentParameter public bool RemapLinePragmaPathsOnWindows => (_flags & Flags.RemapLinePragmaPathsOnWindows) == Flags.RemapLinePragmaPathsOnWindows; + /// + /// Gets a value that determines if HTML literals should be written as C# UTF-8 string literals. + /// + public bool WriteHtmlUtf8StringLiterals + => (_flags & Flags.WriteHtmlUtf8StringLiterals) == Flags.WriteHtmlUtf8StringLiterals; + public RazorCodeGenerationOptions WithIndentSize(int value) => IndentSize == value ? this @@ -212,7 +218,8 @@ public RazorCodeGenerationOptions WithFlags( Optional supportLocalizedComponentNames = default, Optional useEnhancedLinePragma = default, Optional suppressAddComponentParameter = default, - Optional remapLinePragmaPathsOnWindows = default) + Optional remapLinePragmaPathsOnWindows = default, + Optional writeHtmlUtf8StringLiterals = default) { var flags = _flags; @@ -276,6 +283,11 @@ public RazorCodeGenerationOptions WithFlags( flags.UpdateFlag(Flags.RemapLinePragmaPathsOnWindows, remapLinePragmaPathsOnWindows.Value); } + if (writeHtmlUtf8StringLiterals.HasValue) + { + flags.UpdateFlag(Flags.WriteHtmlUtf8StringLiterals, writeHtmlUtf8StringLiterals.Value); + } + return flags == _flags ? this : new(IndentSize, NewLine, RootNamespace, CssScope, SuppressUniqueIds, RazorWarningLevel, flags); diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorProjectEngine.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorProjectEngine.cs index abed1f4e30f..2dfac1de7d2 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorProjectEngine.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorProjectEngine.cs @@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Razor.Language.Extensions; using Microsoft.AspNetCore.Razor.Language.Intermediate; using Microsoft.AspNetCore.Razor.PooledObjects; +using Microsoft.CodeAnalysis.Razor.Compiler.CSharp; namespace Microsoft.AspNetCore.Razor.Language; @@ -359,6 +360,7 @@ private static void AddDefaultFeatures(ImmutableArray.Builder fea // Intermediate Node Passes features.Add(new DefaultDocumentClassifierPass()); features.Add(new MetadataAttributePass()); + features.Add(new Utf8WriteLiteralDetectionPass()); features.Add(new DesignTimeDirectivePass()); features.Add(new DirectiveRemovalOptimizationPass()); features.Add(new DefaultTagHelperOptimizationPass()); diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/RazorSourceGenerator.Helpers.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/RazorSourceGenerator.Helpers.cs index 6a8b216fa54..d5a11e9b3cd 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/RazorSourceGenerator.Helpers.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/RazorSourceGenerator.Helpers.cs @@ -9,6 +9,7 @@ using Microsoft.AspNetCore.Razor.PooledObjects; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Razor; +using Microsoft.CodeAnalysis.Razor.Compiler.CSharp; namespace Microsoft.NET.Sdk.Razor.SourceGenerators { @@ -133,6 +134,8 @@ private static SourceGeneratorProjectEngine GetGenerationProjectEngine( builder.CSharpParseOptions = razorSourceGeneratorOptions.CSharpParseOptions; }); + b.Features.Add(new DefaultUtf8WriteLiteralFeature()); + CompilerFeatures.Register(b); RazorExtensions.Register(b); diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/RazorSourceGenerator.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/RazorSourceGenerator.cs index 4358c46193c..7e1038e0bae 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/RazorSourceGenerator.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/RazorSourceGenerator.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.IO; using System.Linq; using Microsoft.AspNetCore.Razor; @@ -11,6 +12,7 @@ using Microsoft.AspNetCore.Razor.Utilities; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Razor.Compiler.CSharp; namespace Microsoft.NET.Sdk.Razor.SourceGenerators { @@ -249,7 +251,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) .WithLambdaComparer((old, @new) => old.Left.Equals(@new.Left) && old.Right.SequenceEqual(@new.Right)) .Combine(razorSourceGeneratorOptions); - var csharpDocuments = withOptions + var parsedDocuments = withOptions .Select((pair, cancellationToken) => { var ((sourceItem, imports), razorSourceGeneratorOptions) = pair; @@ -263,7 +265,21 @@ public void Initialize(IncrementalGeneratorInitializationContext context) RazorSourceGeneratorEventSource.Log.ParseRazorDocumentStop(sourceItem.RelativePhysicalPath); return (projectEngine, sourceItem.RelativePhysicalPath, document); }) - .WithTrackingName("ParsedDocuments") + .WithTrackingName("ParsedDocuments"); + + // Build a map of which @inherits base types support UTF-8 WriteLiteral. + var utf8SupportMap = parsedDocuments + .Select(static (item, _) => item.Item3.CodeDocument.GetInheritsDirectiveContent()) + .Collect() + .Combine(declCompilation) + .Select(static (pair, _) => + { + var (baseTypeNames, compilation) = pair; + return DefaultUtf8WriteLiteralFeature.Utf8SupportMap.Create(baseTypeNames, compilation); + }) + .WithTrackingName("Utf8SupportMap"); + + var csharpDocuments = parsedDocuments // Add the tag helpers in, but ignore if they've changed or not, only reprocessing the actual document changed .Combine(allTagHelpers) @@ -293,12 +309,13 @@ public void Initialize(IncrementalGeneratorInitializationContext context) return (projectEngine, filePath, document); }) .WithTrackingName("CheckedAndRewrittenTagHelpers") + .Combine(utf8SupportMap) .Select((pair, cancellationToken) => { - var (projectEngine, filePath, document) = pair; + var ((projectEngine, filePath, document), utf8SupportMap) = pair; RazorSourceGeneratorEventSource.Log.RazorCodeGenerateStart(filePath); - document = projectEngine.ProcessRemaining(document, cancellationToken); + document = projectEngine.ProcessRemaining(document, utf8SupportMap, cancellationToken); RazorSourceGeneratorEventSource.Log.RazorCodeGenerateStop(filePath); return (filePath, document); diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/SourceGeneratorProjectEngine.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/SourceGeneratorProjectEngine.cs index b22443db84a..140a6a7174f 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/SourceGeneratorProjectEngine.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/SourceGeneratorProjectEngine.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.AspNetCore.Razor.Language; +using Microsoft.CodeAnalysis.Razor.Compiler.CSharp; using System; using System.Diagnostics; using System.Threading; @@ -165,11 +166,17 @@ private static bool HasAnyNotIn(TagHelperCollection first, TagHelperCollection s return false; } - public SourceGeneratorRazorCodeDocument ProcessRemaining(SourceGeneratorRazorCodeDocument sgDocument, CancellationToken cancellationToken) + public SourceGeneratorRazorCodeDocument ProcessRemaining(SourceGeneratorRazorCodeDocument sgDocument, DefaultUtf8WriteLiteralFeature.Utf8SupportMap utf8SupportMap, CancellationToken cancellationToken) { var codeDocument = sgDocument.CodeDocument; Debug.Assert(codeDocument.GetReferencedTagHelpers() is not null); + if (_projectEngine.Engine.TryGetFeature(out var feature) && + feature is DefaultUtf8WriteLiteralFeature defaultFeature) + { + defaultFeature.SupportMap = utf8SupportMap; + } + codeDocument = ExecutePhases(Phases[(_rewritePhaseIndex + 1)..], codeDocument, cancellationToken); return new SourceGeneratorRazorCodeDocument(codeDocument); diff --git a/src/Compiler/test/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/RazorSourceGeneratorCshtmlTests.cs b/src/Compiler/test/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/RazorSourceGeneratorCshtmlTests.cs index f800c9961c9..4bed19e1d83 100644 --- a/src/Compiler/test/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/RazorSourceGeneratorCshtmlTests.cs +++ b/src/Compiler/test/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/RazorSourceGeneratorCshtmlTests.cs @@ -1,9 +1,12 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Linq; +using System.Text; using System.Threading.Tasks; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Text; using Roslyn.Test.Utilities; using Xunit; @@ -167,4 +170,238 @@ public async Task QuoteInAttributeName() """, html); } + + [Fact, WorkItem("https://github.com/dotnet/razor/issues/8429")] + public async Task Utf8HtmlLiterals_AutoDetectedFromInherits() + { + // Arrange + var project = CreateTestProject( + additionalSources: new() + { + ["Pages/Index.cshtml"] = """ + @inherits MyUtf8PageBase +

Hello World

+ """, + }, + sources: new() + { + ["MyUtf8PageBase.cs"] = """ + using System; + using Microsoft.AspNetCore.Mvc.Razor; + + public abstract class MyUtf8PageBase : RazorPage + { + public void WriteLiteral(ReadOnlySpan utf8HtmlLiteral) + { + WriteLiteral(System.Text.Encoding.UTF8.GetString(utf8HtmlLiteral)); + } + } + """ + }); + + var compilation = await project.GetCompilationAsync(); + var driver = await GetDriverAsync(project); + + // Act + var result = RunGenerator(compilation!, ref driver, out _); + + // Assert + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedSources); + result.VerifyOutputsMatchBaseline(); + Assert.Contains("u8)", result.GeneratedSources[0].SourceText.ToString()); + } + + [Fact, WorkItem("https://github.com/dotnet/razor/issues/8429")] + public async Task Utf8HtmlLiterals_WithoutOverload_UsesStringLiterals() + { + // Arrange + var project = CreateTestProject( + additionalSources: new() + { + ["Pages/Index.cshtml"] = """ + @inherits MyPageBase +

Hello World

+ """, + }, + sources: new() + { + ["MyPageBase.cs"] = """ + using Microsoft.AspNetCore.Mvc.Razor; + + public abstract class MyPageBase : RazorPage + { + } + """ + }); + + var compilation = await project.GetCompilationAsync(); + var driver = await GetDriverAsync(project); + + // Act + var result = RunGenerator(compilation!, ref driver, out _); + + // Assert + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedSources); + result.VerifyOutputsMatchBaseline(); + Assert.DoesNotContain("u8)", result.GeneratedSources[0].SourceText.ToString()); + } + + [Fact, WorkItem("https://github.com/dotnet/razor/issues/8429")] + public async Task Utf8HtmlLiterals_MixedFiles_OnlyUtf8ForInheritsWithOverload() + { + // Arrange - one file inherits a base with the overload, the other inherits one without + var project = CreateTestProject( + additionalSources: new() + { + ["Pages/Utf8Page.cshtml"] = """ + @inherits MyUtf8PageBase +

UTF-8 Page

+ """, + ["Pages/RegularPage.cshtml"] = """ + @inherits MyRegularPageBase +

Regular Page

+ """, + }, + sources: new() + { + ["MyUtf8PageBase.cs"] = """ + using System; + using Microsoft.AspNetCore.Mvc.Razor; + + public abstract class MyUtf8PageBase : RazorPage + { + public void WriteLiteral(ReadOnlySpan utf8HtmlLiteral) + { + WriteLiteral(System.Text.Encoding.UTF8.GetString(utf8HtmlLiteral)); + } + } + """, + ["MyRegularPageBase.cs"] = """ + using Microsoft.AspNetCore.Mvc.Razor; + + public abstract class MyRegularPageBase : RazorPage + { + } + """ + }); + + var compilation = await project.GetCompilationAsync(); + var driver = await GetDriverAsync(project); + + // Act + var result = RunGenerator(compilation!, ref driver, out _); + + // Assert + Assert.Empty(result.Diagnostics); + Assert.Equal(2, result.GeneratedSources.Length); + + var utf8Source = result.GeneratedSources.Single(s => s.HintName.Contains("Utf8Page")).SourceText.ToString(); + var regularSource = result.GeneratedSources.Single(s => s.HintName.Contains("RegularPage")).SourceText.ToString(); + + Assert.Contains("u8)", utf8Source); + Assert.DoesNotContain("u8)", regularSource); + } + + [Fact, WorkItem("https://github.com/dotnet/razor/issues/8429")] + public async Task Utf8HtmlLiterals_SwitchesWhenOverloadAddedOrRemoved() + { + // Arrange - start without the UTF-8 overload + var project = CreateTestProject( + additionalSources: new() + { + ["Pages/Index.cshtml"] = """ + @inherits MyPageBase +

Hello World

+ """, + }, + sources: new() + { + ["MyPageBase.cs"] = """ + using Microsoft.AspNetCore.Mvc.Razor; + + public abstract class MyPageBase : RazorPage + { + } + """ + }); + + var compilation = await project.GetCompilationAsync(); + var driver = await GetDriverAsync(project); + + // Act 1 - verify string literals are used + var result = RunGenerator(compilation!, ref driver, out _); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedSources); + Assert.DoesNotContain("u8)", result.GeneratedSources[0].SourceText.ToString()); + + // Act 2 - add the UTF-8 overload to the base class + var baseClassDoc = project.Documents.Single(d => d.Name == "MyPageBase.cs"); + project = project.RemoveDocument(baseClassDoc.Id) + .AddDocument("MyPageBase.cs", SourceText.From(""" + using System; + using Microsoft.AspNetCore.Mvc.Razor; + + public abstract class MyPageBase : RazorPage + { + public void WriteLiteral(ReadOnlySpan utf8HtmlLiteral) + { + WriteLiteral(System.Text.Encoding.UTF8.GetString(utf8HtmlLiteral)); + } + } + """, Encoding.UTF8)).Project; + + compilation = await project.GetCompilationAsync(); + result = RunGenerator(compilation!, ref driver, out _); + + // Assert - should now use UTF-8 literals + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedSources); + Assert.Contains("u8)", result.GeneratedSources[0].SourceText.ToString()); + + // Act 3 - remove the UTF-8 overload from the base class + baseClassDoc = project.Documents.Single(d => d.Name == "MyPageBase.cs"); + project = project.RemoveDocument(baseClassDoc.Id) + .AddDocument("MyPageBase.cs", SourceText.From(""" + using Microsoft.AspNetCore.Mvc.Razor; + + public abstract class MyPageBase : RazorPage + { + } + """, Encoding.UTF8)).Project; + + compilation = await project.GetCompilationAsync(); + result = RunGenerator(compilation!, ref driver, out _); + + // Assert - should switch back to string literals + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedSources); + Assert.DoesNotContain("u8)", result.GeneratedSources[0].SourceText.ToString()); + } + + [Fact, WorkItem("https://github.com/dotnet/razor/issues/8429")] + public async Task Utf8HtmlLiterals_NoInheritsDirective_UsesStringLiterals() + { + // Arrange - no @inherits directive, default base class + var project = CreateTestProject(new() + { + ["Pages/Index.cshtml"] = """ + @page +

Hello World

+ """, + }); + + var compilation = await project.GetCompilationAsync(); + var driver = await GetDriverAsync(project); + + // Act + var result = RunGenerator(compilation!, ref driver, out _); + + // Assert - default base classes don't support UTF-8 WriteLiteral + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedSources); + Assert.DoesNotContain("u8)", result.GeneratedSources[0].SourceText.ToString()); + } } diff --git a/src/Compiler/test/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/TestFiles/RazorSourceGeneratorCshtmlTests/Utf8HtmlLiterals_AutoDetectedFromInherits/Pages/Index_cshtml.g.cs b/src/Compiler/test/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/TestFiles/RazorSourceGeneratorCshtmlTests/Utf8HtmlLiterals_AutoDetectedFromInherits/Pages/Index_cshtml.g.cs new file mode 100644 index 00000000000..33f758b023f --- /dev/null +++ b/src/Compiler/test/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/TestFiles/RazorSourceGeneratorCshtmlTests/Utf8HtmlLiterals_AutoDetectedFromInherits/Pages/Index_cshtml.g.cs @@ -0,0 +1,59 @@ +#pragma checksum "Pages/Index.cshtml" "{ff1816ec-aa5e-4d10-87f7-6f4963833460}" "9d8afc5c1b11134bb445da5b3415042369d58dd7" +// +#pragma warning disable 1591 +[assembly: global::Microsoft.AspNetCore.Razor.Hosting.RazorCompiledItemAttribute(typeof(AspNetCoreGeneratedDocument.Pages_Index), @"mvc.1.0.view", @"/Pages/Index.cshtml")] +namespace AspNetCoreGeneratedDocument +{ + #line default + using global::System; + using global::System.Collections.Generic; + using global::System.Linq; + using global::System.Threading.Tasks; + using global::Microsoft.AspNetCore.Mvc; + using global::Microsoft.AspNetCore.Mvc.Rendering; + using global::Microsoft.AspNetCore.Mvc.ViewFeatures; + #line default + #line hidden + [global::Microsoft.AspNetCore.Razor.Hosting.RazorCompiledItemMetadataAttribute("Identifier", "/Pages/Index.cshtml")] + [global::System.Runtime.CompilerServices.CreateNewOnMetadataUpdateAttribute] + #nullable restore + internal sealed class Pages_Index : +#nullable restore +#line (1,11)-(1,25) "Pages/Index.cshtml" +MyUtf8PageBase + +#line default +#line hidden +#nullable disable + + #nullable disable + { + #pragma warning disable 1998 + public async override global::System.Threading.Tasks.Task ExecuteAsync() + { + WriteLiteral("

Hello World

"u8); + } + #pragma warning restore 1998 + #nullable restore + [global::Microsoft.AspNetCore.Mvc.Razor.Internal.RazorInjectAttribute] + public global::Microsoft.AspNetCore.Mvc.ViewFeatures.IModelExpressionProvider ModelExpressionProvider { get; private set; } = default!; + #nullable disable + #nullable restore + [global::Microsoft.AspNetCore.Mvc.Razor.Internal.RazorInjectAttribute] + public global::Microsoft.AspNetCore.Mvc.IUrlHelper Url { get; private set; } = default!; + #nullable disable + #nullable restore + [global::Microsoft.AspNetCore.Mvc.Razor.Internal.RazorInjectAttribute] + public global::Microsoft.AspNetCore.Mvc.IViewComponentHelper Component { get; private set; } = default!; + #nullable disable + #nullable restore + [global::Microsoft.AspNetCore.Mvc.Razor.Internal.RazorInjectAttribute] + public global::Microsoft.AspNetCore.Mvc.Rendering.IJsonHelper Json { get; private set; } = default!; + #nullable disable + #nullable restore + [global::Microsoft.AspNetCore.Mvc.Razor.Internal.RazorInjectAttribute] + public global::Microsoft.AspNetCore.Mvc.Rendering.IHtmlHelper Html { get; private set; } = default!; + #nullable disable + } +} +#pragma warning restore 1591 diff --git a/src/Compiler/test/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/TestFiles/RazorSourceGeneratorCshtmlTests/Utf8HtmlLiterals_WithoutOverload_UsesStringLiterals/Pages/Index_cshtml.g.cs b/src/Compiler/test/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/TestFiles/RazorSourceGeneratorCshtmlTests/Utf8HtmlLiterals_WithoutOverload_UsesStringLiterals/Pages/Index_cshtml.g.cs new file mode 100644 index 00000000000..ed8f3e05886 --- /dev/null +++ b/src/Compiler/test/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/TestFiles/RazorSourceGeneratorCshtmlTests/Utf8HtmlLiterals_WithoutOverload_UsesStringLiterals/Pages/Index_cshtml.g.cs @@ -0,0 +1,59 @@ +#pragma checksum "Pages/Index.cshtml" "{ff1816ec-aa5e-4d10-87f7-6f4963833460}" "c62489988165175ecfbb0389a7f80500ae401ed2" +// +#pragma warning disable 1591 +[assembly: global::Microsoft.AspNetCore.Razor.Hosting.RazorCompiledItemAttribute(typeof(AspNetCoreGeneratedDocument.Pages_Index), @"mvc.1.0.view", @"/Pages/Index.cshtml")] +namespace AspNetCoreGeneratedDocument +{ + #line default + using global::System; + using global::System.Collections.Generic; + using global::System.Linq; + using global::System.Threading.Tasks; + using global::Microsoft.AspNetCore.Mvc; + using global::Microsoft.AspNetCore.Mvc.Rendering; + using global::Microsoft.AspNetCore.Mvc.ViewFeatures; + #line default + #line hidden + [global::Microsoft.AspNetCore.Razor.Hosting.RazorCompiledItemMetadataAttribute("Identifier", "/Pages/Index.cshtml")] + [global::System.Runtime.CompilerServices.CreateNewOnMetadataUpdateAttribute] + #nullable restore + internal sealed class Pages_Index : +#nullable restore +#line (1,11)-(1,21) "Pages/Index.cshtml" +MyPageBase + +#line default +#line hidden +#nullable disable + + #nullable disable + { + #pragma warning disable 1998 + public async override global::System.Threading.Tasks.Task ExecuteAsync() + { + WriteLiteral("

Hello World

"); + } + #pragma warning restore 1998 + #nullable restore + [global::Microsoft.AspNetCore.Mvc.Razor.Internal.RazorInjectAttribute] + public global::Microsoft.AspNetCore.Mvc.ViewFeatures.IModelExpressionProvider ModelExpressionProvider { get; private set; } = default!; + #nullable disable + #nullable restore + [global::Microsoft.AspNetCore.Mvc.Razor.Internal.RazorInjectAttribute] + public global::Microsoft.AspNetCore.Mvc.IUrlHelper Url { get; private set; } = default!; + #nullable disable + #nullable restore + [global::Microsoft.AspNetCore.Mvc.Razor.Internal.RazorInjectAttribute] + public global::Microsoft.AspNetCore.Mvc.IViewComponentHelper Component { get; private set; } = default!; + #nullable disable + #nullable restore + [global::Microsoft.AspNetCore.Mvc.Razor.Internal.RazorInjectAttribute] + public global::Microsoft.AspNetCore.Mvc.Rendering.IJsonHelper Json { get; private set; } = default!; + #nullable disable + #nullable restore + [global::Microsoft.AspNetCore.Mvc.Razor.Internal.RazorInjectAttribute] + public global::Microsoft.AspNetCore.Mvc.Rendering.IHtmlHelper Html { get; private set; } = default!; + #nullable disable + } +} +#pragma warning restore 1591 diff --git a/src/Shared/Microsoft.AspNetCore.Razor.Test.Common/Language/CodeGeneration/TestCodeRenderingContext.cs b/src/Shared/Microsoft.AspNetCore.Razor.Test.Common/Language/CodeGeneration/TestCodeRenderingContext.cs index 870132e673c..32c16f09e71 100644 --- a/src/Shared/Microsoft.AspNetCore.Razor.Test.Common/Language/CodeGeneration/TestCodeRenderingContext.cs +++ b/src/Shared/Microsoft.AspNetCore.Razor.Test.Common/Language/CodeGeneration/TestCodeRenderingContext.cs @@ -31,7 +31,8 @@ public static CodeRenderingContext CreateRuntime( string newLineString = null, string suppressUniqueIds = "test", RazorSourceDocument source = null, - IntermediateNodeWriter nodeWriter = null) + IntermediateNodeWriter nodeWriter = null, + bool writeHtmlUtf8StringLiterals = false) { nodeWriter ??= RuntimeNodeWriter.Instance; source ??= TestRazorSourceDocument.Create(); @@ -39,6 +40,11 @@ public static CodeRenderingContext CreateRuntime( var options = ConfigureOptions(RazorCodeGenerationOptions.Default, newLineString, suppressUniqueIds); + if (writeHtmlUtf8StringLiterals) + { + options = options.WithFlags(writeHtmlUtf8StringLiterals: true); + } + var context = new CodeRenderingContext(nodeWriter, source, documentNode, options); context.SetVisitor(new RenderChildrenVisitor(context.CodeWriter)); From 9cab3b2d2d6d2a37a91dedc2ec2c963e9ae0cf7b Mon Sep 17 00:00:00 2001 From: Chris Sienkiewicz Date: Tue, 14 Apr 2026 21:23:39 -0700 Subject: [PATCH 2/9] Add @inherits qualification and incremental switching tests - FullyQualifiedInherits: namespaced type with fully-qualified @inherits - ShortNameInherits_WithUsing: documents that short names don't resolve for UTF-8 detection (GetTypeByMetadataName requires full qualification) - PartiallyQualifiedInherits: documents partial names don't resolve - SwitchesWhenOverloadAddedOrRemoved: uses fresh drivers per edit step Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../RazorSourceGeneratorCshtmlTests.cs | 142 +++++++++++++++++- 1 file changed, 140 insertions(+), 2 deletions(-) diff --git a/src/Compiler/test/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/RazorSourceGeneratorCshtmlTests.cs b/src/Compiler/test/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/RazorSourceGeneratorCshtmlTests.cs index 4bed19e1d83..3f8af0559d6 100644 --- a/src/Compiler/test/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/RazorSourceGeneratorCshtmlTests.cs +++ b/src/Compiler/test/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/RazorSourceGeneratorCshtmlTests.cs @@ -354,7 +354,8 @@ public void WriteLiteral(ReadOnlySpan utf8HtmlLiteral) """, Encoding.UTF8)).Project; compilation = await project.GetCompilationAsync(); - result = RunGenerator(compilation!, ref driver, out _); + driver = await GetDriverAsync(project); + result = RunGenerator(compilation!, ref driver, out _, _ => { }); // Assert - should now use UTF-8 literals Assert.Empty(result.Diagnostics); @@ -373,7 +374,8 @@ public abstract class MyPageBase : RazorPage """, Encoding.UTF8)).Project; compilation = await project.GetCompilationAsync(); - result = RunGenerator(compilation!, ref driver, out _); + driver = await GetDriverAsync(project); + result = RunGenerator(compilation!, ref driver, out _, _ => { }); // Assert - should switch back to string literals Assert.Empty(result.Diagnostics); @@ -404,4 +406,140 @@ public async Task Utf8HtmlLiterals_NoInheritsDirective_UsesStringLiterals() Assert.Single(result.GeneratedSources); Assert.DoesNotContain("u8)", result.GeneratedSources[0].SourceText.ToString()); } + + [Fact, WorkItem("https://github.com/dotnet/razor/issues/8429")] + public async Task Utf8HtmlLiterals_FullyQualifiedInherits() + { + // Arrange - @inherits with fully-qualified type name + var project = CreateTestProject( + additionalSources: new() + { + ["Pages/Index.cshtml"] = """ + @inherits MyApp.Infrastructure.MyUtf8PageBase +

Hello World

+ """, + }, + sources: new() + { + ["MyUtf8PageBase.cs"] = """ + using System; + using Microsoft.AspNetCore.Mvc.Razor; + + namespace MyApp.Infrastructure + { + public abstract class MyUtf8PageBase : RazorPage + { + public void WriteLiteral(ReadOnlySpan utf8HtmlLiteral) + { + WriteLiteral(System.Text.Encoding.UTF8.GetString(utf8HtmlLiteral)); + } + } + } + """ + }); + + var compilation = await project.GetCompilationAsync(); + var driver = await GetDriverAsync(project); + + // Act + var result = RunGenerator(compilation!, ref driver, out _); + + // Assert - fully-qualified name resolves correctly + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedSources); + Assert.Contains("u8)", result.GeneratedSources[0].SourceText.ToString()); + } + + [Fact, WorkItem("https://github.com/dotnet/razor/issues/8429")] + public async Task Utf8HtmlLiterals_ShortNameInherits_WithUsing() + { + // Arrange - @inherits with short name, namespace imported via _ViewImports + var project = CreateTestProject( + additionalSources: new() + { + ["Pages/_ViewImports.cshtml"] = """ + @using MyApp.Infrastructure + """, + ["Pages/Index.cshtml"] = """ + @inherits MyUtf8PageBase +

Hello World

+ """, + }, + sources: new() + { + ["MyUtf8PageBase.cs"] = """ + using System; + using Microsoft.AspNetCore.Mvc.Razor; + + namespace MyApp.Infrastructure + { + public abstract class MyUtf8PageBase : RazorPage + { + public void WriteLiteral(ReadOnlySpan utf8HtmlLiteral) + { + WriteLiteral(System.Text.Encoding.UTF8.GetString(utf8HtmlLiteral)); + } + } + } + """ + }); + + var compilation = await project.GetCompilationAsync(); + var driver = await GetDriverAsync(project); + + // Act + var result = RunGenerator(compilation!, ref driver, out _); + + // Assert - short name with @using: the @inherits value is "MyUtf8PageBase" which + // won't match the metadata name "MyApp.Infrastructure.MyUtf8PageBase". + // UTF-8 detection only works with fully-qualified @inherits for now. + Assert.Empty(result.Diagnostics); + var indexSource = result.GeneratedSources.Single(s => s.HintName.Contains("Index")).SourceText.ToString(); + Assert.DoesNotContain("u8)", indexSource); + } + + [Fact, WorkItem("https://github.com/dotnet/razor/issues/8429")] + public async Task Utf8HtmlLiterals_PartiallyQualifiedInherits() + { + // Arrange - @inherits with partially-qualified name + // Note: Razor requires fully-qualified names in @inherits, so this produces a compile error. + // This test documents that we gracefully handle this case (no UTF-8 detection). + var project = CreateTestProject( + additionalSources: new() + { + ["Pages/Index.cshtml"] = """ + @inherits Infrastructure.MyUtf8PageBase +

Hello World

+ """, + }, + sources: new() + { + ["MyUtf8PageBase.cs"] = """ + using System; + using Microsoft.AspNetCore.Mvc.Razor; + + namespace MyApp.Infrastructure + { + public abstract class MyUtf8PageBase : RazorPage + { + public void WriteLiteral(ReadOnlySpan utf8HtmlLiteral) + { + WriteLiteral(System.Text.Encoding.UTF8.GetString(utf8HtmlLiteral)); + } + } + } + """ + }); + + var compilation = await project.GetCompilationAsync(); + var driver = await GetDriverAsync(project); + + // Act + var result = RunGenerator(compilation!, ref driver, out _, _ => { }); + + // Assert - partially-qualified name won't match the metadata name. + Assert.Empty(result.Diagnostics); + var indexSource = result.GeneratedSources.Single(s => s.HintName.Contains("Index")).SourceText.ToString(); + Assert.DoesNotContain("u8)", indexSource); + } } From 87a5210c09bcd3686d78eba7bca60f25e807cbd7 Mon Sep 17 00:00:00 2001 From: Chris Sienkiewicz Date: Wed, 15 Apr 2026 14:57:44 -0700 Subject: [PATCH 3/9] Add short name and alias resolution for @inherits UTF-8 detection - Add GetInheritsDirectiveContent and GetUsingDirectives extension methods on RazorCodeDocument for extracting @inherits and @using directives - Resolve short/aliased type names via augmented compilation with the document's @using directives when GetTypeByMetadataName fails - Dual-lookup Utf8SupportMap: per-file (filePath -> FQN) + per-type (FQN -> bool) to handle same @inherits text resolving differently - Use GetFullName() for metadata name formatting - Call HasCallableUtf8WriteLiteralOverload via string overload to avoid cross-compilation symbol issues - Add InheritsInfo nested record on DefaultUtf8WriteLiteralFeature - Tests: short name with @using, alias via _ViewImports, file-level alias, alias shadowing (CS0576 graceful fallback), fully-qualified Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CodeGenerationIntegrationTest.cs | 1 + .../CSharp/DefaultUtf8WriteLiteralFeature.cs | 150 ++++++++++++--- .../src/CSharp/IUtf8WriteLiteralFeature.cs | 7 +- .../CSharp/Utf8WriteLiteralDetectionPass.cs | 2 +- .../Language/RazorCodeDocumentExtensions.cs | 48 ++++- .../SourceGenerators/RazorSourceGenerator.cs | 13 +- .../RazorSourceGeneratorCshtmlTests.cs | 177 +++++++++++++++++- 7 files changed, 362 insertions(+), 36 deletions(-) diff --git a/src/Compiler/Microsoft.AspNetCore.Mvc.Razor.Extensions/test/IntegrationTests/CodeGenerationIntegrationTest.cs b/src/Compiler/Microsoft.AspNetCore.Mvc.Razor.Extensions/test/IntegrationTests/CodeGenerationIntegrationTest.cs index 0c91d7d70d3..d23b687ad9f 100644 --- a/src/Compiler/Microsoft.AspNetCore.Mvc.Razor.Extensions/test/IntegrationTests/CodeGenerationIntegrationTest.cs +++ b/src/Compiler/Microsoft.AspNetCore.Mvc.Razor.Extensions/test/IntegrationTests/CodeGenerationIntegrationTest.cs @@ -40,6 +40,7 @@ protected override void ConfigureProjectEngine(RazorProjectEngineBuilder builder // Register the UTF-8 WriteLiteral feature with a pre-computed support map. var supportMap = new DefaultUtf8WriteLiteralFeature.Utf8SupportMap( + ImmutableSortedDictionary.Empty, ImmutableSortedDictionary.CreateRange(StringComparer.Ordinal, new[] { new KeyValuePair("MyUtf8PageBase", true), diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/DefaultUtf8WriteLiteralFeature.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/DefaultUtf8WriteLiteralFeature.cs index b9a1515f832..c172481c7e9 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/DefaultUtf8WriteLiteralFeature.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/DefaultUtf8WriteLiteralFeature.cs @@ -2,10 +2,13 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; +using System.Text; using Microsoft.AspNetCore.Razor.Language; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Razor; namespace Microsoft.CodeAnalysis.Razor.Compiler.CSharp; @@ -23,6 +26,11 @@ internal sealed class DefaultUtf8WriteLiteralFeature : IUtf8WriteLiteralFeature { private RazorEngine? _engine; + /// + /// Information about an @inherits directive extracted from a parsed document. + /// + internal readonly record struct InheritsInfo(string FilePath, string BaseTypeName, ImmutableArray Usings); + public RazorEngine Engine { get => _engine!; @@ -36,46 +44,133 @@ public void Initialize(RazorEngine engine) _engine = engine; } - public bool IsSupported(string baseTypeName) - => SupportMap.IsSupported(baseTypeName); + public bool IsSupported(string? filePath, string baseTypeName) + => SupportMap.IsSupported(filePath, baseTypeName); /// - /// A value-comparable map of base type names to whether they support UTF-8 WriteLiteral. - /// Used to flow pre-computed results through the incremental pipeline without carrying a - /// reference. + /// A value-comparable map that determines whether a file's @inherits base type supports + /// UTF-8 WriteLiteral. Uses a two-level lookup: + /// + /// Per-file: maps (filePath, rawInheritsText) to a fully-qualified type name + /// Per-type: maps fully-qualified type name to + /// + /// This handles cases where the same @inherits text resolves to different types + /// in different files (e.g., via @using aliases). /// internal sealed class Utf8SupportMap : IEquatable { - public static readonly Utf8SupportMap Empty = new(ImmutableSortedDictionary.Empty); - - private readonly ImmutableSortedDictionary _entries; - - internal Utf8SupportMap(ImmutableSortedDictionary entries) + public static readonly Utf8SupportMap Empty = new( + ImmutableSortedDictionary.Empty, + ImmutableSortedDictionary.Empty); + + // filePath -> fully-qualified type name + private readonly ImmutableSortedDictionary _fileToType; + // fully-qualified type name -> supports UTF-8 + private readonly ImmutableSortedDictionary _typeSupport; + + internal Utf8SupportMap( + ImmutableSortedDictionary fileToType, + ImmutableSortedDictionary typeSupport) { - _entries = entries; + _fileToType = fileToType; + _typeSupport = typeSupport; } /// - /// Builds a by checking each base type name against the compilation. - /// Null and duplicate entries are filtered out. + /// Builds a by resolving each file's @inherits to a + /// fully-qualified type name, then checking whether each unique type supports UTF-8. /// - public static Utf8SupportMap Create(ImmutableArray baseTypeNames, Compilation compilation) + public static Utf8SupportMap Create(ImmutableArray inheritsInfos, Compilation compilation) { - var builder = ImmutableSortedDictionary.CreateBuilder(StringComparer.Ordinal); + var fileToType = ImmutableSortedDictionary.CreateBuilder(StringComparer.OrdinalIgnoreCase); + var typeSupport = ImmutableSortedDictionary.CreateBuilder(StringComparer.Ordinal); - foreach (var name in baseTypeNames) + foreach (var info in inheritsInfos) { - if (!string.IsNullOrEmpty(name) && !builder.ContainsKey(name)) + var filePath = info.FilePath; + var baseTypeName = info.BaseTypeName; + + // Fast path: try fully-qualified metadata name lookup. + var type = compilation.GetTypeByMetadataName(baseTypeName); + string? fqn; + if (type is null || type.TypeKind == TypeKind.Error) + { + // Slow path: use the document's @using directives to resolve short names + // via an augmented compilation. + fqn = ResolveTypeNameWithUsings(baseTypeName, info.Usings, compilation); + } + else + { + fqn = type.GetFullName(); + } + + if (fqn is null) + { + continue; + } + + fileToType[filePath] = fqn; + + if (!typeSupport.ContainsKey(fqn)) { - builder[name] = compilation.HasCallableUtf8WriteLiteralOverload(name); + typeSupport[fqn] = compilation.HasCallableUtf8WriteLiteralOverload(fqn); } } - return builder.Count == 0 ? Empty : new Utf8SupportMap(builder.ToImmutable()); + return fileToType.Count == 0 + ? Empty + : new Utf8SupportMap(fileToType.ToImmutable(), typeSupport.ToImmutable()); + } + + /// + /// Resolves a short or partially-qualified type name to a fully-qualified metadata name + /// using the document's @using directives and an augmented compilation. + /// + private static string? ResolveTypeNameWithUsings( + string typeName, + ImmutableArray usings, + Compilation compilation) + { + if (compilation is not CSharpCompilation csharpCompilation || usings.IsEmpty) + { + return null; + } + + var sb = new StringBuilder(); + foreach (var u in usings) + { + sb.Append("using ").Append(u).AppendLine(";"); + } + + sb.Append("class __Utf8Probe__ : ").Append(typeName).AppendLine(" { }"); + + var parseOptions = csharpCompilation.SyntaxTrees.FirstOrDefault()?.Options as CSharpParseOptions + ?? CSharpParseOptions.Default; + var probeTree = CSharpSyntaxTree.ParseText(sb.ToString(), parseOptions); + + var augmented = csharpCompilation.AddSyntaxTrees(probeTree); + var semanticModel = augmented.GetSemanticModel(probeTree); + var classDecl = probeTree.GetRoot().DescendantNodes().OfType().FirstOrDefault(); + var baseTypeSyntax = classDecl?.BaseList?.Types.FirstOrDefault(); + + if (baseTypeSyntax is null) + { + return null; + } + + return (semanticModel.GetSymbolInfo(baseTypeSyntax.Type).Symbol as INamedTypeSymbol)?.GetFullName(); } - public bool IsSupported(string baseTypeName) - => _entries.TryGetValue(baseTypeName, out var supported) && supported; + public bool IsSupported(string? filePath, string baseTypeName) + { + if (filePath is not null && _fileToType.TryGetValue(filePath, out var fqn)) + { + return _typeSupport.TryGetValue(fqn, out var supported) && supported; + } + + // Fallback: try the raw name directly as a fully-qualified name. + return _typeSupport.TryGetValue(baseTypeName, out var fallback) && fallback; + } public bool Equals(Utf8SupportMap? other) { @@ -89,7 +184,8 @@ public bool Equals(Utf8SupportMap? other) return true; } - return _entries.SequenceEqual(other._entries); + return _fileToType.SequenceEqual(other._fileToType) && + _typeSupport.SequenceEqual(other._typeSupport); } public override bool Equals(object? obj) => Equals(obj as Utf8SupportMap); @@ -98,7 +194,13 @@ public override int GetHashCode() { var hash = 17; - foreach (var kvp in _entries) + foreach (var kvp in _fileToType) + { + hash = hash * 31 + StringComparer.OrdinalIgnoreCase.GetHashCode(kvp.Key); + hash = hash * 31 + StringComparer.Ordinal.GetHashCode(kvp.Value); + } + + foreach (var kvp in _typeSupport) { hash = hash * 31 + StringComparer.Ordinal.GetHashCode(kvp.Key); hash = hash * 31 + kvp.Value.GetHashCode(); diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/IUtf8WriteLiteralFeature.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/IUtf8WriteLiteralFeature.cs index 24d0e6d0adc..0ed06885921 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/IUtf8WriteLiteralFeature.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/IUtf8WriteLiteralFeature.cs @@ -12,9 +12,10 @@ namespace Microsoft.CodeAnalysis.Razor.Compiler.CSharp; internal interface IUtf8WriteLiteralFeature : IRazorEngineFeature { /// - /// Returns if the specified base type has a callable + /// Returns if the base type used by the specified file has a callable /// WriteLiteral(ReadOnlySpan<byte>) overload. /// - /// The fully-qualified metadata name of the base type to check. - bool IsSupported(string baseTypeName); + /// The file path of the Razor document. + /// The raw @inherits value from the document. + bool IsSupported(string? filePath, string baseTypeName); } diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/Utf8WriteLiteralDetectionPass.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/Utf8WriteLiteralDetectionPass.cs index 9b2db316b9e..b649c230cf9 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/Utf8WriteLiteralDetectionPass.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/Utf8WriteLiteralDetectionPass.cs @@ -41,7 +41,7 @@ documentNode.Options is null || } var baseTypeName = baseType.BaseType.Content; - if (_utf8Feature.IsSupported(baseTypeName)) + if (_utf8Feature.IsSupported(codeDocument.Source.FilePath, baseTypeName)) { documentNode.Options = documentNode.Options.WithFlags(writeHtmlUtf8StringLiterals: true); } diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorCodeDocumentExtensions.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorCodeDocumentExtensions.cs index 00f0ce531cc..9699594d700 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorCodeDocumentExtensions.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorCodeDocumentExtensions.cs @@ -2,6 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Collections.Generic; +using System.Collections.Immutable; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.IO; @@ -48,7 +50,7 @@ internal static bool IsImportsFile(this RazorCodeDocument codeDocument) /// Returns the content of the @inherits directive if present in a legacy .cshtml /// document's syntax tree, or for non-legacy files or when absent. /// - internal static string? GetInheritsDirectiveContent(this RazorCodeDocument codeDocument) + internal static string? GetInheritsDirectiveValue(this RazorCodeDocument codeDocument) { if (!codeDocument.FileKind.IsLegacy()) { @@ -77,6 +79,50 @@ internal static bool IsImportsFile(this RazorCodeDocument codeDocument) return null; } + /// + /// Returns all @using directives from the document and its import files. + /// + internal static ImmutableArray GetUsingDirectives(this RazorCodeDocument codeDocument) + { + var syntaxTree = codeDocument.GetSyntaxTree(); + if (syntaxTree is null) + { + return []; + } + + var usings = new List(); + CollectUsings(syntaxTree, usings); + + if (codeDocument.TryGetImportSyntaxTrees(out var importSyntaxTrees)) + { + foreach (var importTree in importSyntaxTrees) + { + CollectUsings(importTree, usings); + } + } + + return [.. usings]; + + static void CollectUsings(RazorSyntaxTree tree, List usings) + { + foreach (var node in tree.Root.DescendantNodes()) + { + if (node is RazorUsingDirectiveSyntax usingDirective) + { + var content = usingDirective.Body?.GetContent()?.Trim(); + if (content is not null && content.StartsWith("using ", StringComparison.Ordinal)) + { + var ns = content.Substring("using ".Length).TrimEnd(';').Trim(); + if (ns.Length > 0) + { + usings.Add(ns); + } + } + } + } + } + } + /// /// Returns whether the directive specified was involved in tag helper binding /// diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/RazorSourceGenerator.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/RazorSourceGenerator.cs index 7e1038e0bae..8026d7af953 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/RazorSourceGenerator.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/RazorSourceGenerator.cs @@ -269,13 +269,20 @@ public void Initialize(IncrementalGeneratorInitializationContext context) // Build a map of which @inherits base types support UTF-8 WriteLiteral. var utf8SupportMap = parsedDocuments - .Select(static (item, _) => item.Item3.CodeDocument.GetInheritsDirectiveContent()) + .Select(static (item, _) => + { + var codeDocument = item.Item3.CodeDocument; + return (codeDocument, InheritsValue: codeDocument.GetInheritsDirectiveValue()); + }) + .Where(static item => item.InheritsValue is not null) + .Select(static (item, _) => new DefaultUtf8WriteLiteralFeature.InheritsInfo( + item.codeDocument.Source.FilePath ?? string.Empty, item.InheritsValue!, item.codeDocument.GetUsingDirectives())) .Collect() .Combine(declCompilation) .Select(static (pair, _) => { - var (baseTypeNames, compilation) = pair; - return DefaultUtf8WriteLiteralFeature.Utf8SupportMap.Create(baseTypeNames, compilation); + var (inheritsInfos, compilation) = pair; + return DefaultUtf8WriteLiteralFeature.Utf8SupportMap.Create(inheritsInfos, compilation); }) .WithTrackingName("Utf8SupportMap"); diff --git a/src/Compiler/test/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/RazorSourceGeneratorCshtmlTests.cs b/src/Compiler/test/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/RazorSourceGeneratorCshtmlTests.cs index 3f8af0559d6..6b8aed5fa74 100644 --- a/src/Compiler/test/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/RazorSourceGeneratorCshtmlTests.cs +++ b/src/Compiler/test/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/RazorSourceGeneratorCshtmlTests.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System; +using System.Collections.Immutable; using System.Linq; using System.Text; using System.Threading.Tasks; @@ -490,12 +492,10 @@ public void WriteLiteral(ReadOnlySpan utf8HtmlLiteral) // Act var result = RunGenerator(compilation!, ref driver, out _); - // Assert - short name with @using: the @inherits value is "MyUtf8PageBase" which - // won't match the metadata name "MyApp.Infrastructure.MyUtf8PageBase". - // UTF-8 detection only works with fully-qualified @inherits for now. + // Assert - short name resolves via augmented compilation with the @using directives Assert.Empty(result.Diagnostics); var indexSource = result.GeneratedSources.Single(s => s.HintName.Contains("Index")).SourceText.ToString(); - Assert.DoesNotContain("u8)", indexSource); + Assert.Contains("u8)", indexSource); } [Fact, WorkItem("https://github.com/dotnet/razor/issues/8429")] @@ -542,4 +542,173 @@ public void WriteLiteral(ReadOnlySpan utf8HtmlLiteral) var indexSource = result.GeneratedSources.Single(s => s.HintName.Contains("Index")).SourceText.ToString(); Assert.DoesNotContain("u8)", indexSource); } + + [Fact, WorkItem("https://github.com/dotnet/razor/issues/8429")] + public async Task Utf8HtmlLiterals_AliasedInherits_WithUsing() + { + // Arrange - @inherits with a type alias defined via @using alias in _ViewImports + var project = CreateTestProject( + additionalSources: new() + { + ["Pages/_ViewImports.cshtml"] = """ + @using Utf8Base = MyApp.Infrastructure.MyUtf8PageBase + """, + ["Pages/Index.cshtml"] = """ + @inherits Utf8Base +

Hello World

+ """, + }, + sources: new() + { + ["MyUtf8PageBase.cs"] = """ + using System; + using Microsoft.AspNetCore.Mvc.Razor; + + namespace MyApp.Infrastructure + { + public abstract class MyUtf8PageBase : RazorPage + { + public void WriteLiteral(ReadOnlySpan utf8HtmlLiteral) + { + WriteLiteral(System.Text.Encoding.UTF8.GetString(utf8HtmlLiteral)); + } + } + } + """ + }); + + var compilation = await project.GetCompilationAsync(); + var driver = await GetDriverAsync(project); + + // Act + var result = RunGenerator(compilation!, ref driver, out _); + + // Assert - alias resolves via augmented compilation + Assert.Empty(result.Diagnostics); + var indexSource = result.GeneratedSources.Single(s => s.HintName.Contains("Index")).SourceText.ToString(); + Assert.Contains("u8)", indexSource); + } + + [Fact, WorkItem("https://github.com/dotnet/razor/issues/8429")] + public async Task Utf8HtmlLiterals_AliasShadowsExistingType_GracefulFallback() + { + // Arrange - "MyPageBase" exists as a non-UTF-8 type in the global namespace. + // AliasedPage uses "@using MyPageBase = MyApp.Infrastructure.MyUtf8PageBase" + // which creates a C# compile error (CS0576: definition conflicting with alias). + // This test verifies we handle this gracefully — no crash, falls back to string literals. + var project = CreateTestProject( + additionalSources: new() + { + ["Pages/RegularPage.cshtml"] = """ + @inherits MyPageBase +

Regular Page

+ """, + ["Pages/AliasedPage.cshtml"] = """ + @using MyPageBase = MyApp.Infrastructure.MyUtf8PageBase + @inherits MyPageBase +

Aliased Page

+ """, + }, + sources: new() + { + ["MyPageBase.cs"] = """ + using Microsoft.AspNetCore.Mvc.Razor; + + public abstract class MyPageBase : RazorPage + { + } + """, + ["MyUtf8PageBase.cs"] = """ + using System; + using Microsoft.AspNetCore.Mvc.Razor; + + namespace MyApp.Infrastructure + { + public abstract class MyUtf8PageBase : RazorPage + { + public void WriteLiteral(ReadOnlySpan utf8HtmlLiteral) + { + WriteLiteral(System.Text.Encoding.UTF8.GetString(utf8HtmlLiteral)); + } + } + } + """ + }); + + var compilation = await project.GetCompilationAsync(); + var driver = await GetDriverAsync(project); + + // Act + var result = RunGenerator(compilation!, ref driver, out _); + + // Assert - no generator crash. The alias conflicts with the global type (CS0576), + // so both files fall back to string literals. This is correct behavior since the + // user's code has a compile error. + Assert.Empty(result.Diagnostics); + + var regularSource = result.GeneratedSources.Single(s => s.HintName.Contains("RegularPage")).SourceText.ToString(); + var aliasedSource = result.GeneratedSources.Single(s => s.HintName.Contains("AliasedPage")).SourceText.ToString(); + + Assert.DoesNotContain("u8)", regularSource); + Assert.DoesNotContain("u8)", aliasedSource); + } + + [Fact, WorkItem("https://github.com/dotnet/razor/issues/8429")] + public async Task Utf8HtmlLiterals_AddingUsingMakesShortNameResolve() + { + // Arrange - @inherits uses a short name that doesn't resolve without a @using + var project = CreateTestProject( + additionalSources: new() + { + ["Pages/Index.cshtml"] = """ + @inherits MyUtf8PageBase +

Hello World

+ """, + }, + sources: new() + { + ["MyUtf8PageBase.cs"] = """ + using System; + using Microsoft.AspNetCore.Mvc.Razor; + + namespace MyApp.Infrastructure + { + public abstract class MyUtf8PageBase : RazorPage + { + public void WriteLiteral(ReadOnlySpan utf8HtmlLiteral) + { + WriteLiteral(System.Text.Encoding.UTF8.GetString(utf8HtmlLiteral)); + } + } + } + """ + }); + + var compilation = await project.GetCompilationAsync(); + var (driver, additionalTexts, optionsProvider) = await GetDriverWithAdditionalTextAndProviderAsync(project); + + // Act 1 - short name doesn't resolve, falls back to string literals + var result = RunGenerator(compilation!, ref driver, out _, _ => { }); + + Assert.Empty(result.Diagnostics); + Assert.Single(result.GeneratedSources); + Assert.DoesNotContain("u8)", result.GeneratedSources[0].SourceText.ToString()); + + // Act 2 - add a _ViewImports with @using that makes the short name resolve + var viewImports = new TestAdditionalText("Pages/_ViewImports.cshtml", + SourceText.From("@using MyApp.Infrastructure", Encoding.UTF8)); + driver = driver.AddAdditionalTexts([viewImports]); + optionsProvider.AdditionalTextOptions[viewImports.Path] = new TestAnalyzerConfigOptions + { + ["build_metadata.AdditionalFiles.TargetPath"] = Convert.ToBase64String(Encoding.UTF8.GetBytes(viewImports.Path)) + }; + driver = driver.WithUpdatedAnalyzerConfigOptions(optionsProvider); + + result = RunGenerator(compilation!, ref driver, out _, _ => { }); + + // Assert - now the short name resolves and UTF-8 is used + Assert.Empty(result.Diagnostics); + var indexSource = result.GeneratedSources.Single(s => s.HintName.Contains("Index")).SourceText.ToString(); + Assert.Contains("u8)", indexSource); + } } From c6e27661fc00c46160040e44229896155b15eb0a Mon Sep 17 00:00:00 2001 From: Chris Sienkiewicz Date: Wed, 15 Apr 2026 16:34:50 -0700 Subject: [PATCH 4/9] Optimize short name resolution with single-tree probe Build one probe syntax tree with namespace-scoped usings for all entries that need resolution, instead of creating a separate augmented compilation per entry. This reduces O(N) AddSyntaxTrees calls to O(1). - Two-pass Create: fast path via GetTypeByMetadataName, then batch slow path - ResolveTypeNamesWithUsings takes CSharpCompilation directly - Split pipeline: extract @inherits first, then usings only for files that need it - Rename GetInheritsDirectiveContent to GetInheritsDirectiveValue - Make InheritsInfo fields non-nullable Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CSharp/DefaultUtf8WriteLiteralFeature.cs | 121 +++++++++++------- 1 file changed, 78 insertions(+), 43 deletions(-) diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/DefaultUtf8WriteLiteralFeature.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/DefaultUtf8WriteLiteralFeature.cs index c172481c7e9..77e68fea66b 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/DefaultUtf8WriteLiteralFeature.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/DefaultUtf8WriteLiteralFeature.cs @@ -2,7 +2,9 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Collections.Generic; using System.Collections.Immutable; +using System.Diagnostics; using System.Linq; using System.Text; using Microsoft.AspNetCore.Razor.Language; @@ -85,35 +87,47 @@ public static Utf8SupportMap Create(ImmutableArray inheritsInfos, var fileToType = ImmutableSortedDictionary.CreateBuilder(StringComparer.OrdinalIgnoreCase); var typeSupport = ImmutableSortedDictionary.CreateBuilder(StringComparer.Ordinal); - foreach (var info in inheritsInfos) - { - var filePath = info.FilePath; - var baseTypeName = info.BaseTypeName; + // First pass: resolve fully-qualified names via fast path, collect unresolved entries. + List<(int Index, InheritsInfo Info)>? unresolvedEntries = null; - // Fast path: try fully-qualified metadata name lookup. - var type = compilation.GetTypeByMetadataName(baseTypeName); - string? fqn; - if (type is null || type.TypeKind == TypeKind.Error) + for (var i = 0; i < inheritsInfos.Length; i++) + { + var info = inheritsInfos[i]; + var type = compilation.GetTypeByMetadataName(info.BaseTypeName); + if (type is not null && type.TypeKind != TypeKind.Error) { - // Slow path: use the document's @using directives to resolve short names - // via an augmented compilation. - fqn = ResolveTypeNameWithUsings(baseTypeName, info.Usings, compilation); + var fqn = type.GetFullName(); + fileToType[info.FilePath] = fqn; + + if (!typeSupport.ContainsKey(fqn)) + { + typeSupport[fqn] = compilation.HasCallableUtf8WriteLiteralOverload(fqn); + } } else { - fqn = type.GetFullName(); - } - - if (fqn is null) - { - continue; + unresolvedEntries ??= []; + unresolvedEntries.Add((i, info)); } + } - fileToType[filePath] = fqn; - - if (!typeSupport.ContainsKey(fqn)) + // Second pass: resolve remaining entries via a single augmented compilation. + if (unresolvedEntries is { Count: > 0 } && compilation is CSharpCompilation csharpCompilation) + { + var entriesWithUsings = unresolvedEntries.Where(e => !e.Info.Usings.IsEmpty).ToList(); + if (entriesWithUsings.Count > 0) { - typeSupport[fqn] = compilation.HasCallableUtf8WriteLiteralOverload(fqn); + var resolved = ResolveTypeNamesWithUsings(entriesWithUsings, csharpCompilation); + foreach (var (index, fqn) in resolved) + { + var info = inheritsInfos[index]; + fileToType[info.FilePath] = fqn; + + if (!typeSupport.ContainsKey(fqn)) + { + typeSupport[fqn] = compilation.HasCallableUtf8WriteLiteralOverload(fqn); + } + } } } @@ -123,42 +137,63 @@ public static Utf8SupportMap Create(ImmutableArray inheritsInfos, } /// - /// Resolves a short or partially-qualified type name to a fully-qualified metadata name - /// using the document's @using directives and an augmented compilation. + /// Resolves multiple short or partially-qualified type names in a single augmented + /// compilation. Each entry's usings are scoped to a unique namespace block to prevent + /// cross-contamination. /// - private static string? ResolveTypeNameWithUsings( - string typeName, - ImmutableArray usings, - Compilation compilation) + private static List<(int Index, string Fqn)> ResolveTypeNamesWithUsings( + List<(int Index, InheritsInfo Info)> entries, + CSharpCompilation compilation) { - if (compilation is not CSharpCompilation csharpCompilation || usings.IsEmpty) - { - return null; - } + var results = new List<(int, string)>(); + // Build a single probe tree with namespace-scoped usings for each entry. var sb = new StringBuilder(); - foreach (var u in usings) + for (var i = 0; i < entries.Count; i++) { - sb.Append("using ").Append(u).AppendLine(";"); - } + var info = entries[i].Info; + Debug.Assert(!info.Usings.IsEmpty); - sb.Append("class __Utf8Probe__ : ").Append(typeName).AppendLine(" { }"); + sb.Append("namespace __Utf8Probe_").Append(i).AppendLine(" {"); + foreach (var u in info.Usings) + { + sb.Append(" using ").Append(u).AppendLine(";"); + } + + sb.Append(" class __Probe__ : ").Append(info.BaseTypeName).AppendLine(" { }"); + sb.AppendLine("}"); + } - var parseOptions = csharpCompilation.SyntaxTrees.FirstOrDefault()?.Options as CSharpParseOptions + var parseOptions = compilation.SyntaxTrees.FirstOrDefault()?.Options as CSharpParseOptions ?? CSharpParseOptions.Default; var probeTree = CSharpSyntaxTree.ParseText(sb.ToString(), parseOptions); - - var augmented = csharpCompilation.AddSyntaxTrees(probeTree); + var augmented = compilation.AddSyntaxTrees(probeTree); var semanticModel = augmented.GetSemanticModel(probeTree); - var classDecl = probeTree.GetRoot().DescendantNodes().OfType().FirstOrDefault(); - var baseTypeSyntax = classDecl?.BaseList?.Types.FirstOrDefault(); - if (baseTypeSyntax is null) + // Query each probe class's base type. + var namespaceDecls = probeTree.GetRoot().DescendantNodes() + .OfType() + .ToArray(); + + for (var i = 0; i < namespaceDecls.Length; i++) { - return null; + var classDecl = namespaceDecls[i].DescendantNodes() + .OfType() + .FirstOrDefault(); + var baseTypeSyntax = classDecl?.BaseList?.Types.FirstOrDefault(); + if (baseTypeSyntax is null) + { + continue; + } + + var symbol = semanticModel.GetSymbolInfo(baseTypeSyntax.Type).Symbol as INamedTypeSymbol; + if (symbol is not null && symbol.TypeKind != TypeKind.Error) + { + results.Add((entries[i].Index, symbol.GetFullName())); + } } - return (semanticModel.GetSymbolInfo(baseTypeSyntax.Type).Symbol as INamedTypeSymbol)?.GetFullName(); + return results; } public bool IsSupported(string? filePath, string baseTypeName) From 15d4f238eb2c5fe32bb22f250154ede782025d17 Mon Sep 17 00:00:00 2001 From: Chris Sienkiewicz Date: Fri, 17 Apr 2026 11:16:14 -0700 Subject: [PATCH 5/9] Update benchmark baseline nuget package version Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Microsoft.AspNetCore.Razor.Microbenchmarks.Generator.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Compiler/perf/Microsoft.AspNetCore.Razor.Microbenchmarks.Generator/Microsoft.AspNetCore.Razor.Microbenchmarks.Generator.csproj b/src/Compiler/perf/Microsoft.AspNetCore.Razor.Microbenchmarks.Generator/Microsoft.AspNetCore.Razor.Microbenchmarks.Generator.csproj index 0f76248d930..21893ebf1d6 100644 --- a/src/Compiler/perf/Microsoft.AspNetCore.Razor.Microbenchmarks.Generator/Microsoft.AspNetCore.Razor.Microbenchmarks.Generator.csproj +++ b/src/Compiler/perf/Microsoft.AspNetCore.Razor.Microbenchmarks.Generator/Microsoft.AspNetCore.Razor.Microbenchmarks.Generator.csproj @@ -36,7 +36,7 @@ - + From 073e78df6855f2673c2c8ca48a35a633bf90beba Mon Sep 17 00:00:00 2001 From: Chris Sienkiewicz Date: Fri, 17 Apr 2026 16:30:57 -0700 Subject: [PATCH 6/9] Support @inherits from _ViewImports in UTF-8 detection GetInheritsDirectiveValue() now searches import syntax trees when the main document has no @inherits directive. The most specific _ViewImports wins, and the page's own @inherits overrides everything. Added tests for @inherits in _ViewImports (global and namespaced types) and cascading _ViewImports with override precedence. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Language/RazorCodeDocumentExtensions.cs | 41 ++++- .../RazorSourceGeneratorCshtmlTests.cs | 153 ++++++++++++++++++ 2 files changed, 186 insertions(+), 8 deletions(-) diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorCodeDocumentExtensions.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorCodeDocumentExtensions.cs index 9699594d700..15782a6b171 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorCodeDocumentExtensions.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorCodeDocumentExtensions.cs @@ -63,20 +63,45 @@ internal static bool IsImportsFile(this RazorCodeDocument codeDocument) return null; } - foreach (var node in syntaxTree.Root.DescendantNodes()) + // Check the main document first -- its @inherits overrides any from imports. + var inheritsValue = FindInheritsDirective(syntaxTree); + if (inheritsValue is not null) { - if (node is RazorDirectiveSyntax - { - DirectiveDescriptor: var descriptor, - Body: RazorDirectiveBodySyntax { CSharpCode: { } csharpCode } - } && - descriptor == InheritsDirective.Directive) + return inheritsValue; + } + + // Check import syntax trees. The last import's @inherits wins (most specific scope). + if (codeDocument.TryGetImportSyntaxTrees(out var importSyntaxTrees)) + { + for (var i = importSyntaxTrees.Length - 1; i >= 0; i--) { - return csharpCode.GetContent()?.Trim(); + inheritsValue = FindInheritsDirective(importSyntaxTrees[i]); + if (inheritsValue is not null) + { + return inheritsValue; + } } } return null; + + static string? FindInheritsDirective(RazorSyntaxTree tree) + { + foreach (var node in tree.Root.DescendantNodes()) + { + if (node is RazorDirectiveSyntax + { + DirectiveDescriptor: var descriptor, + Body: RazorDirectiveBodySyntax { CSharpCode: { } csharpCode } + } && + descriptor == InheritsDirective.Directive) + { + return csharpCode.GetContent()?.Trim(); + } + } + + return null; + } } /// diff --git a/src/Compiler/test/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/RazorSourceGeneratorCshtmlTests.cs b/src/Compiler/test/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/RazorSourceGeneratorCshtmlTests.cs index 6b8aed5fa74..9a73602e912 100644 --- a/src/Compiler/test/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/RazorSourceGeneratorCshtmlTests.cs +++ b/src/Compiler/test/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/RazorSourceGeneratorCshtmlTests.cs @@ -711,4 +711,157 @@ public void WriteLiteral(ReadOnlySpan utf8HtmlLiteral) var indexSource = result.GeneratedSources.Single(s => s.HintName.Contains("Index")).SourceText.ToString(); Assert.Contains("u8)", indexSource); } + + [Fact, WorkItem("https://github.com/dotnet/razor/issues/8429")] + public async Task Utf8HtmlLiterals_InheritsFromViewImports() + { + // Arrange - @inherits is in _ViewImports.cshtml, not in the page itself + var project = CreateTestProject( + additionalSources: new() + { + ["Pages/_ViewImports.cshtml"] = """ + @inherits MyUtf8PageBase + """, + ["Pages/Index.cshtml"] = """ +

Hello World

+ """, + }, + sources: new() + { + ["MyUtf8PageBase.cs"] = """ + using System; + using Microsoft.AspNetCore.Mvc.Razor; + + public abstract class MyUtf8PageBase : RazorPage + { + public void WriteLiteral(ReadOnlySpan utf8HtmlLiteral) + { + WriteLiteral(System.Text.Encoding.UTF8.GetString(utf8HtmlLiteral)); + } + } + """ + }); + + var compilation = await project.GetCompilationAsync(); + var driver = await GetDriverAsync(project); + + // Act + var result = RunGenerator(compilation!, ref driver, out _); + + // Assert - @inherits from _ViewImports should be detected and UTF-8 used + Assert.Empty(result.Diagnostics); + var indexSource = result.GeneratedSources.Single(s => s.HintName.Contains("Index")).SourceText.ToString(); + Assert.Contains("u8)", indexSource); + } + + [Fact, WorkItem("https://github.com/dotnet/razor/issues/8429")] + public async Task Utf8HtmlLiterals_InheritsFromViewImports_ShortNameWithUsing() + { + // Arrange - @inherits with short name + @using both in _ViewImports.cshtml. + // The page itself has neither directive. + var project = CreateTestProject( + additionalSources: new() + { + ["Pages/_ViewImports.cshtml"] = """ + @using MyApp.Infrastructure + @inherits MyUtf8PageBase + """, + ["Pages/Index.cshtml"] = """ +

Hello World

+ """, + }, + sources: new() + { + ["MyUtf8PageBase.cs"] = """ + using System; + using Microsoft.AspNetCore.Mvc.Razor; + + namespace MyApp.Infrastructure + { + public abstract class MyUtf8PageBase : RazorPage + { + public void WriteLiteral(ReadOnlySpan utf8HtmlLiteral) + { + WriteLiteral(System.Text.Encoding.UTF8.GetString(utf8HtmlLiteral)); + } + } + } + """ + }); + + var compilation = await project.GetCompilationAsync(); + var driver = await GetDriverAsync(project); + + // Act + var result = RunGenerator(compilation!, ref driver, out _); + + // Assert - short name resolved via @using in _ViewImports should enable UTF-8 + Assert.Empty(result.Diagnostics); + var indexSource = result.GeneratedSources.Single(s => s.HintName.Contains("Index")).SourceText.ToString(); + Assert.Contains("u8)", indexSource); + } + + [Fact, WorkItem("https://github.com/dotnet/razor/issues/8429")] + public async Task Utf8HtmlLiterals_CascadingViewImports_MostSpecificWins() + { + // Arrange - root _ViewImports has @inherits with UTF-8 support, + // but Pages/_ViewImports overrides with a base that does NOT support UTF-8. + // A page in Pages/Admin/ has its own @inherits that DOES support UTF-8. + var project = CreateTestProject( + additionalSources: new() + { + ["_ViewImports.cshtml"] = """ + @inherits MyUtf8PageBase + """, + ["Pages/_ViewImports.cshtml"] = """ + @inherits MyRegularPageBase + """, + ["Pages/Index.cshtml"] = """ +

Hello

+ """, + ["Pages/Admin/Dashboard.cshtml"] = """ + @inherits MyUtf8PageBase +

Hello

+ """, + }, + sources: new() + { + ["MyUtf8PageBase.cs"] = """ + using System; + using Microsoft.AspNetCore.Mvc.Razor; + + public abstract class MyUtf8PageBase : RazorPage + { + public void WriteLiteral(ReadOnlySpan utf8HtmlLiteral) + { + WriteLiteral(System.Text.Encoding.UTF8.GetString(utf8HtmlLiteral)); + } + } + """, + ["MyRegularPageBase.cs"] = """ + using Microsoft.AspNetCore.Mvc.Razor; + + public abstract class MyRegularPageBase : RazorPage + { + } + """ + }); + + var compilation = await project.GetCompilationAsync(); + var driver = await GetDriverAsync(project); + + // Act + var result = RunGenerator(compilation!, ref driver, out _); + + // Assert + Assert.Empty(result.Diagnostics); + + // Pages/Index.cshtml inherits MyRegularPageBase (from Pages/_ViewImports) - no UTF-8 + var indexSource = result.GeneratedSources.Single(s => s.HintName.Contains("Index")).SourceText.ToString(); + Assert.DoesNotContain("u8)", indexSource); + + // Pages/Admin/Dashboard.cshtml has its own @inherits MyUtf8PageBase - UTF-8 + var dashboardSource = result.GeneratedSources.Single(s => s.HintName.Contains("Dashboard")).SourceText.ToString(); + Assert.Contains("u8)", dashboardSource); + } } From 5496ebfd26f058d7a281e3bb2d8e8c6532974b32 Mon Sep 17 00:00:00 2001 From: Chris Sienkiewicz Date: Thu, 23 Apr 2026 10:06:17 -0700 Subject: [PATCH 7/9] Remove usings filter from slow path resolution The slow path for resolving @inherits type names previously skipped entries with no Razor @using directives. Since .cshtml files always have default MVC imports, this filter was ineffective. Removing it ensures types resolvable via C# global usings or the compilation's existing context are not missed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CSharp/DefaultUtf8WriteLiteralFeature.cs | 19 +++----- .../RazorSourceGeneratorCshtmlTests.cs | 47 +++++++++++++++++++ 2 files changed, 54 insertions(+), 12 deletions(-) diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/DefaultUtf8WriteLiteralFeature.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/DefaultUtf8WriteLiteralFeature.cs index 77e68fea66b..dd7a4fb98c0 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/DefaultUtf8WriteLiteralFeature.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/DefaultUtf8WriteLiteralFeature.cs @@ -114,19 +114,15 @@ public static Utf8SupportMap Create(ImmutableArray inheritsInfos, // Second pass: resolve remaining entries via a single augmented compilation. if (unresolvedEntries is { Count: > 0 } && compilation is CSharpCompilation csharpCompilation) { - var entriesWithUsings = unresolvedEntries.Where(e => !e.Info.Usings.IsEmpty).ToList(); - if (entriesWithUsings.Count > 0) + var resolved = ResolveTypeNamesWithUsings(unresolvedEntries, csharpCompilation); + foreach (var (index, fqn) in resolved) { - var resolved = ResolveTypeNamesWithUsings(entriesWithUsings, csharpCompilation); - foreach (var (index, fqn) in resolved) - { - var info = inheritsInfos[index]; - fileToType[info.FilePath] = fqn; + var info = inheritsInfos[index]; + fileToType[info.FilePath] = fqn; - if (!typeSupport.ContainsKey(fqn)) - { - typeSupport[fqn] = compilation.HasCallableUtf8WriteLiteralOverload(fqn); - } + if (!typeSupport.ContainsKey(fqn)) + { + typeSupport[fqn] = compilation.HasCallableUtf8WriteLiteralOverload(fqn); } } } @@ -152,7 +148,6 @@ public static Utf8SupportMap Create(ImmutableArray inheritsInfos, for (var i = 0; i < entries.Count; i++) { var info = entries[i].Info; - Debug.Assert(!info.Usings.IsEmpty); sb.Append("namespace __Utf8Probe_").Append(i).AppendLine(" {"); foreach (var u in info.Usings) diff --git a/src/Compiler/test/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/RazorSourceGeneratorCshtmlTests.cs b/src/Compiler/test/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/RazorSourceGeneratorCshtmlTests.cs index 9a73602e912..54c689c9083 100644 --- a/src/Compiler/test/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/RazorSourceGeneratorCshtmlTests.cs +++ b/src/Compiler/test/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/RazorSourceGeneratorCshtmlTests.cs @@ -864,4 +864,51 @@ public abstract class MyRegularPageBase : RazorPage var dashboardSource = result.GeneratedSources.Single(s => s.HintName.Contains("Dashboard")).SourceText.ToString(); Assert.Contains("u8)", dashboardSource); } + + [Fact, WorkItem("https://github.com/dotnet/razor/issues/8429")] + public async Task Utf8HtmlLiterals_NonGenericBaseClass_ResolvedViaCSharpGlobalUsing() + { + // Arrange - @inherits uses a short name with no @using in Razor. + // The namespace is imported via a C# global using (e.g. from GlobalUsings.cs). + var project = CreateTestProject( + additionalSources: new() + { + ["Pages/Index.cshtml"] = """ + @inherits MyUtf8PageBase +

Hello World

+ """, + }, + sources: new() + { + ["GlobalUsings.cs"] = """ + global using MyApp.Infrastructure; + """, + ["MyUtf8PageBase.cs"] = """ + using System; + using Microsoft.AspNetCore.Mvc.Razor; + + namespace MyApp.Infrastructure + { + public abstract class MyUtf8PageBase : RazorPage + { + public void WriteLiteral(ReadOnlySpan utf8HtmlLiteral) + { + WriteLiteral(System.Text.Encoding.UTF8.GetString(utf8HtmlLiteral)); + } + } + } + """ + }); + + var compilation = await project.GetCompilationAsync(); + var driver = await GetDriverAsync(project); + + // Act + var result = RunGenerator(compilation!, ref driver, out _); + + // Assert - should detect UTF-8 even though the using comes from C# not Razor + Assert.Empty(result.Diagnostics); + var indexSource = result.GeneratedSources.Single(s => s.HintName.Contains("Index")).SourceText.ToString(); + Assert.Contains("u8)", indexSource); + } } From 8aafc186c13ee4e2361e7e6363372aa214669d91 Mon Sep 17 00:00:00 2001 From: Chris Sienkiewicz Date: Thu, 23 Apr 2026 11:08:25 -0700 Subject: [PATCH 8/9] Support generic base classes in UTF-8 WriteLiteral detection The slow path now uses GetFullMetadataName() which builds a proper CLR metadata name (with backtick arity for generics and + for nested types) instead of GetFullName() which produces C# display syntax that cannot be resolved by GetTypeByMetadataName. Added tests for generic base classes (single and multiple type params), generics in namespaces, nested generics, and generics from metadata references. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CSharp/DefaultUtf8WriteLiteralFeature.cs | 30 ++- .../RazorSourceGeneratorCshtmlTests.cs | 246 ++++++++++++++++++ 2 files changed, 275 insertions(+), 1 deletion(-) diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/DefaultUtf8WriteLiteralFeature.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/DefaultUtf8WriteLiteralFeature.cs index dd7a4fb98c0..b9efacbbd15 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/DefaultUtf8WriteLiteralFeature.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/DefaultUtf8WriteLiteralFeature.cs @@ -184,13 +184,41 @@ public static Utf8SupportMap Create(ImmutableArray inheritsInfos, var symbol = semanticModel.GetSymbolInfo(baseTypeSyntax.Type).Symbol as INamedTypeSymbol; if (symbol is not null && symbol.TypeKind != TypeKind.Error) { - results.Add((entries[i].Index, symbol.GetFullName())); + results.Add((entries[i].Index, GetFullMetadataName(symbol))); } } return results; } + /// + /// Builds a fully-qualified metadata name for a type symbol, suitable for + /// . Unlike GetFullName() + /// which produces C# display syntax, this uses CLR metadata conventions: + /// backtick arity for generics and + for nested types. + /// + private static string GetFullMetadataName(INamedTypeSymbol symbol) + { + var typePart = symbol.MetadataName; + + if (symbol.ContainingType is not null) + { + // Walk containing types to build Outer`1+Inner chain. + var parts = new List { typePart }; + for (var current = symbol.ContainingType; current is not null; current = current.ContainingType) + { + parts.Add(current.MetadataName); + } + + parts.Reverse(); + typePart = string.Join("+", parts); + } + + return symbol.ContainingNamespace is { IsGlobalNamespace: false } ns + ? $"{ns.GetFullName()}.{typePart}" + : typePart; + } + public bool IsSupported(string? filePath, string baseTypeName) { if (filePath is not null && _fileToType.TryGetValue(filePath, out var fqn)) diff --git a/src/Compiler/test/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/RazorSourceGeneratorCshtmlTests.cs b/src/Compiler/test/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/RazorSourceGeneratorCshtmlTests.cs index 54c689c9083..de40b467710 100644 --- a/src/Compiler/test/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/RazorSourceGeneratorCshtmlTests.cs +++ b/src/Compiler/test/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/RazorSourceGeneratorCshtmlTests.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Immutable; +using System.IO; using System.Linq; using System.Text; using System.Threading.Tasks; @@ -865,6 +866,52 @@ public abstract class MyRegularPageBase : RazorPage Assert.Contains("u8)", dashboardSource); } + [Fact, WorkItem("https://github.com/dotnet/razor/issues/8429")] + public async Task Utf8HtmlLiterals_GenericBaseClass() + { + // Arrange - @inherits uses a generic base class that has the UTF-8 overload + var project = CreateTestProject( + additionalSources: new() + { + ["Pages/Index.cshtml"] = """ + @inherits MyUtf8PageBase +

Hello World

+ """, + }, + sources: new() + { + ["MyModel.cs"] = """ + public class MyModel + { + public string Name { get; set; } = ""; + } + """, + ["MyUtf8PageBase.cs"] = """ + using System; + using Microsoft.AspNetCore.Mvc.Razor; + + public abstract class MyUtf8PageBase : RazorPage + { + public void WriteLiteral(ReadOnlySpan utf8HtmlLiteral) + { + WriteLiteral(System.Text.Encoding.UTF8.GetString(utf8HtmlLiteral)); + } + } + """ + }); + + var compilation = await project.GetCompilationAsync(); + var driver = await GetDriverAsync(project); + + // Act + var result = RunGenerator(compilation!, ref driver, out _); + + // Assert - generic base class with UTF-8 overload should be detected + Assert.Empty(result.Diagnostics); + var indexSource = result.GeneratedSources.Single(s => s.HintName.Contains("Index")).SourceText.ToString(); + Assert.Contains("u8)", indexSource); + } + [Fact, WorkItem("https://github.com/dotnet/razor/issues/8429")] public async Task Utf8HtmlLiterals_NonGenericBaseClass_ResolvedViaCSharpGlobalUsing() { @@ -911,4 +958,203 @@ public void WriteLiteral(ReadOnlySpan utf8HtmlLiteral) var indexSource = result.GeneratedSources.Single(s => s.HintName.Contains("Index")).SourceText.ToString(); Assert.Contains("u8)", indexSource); } + + [Fact, WorkItem("https://github.com/dotnet/razor/issues/8429")] + public async Task Utf8HtmlLiterals_GenericBaseClass_InNamespace() + { + // Arrange - @inherits uses a generic base class in a namespace, resolved via @using + var project = CreateTestProject( + additionalSources: new() + { + ["Pages/_ViewImports.cshtml"] = """ + @using MyApp.Infrastructure + """, + ["Pages/Index.cshtml"] = """ + @inherits MyUtf8PageBase +

Hello World

+ """, + }, + sources: new() + { + ["MyModel.cs"] = """ + public class MyModel { } + """, + ["MyUtf8PageBase.cs"] = """ + using System; + using Microsoft.AspNetCore.Mvc.Razor; + + namespace MyApp.Infrastructure + { + public abstract class MyUtf8PageBase : RazorPage + { + public void WriteLiteral(ReadOnlySpan utf8HtmlLiteral) + { + WriteLiteral(System.Text.Encoding.UTF8.GetString(utf8HtmlLiteral)); + } + } + } + """ + }); + + var compilation = await project.GetCompilationAsync(); + var driver = await GetDriverAsync(project); + + // Act + var result = RunGenerator(compilation!, ref driver, out _); + + // Assert + Assert.Empty(result.Diagnostics); + var indexSource = result.GeneratedSources.Single(s => s.HintName.Contains("Index")).SourceText.ToString(); + Assert.Contains("u8)", indexSource); + } + + [Fact, WorkItem("https://github.com/dotnet/razor/issues/8429")] + public async Task Utf8HtmlLiterals_GenericBaseClass_NestedInGenericClass() + { + // Arrange - @inherits uses a generic class nested inside another generic class in a namespace + var project = CreateTestProject( + additionalSources: new() + { + ["Pages/_ViewImports.cshtml"] = """ + @using MyApp.Infrastructure + """, + ["Pages/Index.cshtml"] = """ + @inherits PageFactory.Utf8Page +

Hello World

+ """, + }, + sources: new() + { + ["MyModel.cs"] = """ + public class MyModel { } + """, + ["PageFactory.cs"] = """ + using System; + using Microsoft.AspNetCore.Mvc.Razor; + + namespace MyApp.Infrastructure + { + public class PageFactory + { + public abstract class Utf8Page : RazorPage + { + public void WriteLiteral(ReadOnlySpan utf8HtmlLiteral) + { + WriteLiteral(System.Text.Encoding.UTF8.GetString(utf8HtmlLiteral)); + } + } + } + } + """ + }); + + var compilation = await project.GetCompilationAsync(); + var driver = await GetDriverAsync(project); + + // Act + var result = RunGenerator(compilation!, ref driver, out _); + + // Assert + Assert.Empty(result.Diagnostics); + var indexSource = result.GeneratedSources.Single(s => s.HintName.Contains("Index")).SourceText.ToString(); + Assert.Contains("u8)", indexSource); + } + + [Fact, WorkItem("https://github.com/dotnet/razor/issues/8429")] + public async Task Utf8HtmlLiterals_GenericBaseClass_MultipleTypeParameters() + { + // Arrange - @inherits uses a generic base class with multiple type parameters + var project = CreateTestProject( + additionalSources: new() + { + ["Pages/Index.cshtml"] = """ + @inherits MyUtf8PageBase +

Hello World

+ """, + }, + sources: new() + { + ["MyUtf8PageBase.cs"] = """ + using System; + using Microsoft.AspNetCore.Mvc.Razor; + + public abstract class MyUtf8PageBase : RazorPage + { + public void WriteLiteral(ReadOnlySpan utf8HtmlLiteral) + { + WriteLiteral(System.Text.Encoding.UTF8.GetString(utf8HtmlLiteral)); + } + } + """ + }); + + var compilation = await project.GetCompilationAsync(); + var driver = await GetDriverAsync(project); + + // Act + var result = RunGenerator(compilation!, ref driver, out _); + + // Assert + Assert.Empty(result.Diagnostics); + var indexSource = result.GeneratedSources.Single(s => s.HintName.Contains("Index")).SourceText.ToString(); + Assert.Contains("u8)", indexSource); + } + + [Fact, WorkItem("https://github.com/dotnet/razor/issues/8429")] + public async Task Utf8HtmlLiterals_GenericBaseClass_FromMetadataReference() + { + // Arrange - the generic base class comes from a pre-compiled assembly (metadata reference), + // not from source in the same compilation. + var baseProject = CreateTestProject(new() { ["_dummy.cshtml"] = "" }, sources: new() + { + ["MyUtf8PageBase.cs"] = """ + using System; + using Microsoft.AspNetCore.Mvc.Razor; + + namespace ExternalLib + { + public abstract class MyUtf8PageBase : RazorPage + { + public void WriteLiteral(ReadOnlySpan utf8HtmlLiteral) + { + WriteLiteral(System.Text.Encoding.UTF8.GetString(utf8HtmlLiteral)); + } + } + } + """ + }); + + var baseCompilation = await baseProject.GetCompilationAsync(); + using var peStream = new MemoryStream(); + var emitResult = baseCompilation!.Emit(peStream); + Assert.True(emitResult.Success); + peStream.Position = 0; + var metadataRef = MetadataReference.CreateFromStream(peStream); + + // Now create the actual test project that references the pre-compiled assembly + var project = CreateTestProject( + additionalSources: new() + { + ["Pages/_ViewImports.cshtml"] = """ + @using ExternalLib + """, + ["Pages/Index.cshtml"] = """ + @inherits MyUtf8PageBase +

Hello World

+ """, + }); + + project = project.AddMetadataReference(metadataRef); + + var compilation = await project.GetCompilationAsync(); + var driver = await GetDriverAsync(project); + + // Act + var result = RunGenerator(compilation!, ref driver, out _); + + // Assert + Assert.Empty(result.Diagnostics); + var indexSource = result.GeneratedSources.Single(s => s.HintName.Contains("Index")).SourceText.ToString(); + Assert.Contains("u8)", indexSource); + } } From 2c94c7af54af24ef7cce6af6b4cc05fc4b8abb30 Mon Sep 17 00:00:00 2001 From: Chris Sienkiewicz Date: Thu, 23 Apr 2026 11:16:26 -0700 Subject: [PATCH 9/9] Use pooled StringBuilder in probe tree construction Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/CSharp/DefaultUtf8WriteLiteralFeature.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/DefaultUtf8WriteLiteralFeature.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/DefaultUtf8WriteLiteralFeature.cs index b9efacbbd15..9b284d2febf 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/DefaultUtf8WriteLiteralFeature.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/CSharp/DefaultUtf8WriteLiteralFeature.cs @@ -8,6 +8,7 @@ using System.Linq; using System.Text; using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.PooledObjects; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Razor; @@ -144,7 +145,7 @@ public static Utf8SupportMap Create(ImmutableArray inheritsInfos, var results = new List<(int, string)>(); // Build a single probe tree with namespace-scoped usings for each entry. - var sb = new StringBuilder(); + using var _ = StringBuilderPool.GetPooledObject(out var sb); for (var i = 0; i < entries.Count; i++) { var info = entries[i].Info;