From 131ae59a132355624fb5bdaa1f078d30da038fef Mon Sep 17 00:00:00 2001 From: Damian Edwards Date: Sun, 1 Mar 2026 13:57:09 -0800 Subject: [PATCH] Add SuppressMvcRazorImports configuration option Add a new SuppressMvcRazorImports boolean property to RazorConfiguration that, when set to true via MSBuild property, suppresses all MVC-specific default directives from the synthetic import in MvcImportProjectFeature. When enabled, the default import retains only System @using directives. The following MVC-specific directives are suppressed: - 3 @using Microsoft.AspNetCore.Mvc.* directives - 5 @inject directives (IHtmlHelper, IJsonHelper, etc.) - 3 @addTagHelper directives (UrlResolution, Head, Body) Explicit @inject, @using, and @addTagHelper directives in .cshtml or _ViewImports.cshtml files continue to work regardless of this setting. Users set true in their project file. A follow-up change in dotnet/sdk is needed to add the corresponding CompilerVisibleProperty declaration. Fixes #8259 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test/MvcImportProjectFeatureTest.cs | 47 ++++++++++++++++++- .../src/Language/RazorConfiguration.cs | 4 ++ .../src/Mvc/MvcImportProjectFeature.cs | 13 +++-- .../RazorSourceGenerator.RazorProviders.cs | 3 +- .../RazorSourceGeneratorTests.cs | 45 ++++++++++++++++++ .../Formatters/RazorConfigurationFormatter.cs | 5 +- .../Utilities/RazorProjectInfoFactory.cs | 3 ++ .../RazorConfigurationSerializationTest.cs | 2 + .../ObjectReaders.cs | 2 + .../ObjectWriters.cs | 1 + 10 files changed, 119 insertions(+), 6 deletions(-) diff --git a/src/Compiler/Microsoft.AspNetCore.Mvc.Razor.Extensions/test/MvcImportProjectFeatureTest.cs b/src/Compiler/Microsoft.AspNetCore.Mvc.Razor.Extensions/test/MvcImportProjectFeatureTest.cs index 1325a698ef0..a3eab02d215 100644 --- a/src/Compiler/Microsoft.AspNetCore.Mvc.Razor.Extensions/test/MvcImportProjectFeatureTest.cs +++ b/src/Compiler/Microsoft.AspNetCore.Mvc.Razor.Extensions/test/MvcImportProjectFeatureTest.cs @@ -16,13 +16,51 @@ public void AddDefaultDirectivesImport_AddsSingleDynamicImport() using var imports = new PooledArrayBuilder(); // Act - MvcImportProjectFeature.AddDefaultDirectivesImport(ref imports.AsRef()); + MvcImportProjectFeature.AddDefaultDirectivesImport(suppressMvcRazorImports: false, ref imports.AsRef()); // Assert var import = Assert.Single(imports.ToImmutable()); Assert.Null(import.FilePath); } + [Fact] + public void AddDefaultDirectivesImport_DefaultImportContainsMvcDirectives() + { + // Arrange + using var imports = new PooledArrayBuilder(); + + // Act + MvcImportProjectFeature.AddDefaultDirectivesImport(suppressMvcRazorImports: false, ref imports.AsRef()); + + // Assert + var import = Assert.Single(imports.ToImmutable()); + var content = ReadContent(import); + Assert.Contains("@inject", content); + Assert.Contains("@addTagHelper", content); + Assert.Contains("@using global::Microsoft.AspNetCore.Mvc", content); + } + + [Fact] + public void AddDefaultDirectivesImport_SuppressMvcRazorImports_OmitsMvcDirectives() + { + // Arrange + using var imports = new PooledArrayBuilder(); + + // Act + MvcImportProjectFeature.AddDefaultDirectivesImport(suppressMvcRazorImports: true, ref imports.AsRef()); + + // Assert + var import = Assert.Single(imports.ToImmutable()); + var content = ReadContent(import); + Assert.DoesNotContain("@inject", content); + Assert.DoesNotContain("@addTagHelper", content); + Assert.DoesNotContain("Microsoft.AspNetCore.Mvc", content); + Assert.Contains("@using global::System", content); + Assert.Contains("@using global::System.Collections.Generic", content); + Assert.Contains("@using global::System.Linq", content); + Assert.Contains("@using global::System.Threading.Tasks", content); + } + [Fact] public void AddHierarchicalImports_AddsViewImportSourceDocumentsOnDisk() { @@ -71,4 +109,11 @@ public void AddHierarchicalImports_AddsViewImportSourceDocumentsNotOnDisk() import => Assert.Equal("/Pages/_ViewImports.cshtml", import.FilePath), import => Assert.Equal("/Pages/Contact/_ViewImports.cshtml", import.FilePath)); } + + private static string ReadContent(RazorProjectItem item) + { + using var stream = item.Read(); + using var reader = new System.IO.StreamReader(stream); + return reader.ReadToEnd(); + } } diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorConfiguration.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorConfiguration.cs index e6b1eea1305..6abec821bff 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorConfiguration.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Language/RazorConfiguration.cs @@ -16,6 +16,7 @@ public sealed record class RazorConfiguration( bool UseConsolidatedMvcViews = true, bool SuppressAddComponentParameter = false, bool UseRoslynTokenizer = false, + bool SuppressMvcRazorImports = false, ImmutableArray PreprocessorSymbols = default) { public ImmutableArray PreprocessorSymbols @@ -32,6 +33,7 @@ public ImmutableArray PreprocessorSymbols UseConsolidatedMvcViews: true, SuppressAddComponentParameter: false, UseRoslynTokenizer: false, + SuppressMvcRazorImports: false, PreprocessorSymbols: []); public bool Equals(RazorConfiguration? other) @@ -42,6 +44,7 @@ public bool Equals(RazorConfiguration? other) SuppressAddComponentParameter == other.SuppressAddComponentParameter && UseConsolidatedMvcViews == other.UseConsolidatedMvcViews && UseRoslynTokenizer == other.UseRoslynTokenizer && + SuppressMvcRazorImports == other.SuppressMvcRazorImports && PreprocessorSymbols.SequenceEqual(other.PreprocessorSymbols) && Extensions.SequenceEqual(other.Extensions); @@ -55,6 +58,7 @@ public override int GetHashCode() hash.Add(SuppressAddComponentParameter); hash.Add(UseConsolidatedMvcViews); hash.Add(UseRoslynTokenizer); + hash.Add(SuppressMvcRazorImports); hash.Add(PreprocessorSymbols); return hash; } diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Mvc/MvcImportProjectFeature.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Mvc/MvcImportProjectFeature.cs index d27c6af1af7..6086c9dc70c 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Mvc/MvcImportProjectFeature.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/Mvc/MvcImportProjectFeature.cs @@ -27,6 +27,13 @@ internal sealed class MvcImportProjectFeature : RazorProjectEngineFeatureBase, I @addTagHelper global::Microsoft.AspNetCore.Mvc.Razor.TagHelpers.UrlResolutionTagHelper, Microsoft.AspNetCore.Mvc.Razor @addTagHelper global::Microsoft.AspNetCore.Mvc.Razor.TagHelpers.HeadTagHelper, Microsoft.AspNetCore.Mvc.Razor @addTagHelper global::Microsoft.AspNetCore.Mvc.Razor.TagHelpers.BodyTagHelper, Microsoft.AspNetCore.Mvc.Razor +"); + + private static readonly DefaultImportProjectItem s_defaultImportNoMvc = new($"Default non-MVC imports ({ImportsFileName})", @" +@using global::System +@using global::System.Collections.Generic +@using global::System.Linq +@using global::System.Threading.Tasks "); public void CollectImports(RazorProjectItem projectItem, ref PooledArrayBuilder imports) @@ -39,16 +46,16 @@ public void CollectImports(RazorProjectItem projectItem, ref PooledArrayBuilder< return; } - AddDefaultDirectivesImport(ref imports); + AddDefaultDirectivesImport(ProjectEngine.Configuration.SuppressMvcRazorImports, ref imports); // We add hierarchical imports second so any default directive imports can be overridden. AddHierarchicalImports(projectItem, ref imports); } // Internal for testing - internal static void AddDefaultDirectivesImport(ref PooledArrayBuilder imports) + internal static void AddDefaultDirectivesImport(bool suppressMvcRazorImports, ref PooledArrayBuilder imports) { - imports.Add(s_defaultImport); + imports.Add(suppressMvcRazorImports ? s_defaultImportNoMvc : s_defaultImport); } // Internal for testing diff --git a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/RazorSourceGenerator.RazorProviders.cs b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/RazorSourceGenerator.RazorProviders.cs index 20320759d8d..9648a44875a 100644 --- a/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/RazorSourceGenerator.RazorProviders.cs +++ b/src/Compiler/Microsoft.CodeAnalysis.Razor.Compiler/src/SourceGenerators/RazorSourceGenerator.RazorProviders.cs @@ -33,6 +33,7 @@ public partial class RazorSourceGenerator globalOptions.TryGetValue("build_property.RootNamespace", out var rootNamespace); globalOptions.TryGetValue("build_property.SupportLocalizedComponentNames", out var supportLocalizedComponentNames); globalOptions.TryGetValue("build_property.GenerateRazorMetadataSourceChecksumAttributes", out var generateMetadataSourceChecksumAttributes); + globalOptions.TryGetValue("build_property.SuppressMvcRazorImports", out var suppressMvcRazorImports); Diagnostic? diagnostic = null; if (!globalOptions.TryGetValue("build_property.RazorLangVersion", out var razorLanguageVersionString) || @@ -54,7 +55,7 @@ public partial class RazorSourceGenerator ? false : CSharpCompilation.Create("components", references: minimalReferences).HasAddComponentParameter(); - var razorConfiguration = new RazorConfiguration(razorLanguageVersion, configurationName ?? "default", Extensions: [], UseConsolidatedMvcViews: true, SuppressAddComponentParameter: !isComponentParameterSupported); + var razorConfiguration = new RazorConfiguration(razorLanguageVersion, configurationName ?? "default", Extensions: [], UseConsolidatedMvcViews: true, SuppressAddComponentParameter: !isComponentParameterSupported, SuppressMvcRazorImports: suppressMvcRazorImports == "true"); // We use the new tokenizer only when requested for now. var useRoslynTokenizer = parseOptions.UseRoslynTokenizer(); diff --git a/src/Compiler/test/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/RazorSourceGeneratorTests.cs b/src/Compiler/test/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/RazorSourceGeneratorTests.cs index 35af7fa8a39..3af0e92b67a 100644 --- a/src/Compiler/test/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/RazorSourceGeneratorTests.cs +++ b/src/Compiler/test/Microsoft.NET.Sdk.Razor.SourceGenerators.Tests/RazorSourceGeneratorTests.cs @@ -1345,6 +1345,51 @@ internal sealed class Views_Shared__Layout : global::Microsoft.AspNetCore.Mvc.Ra } + [Fact, WorkItem("https://github.com/dotnet/razor/issues/8259")] + public async Task SourceGenerator_CshtmlFiles_SuppressMvcRazorImports() + { + // Arrange + var project = CreateTestProject(new() + { + ["Pages/Index.cshtml"] = "

Hello world

", + }); + var compilation = await project.GetCompilationAsync(); + var (driver, _, _) = await GetDriverWithAdditionalTextAndProviderAsync(project, options => + { + options.TestGlobalOptions["build_property.SuppressMvcRazorImports"] = "true"; + }); + + // Act + var result = RunGenerator(compilation!, ref driver); + + // Assert + Assert.Empty(result.Diagnostics); + var generatedSource = Assert.Single(result.GeneratedSources); + var generatedCode = generatedSource.SourceText.ToString(); + + // Should NOT contain MVC-specific inject properties + Assert.DoesNotContain("RazorInjectAttribute", generatedCode); + Assert.DoesNotContain("IModelExpressionProvider", generatedCode); + Assert.DoesNotContain("IUrlHelper", generatedCode); + Assert.DoesNotContain("IViewComponentHelper", generatedCode); + Assert.DoesNotContain("IJsonHelper", generatedCode); + Assert.DoesNotContain("IHtmlHelper", generatedCode); + + // Should NOT contain MVC-specific using directives + Assert.DoesNotContain("using global::Microsoft.AspNetCore.Mvc;", generatedCode); + Assert.DoesNotContain("using global::Microsoft.AspNetCore.Mvc.Rendering;", generatedCode); + Assert.DoesNotContain("using global::Microsoft.AspNetCore.Mvc.ViewFeatures;", generatedCode); + + // Should still contain System using directives + Assert.Contains("using global::System;", generatedCode); + Assert.Contains("using global::System.Collections.Generic;", generatedCode); + Assert.Contains("using global::System.Linq;", generatedCode); + Assert.Contains("using global::System.Threading.Tasks;", generatedCode); + + // Should still contain the page content + Assert.Contains("Hello world", generatedCode); + } + [Fact, WorkItem("https://github.com/dotnet/razor/issues/7049")] public async Task SourceGenerator_CshtmlFiles_TagHelperInFunction() { diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Serialization/MessagePack/Formatters/RazorConfigurationFormatter.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Serialization/MessagePack/Formatters/RazorConfigurationFormatter.cs index 041061539f6..9722cf4bd9d 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Serialization/MessagePack/Formatters/RazorConfigurationFormatter.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Serialization/MessagePack/Formatters/RazorConfigurationFormatter.cs @@ -12,7 +12,7 @@ namespace Microsoft.CodeAnalysis.Razor.Serialization.MessagePack.Formatters; internal sealed class RazorConfigurationFormatter : ValueFormatter { - private const int SerializerPropertyCount = 7; + private const int SerializerPropertyCount = 8; public static readonly ValueFormatter Instance = new RazorConfigurationFormatter(); @@ -31,6 +31,7 @@ public override RazorConfiguration Deserialize(ref MessagePackReader reader, Ser var suppressAddComponentParameter = reader.ReadBoolean(); var useConsolidatedMvcViews = reader.ReadBoolean(); var useRoslynTokenizer = reader.ReadBoolean(); + var suppressMvcRazorImports = reader.ReadBoolean(); var preprocessorSymbols = reader.Deserialize>(options); count -= SerializerPropertyCount; @@ -57,6 +58,7 @@ public override RazorConfiguration Deserialize(ref MessagePackReader reader, Ser UseConsolidatedMvcViews: useConsolidatedMvcViews, SuppressAddComponentParameter: suppressAddComponentParameter, UseRoslynTokenizer: useRoslynTokenizer, + SuppressMvcRazorImports: suppressMvcRazorImports, PreprocessorSymbols: preprocessorSymbols); } @@ -83,6 +85,7 @@ public override void Serialize(ref MessagePackWriter writer, RazorConfiguration writer.Write(value.SuppressAddComponentParameter); writer.Write(value.UseConsolidatedMvcViews); writer.Write(value.UseRoslynTokenizer); + writer.Write(value.SuppressMvcRazorImports); writer.Serialize(value.PreprocessorSymbols, options); count -= SerializerPropertyCount; diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Utilities/RazorProjectInfoFactory.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Utilities/RazorProjectInfoFactory.cs index 5c60d8b27fd..9202f3163dd 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Utilities/RazorProjectInfoFactory.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Utilities/RazorProjectInfoFactory.cs @@ -122,6 +122,8 @@ public static RazorConfiguration ComputeRazorConfigurationOptions(Project projec var suppressAddComponentParameter = compilation is not null && !compilation.HasAddComponentParameter(); + globalOptions.TryGetValue("build_property.SuppressMvcRazorImports", out var suppressMvcRazorImportsValue); + var csharpParseOptions = project.ParseOptions as CSharpParseOptions ?? CSharpParseOptions.Default; var razorConfiguration = new RazorConfiguration( @@ -132,6 +134,7 @@ public static RazorConfiguration ComputeRazorConfigurationOptions(Project projec UseConsolidatedMvcViews: true, suppressAddComponentParameter, UseRoslynTokenizer: csharpParseOptions.UseRoslynTokenizer(), + SuppressMvcRazorImports: suppressMvcRazorImportsValue == "true", PreprocessorSymbols: csharpParseOptions.PreprocessorSymbolNames.ToImmutableArray()); defaultNamespace = rootNamespace ?? "ASP"; // TODO: Source generator does this. Do we want it? diff --git a/src/Razor/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/Serialization/RazorConfigurationSerializationTest.cs b/src/Razor/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/Serialization/RazorConfigurationSerializationTest.cs index a7eae487123..fec6549fc69 100644 --- a/src/Razor/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/Serialization/RazorConfigurationSerializationTest.cs +++ b/src/Razor/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/Serialization/RazorConfigurationSerializationTest.cs @@ -22,6 +22,7 @@ public void RazorConfigurationJsonConverter_Serialization_CanRoundTrip() UseConsolidatedMvcViews: false, SuppressAddComponentParameter: true, UseRoslynTokenizer: true, + SuppressMvcRazorImports: true, PreprocessorSymbols: ["DEBUG", "TRACE", "DAVID"]); // Act @@ -42,6 +43,7 @@ public void RazorConfigurationJsonConverter_Serialization_CanRoundTrip() Assert.Equal(configuration.UseConsolidatedMvcViews, obj.UseConsolidatedMvcViews); Assert.Equal(configuration.SuppressAddComponentParameter, obj.SuppressAddComponentParameter); Assert.Equal(configuration.UseRoslynTokenizer, obj.UseRoslynTokenizer); + Assert.Equal(configuration.SuppressMvcRazorImports, obj.SuppressMvcRazorImports); Assert.Collection(obj.PreprocessorSymbols, s => Assert.Equal("DEBUG", s), s => Assert.Equal("TRACE", s), diff --git a/src/Shared/Microsoft.AspNetCore.Razor.Serialization.Json/ObjectReaders.cs b/src/Shared/Microsoft.AspNetCore.Razor.Serialization.Json/ObjectReaders.cs index 59997625b15..a5089a93879 100644 --- a/src/Shared/Microsoft.AspNetCore.Razor.Serialization.Json/ObjectReaders.cs +++ b/src/Shared/Microsoft.AspNetCore.Razor.Serialization.Json/ObjectReaders.cs @@ -28,6 +28,7 @@ public static RazorConfiguration ReadConfigurationFromProperties(JsonDataReader var suppressAddComponentParameter = reader.ReadBooleanOrFalse(nameof(RazorConfiguration.SuppressAddComponentParameter)); var useConsolidatedMvcViews = reader.ReadBooleanOrTrue(nameof(RazorConfiguration.UseConsolidatedMvcViews)); var useRoslynTokenizer = reader.ReadBooleanOrFalse(nameof(RazorConfiguration.UseRoslynTokenizer)); + var suppressMvcRazorImports = reader.ReadBooleanOrFalse(nameof(RazorConfiguration.SuppressMvcRazorImports)); var preprocessorSymbols = reader.ReadImmutableArrayOrEmpty(nameof(RazorConfiguration.PreprocessorSymbols), r => r.ReadNonNullString()); var extensions = reader.ReadImmutableArrayOrEmpty(nameof(RazorConfiguration.Extensions), static r => @@ -48,6 +49,7 @@ public static RazorConfiguration ReadConfigurationFromProperties(JsonDataReader UseConsolidatedMvcViews: useConsolidatedMvcViews, SuppressAddComponentParameter: suppressAddComponentParameter, UseRoslynTokenizer: useRoslynTokenizer, + SuppressMvcRazorImports: suppressMvcRazorImports, PreprocessorSymbols: preprocessorSymbols); } diff --git a/src/Shared/Microsoft.AspNetCore.Razor.Serialization.Json/ObjectWriters.cs b/src/Shared/Microsoft.AspNetCore.Razor.Serialization.Json/ObjectWriters.cs index 3d6645a901d..50b675b609c 100644 --- a/src/Shared/Microsoft.AspNetCore.Razor.Serialization.Json/ObjectWriters.cs +++ b/src/Shared/Microsoft.AspNetCore.Razor.Serialization.Json/ObjectWriters.cs @@ -35,6 +35,7 @@ public static void WriteProperties(JsonDataWriter writer, RazorConfiguration val writer.WriteIfNotFalse(nameof(value.SuppressAddComponentParameter), value.SuppressAddComponentParameter); writer.WriteIfNotTrue(nameof(value.UseConsolidatedMvcViews), value.UseConsolidatedMvcViews); writer.WriteIfNotFalse(nameof(value.UseRoslynTokenizer), value.UseRoslynTokenizer); + writer.WriteIfNotFalse(nameof(value.SuppressMvcRazorImports), value.SuppressMvcRazorImports); writer.WriteArrayIfNotDefaultOrEmpty(nameof(value.PreprocessorSymbols), value.PreprocessorSymbols, static (w, v) => w.Write(v)); writer.WriteArrayIfNotDefaultOrEmpty(nameof(value.Extensions), value.Extensions, static (w, v) => w.Write(v.ExtensionName));