From 1bb245b7bdebb1953a111f0b3f51171fc75fff29 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Dec 2025 22:38:16 +0000 Subject: [PATCH 1/7] Initial plan From b1df88010ad9d607659dfb2157f90b93ec1ff58f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Dec 2025 23:03:39 +0000 Subject: [PATCH 2/7] Add Environment component for Blazor with WebAssembly adapter Co-authored-by: javiercn <6995051+javiercn@users.noreply.github.com> --- .../Components/src/EnvironmentComponent.cs | 136 ++++++++++ .../Microsoft.AspNetCore.Components.csproj | 1 + .../Components/src/PublicAPI.Unshipped.txt | 8 + .../src/Reflection/ComponentProperties.cs | 8 +- .../test/EnvironmentComponentTest.cs | 256 ++++++++++++++++++ ...crosoft.AspNetCore.Components.Tests.csproj | 1 + .../test/ParameterViewTest.Assignment.cs | 12 +- .../src/Hosting/WebAssemblyCultureProvider.cs | 2 +- .../src/Hosting/WebAssemblyHost.cs | 2 +- .../src/Hosting/WebAssemblyHostBuilder.cs | 2 + .../WebAssemblyHostEnvironmentAdapter.cs | 44 +++ ...t.AspNetCore.Components.WebAssembly.csproj | 1 + .../src/Rendering/WebAssemblyDispatcher.cs | 2 +- .../src/Services/WebAssemblyConsoleLogger.cs | 4 +- .../Hosting/WebAssemblyCultureProviderTest.cs | 4 +- 15 files changed, 466 insertions(+), 17 deletions(-) create mode 100644 src/Components/Components/src/EnvironmentComponent.cs create mode 100644 src/Components/Components/test/EnvironmentComponentTest.cs create mode 100644 src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostEnvironmentAdapter.cs diff --git a/src/Components/Components/src/EnvironmentComponent.cs b/src/Components/Components/src/EnvironmentComponent.cs new file mode 100644 index 000000000000..47a38724aef0 --- /dev/null +++ b/src/Components/Components/src/EnvironmentComponent.cs @@ -0,0 +1,136 @@ +// 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.Components.Rendering; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.AspNetCore.Components; + +/// +/// A component that renders its child content only when the current hosting environment +/// matches one of the specified environment names. +/// +/// +/// +/// This component is similar to the environment tag helper in MVC and Razor Pages. +/// +/// +/// The following example shows how to conditionally render content based on the environment: +/// +/// <Environment Include="Development"> +/// <div class="alert alert-warning"> +/// You are running in Development mode. Debug features are enabled. +/// </div> +/// </Environment> +/// +/// <Environment Include="Development, Staging"> +/// <p>This is a pre-production environment.</p> +/// </Environment> +/// +/// <Environment Exclude="Production"> +/// <p>Debug information: @DateTime.Now</p> +/// </Environment> +/// +/// +/// +public sealed class Environment : ComponentBase +{ + private static readonly char[] NameSeparator = [',']; + + [Inject] + private IHostEnvironment HostEnvironment { get; set; } = default!; + + /// + /// Gets or sets a comma-separated list of environment names in which the content should be rendered. + /// If the current environment is also in the list, the content will not be rendered. + /// + /// + /// The specified environment names are compared case insensitively. + /// + [Parameter] + public string? Include { get; set; } + + /// + /// Gets or sets a comma-separated list of environment names in which the content will not be rendered. + /// + /// + /// The specified environment names are compared case insensitively. + /// + [Parameter] + public string? Exclude { get; set; } + + /// + /// Gets or sets the content to be rendered when the environment matches. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + if (ShouldRenderContent()) + { + builder.AddContent(0, ChildContent); + } + } + + private bool ShouldRenderContent() + { + var currentEnvironmentName = HostEnvironment.EnvironmentName?.Trim(); + + if (string.IsNullOrEmpty(currentEnvironmentName)) + { + return false; + } + + // Check exclusions first - if current environment is excluded, don't render + if (!string.IsNullOrWhiteSpace(Exclude)) + { + foreach (var environment in ParseEnvironmentNames(Exclude)) + { + if (environment.Equals(currentEnvironmentName, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + } + } + + // If no inclusions specified, render (unless excluded above) + if (string.IsNullOrWhiteSpace(Include)) + { + return true; + } + + // Check if current environment is in the include list + var hasEnvironments = false; + foreach (var environment in ParseEnvironmentNames(Include)) + { + hasEnvironments = true; + if (environment.Equals(currentEnvironmentName, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + // If Include was specified but contained no valid environments, render content + // (same behavior as when Include is not specified) + if (!hasEnvironments) + { + return true; + } + + return false; + } + + private static IEnumerable ParseEnvironmentNames(string names) + { + foreach (var segment in names.Split(NameSeparator, StringSplitOptions.RemoveEmptyEntries)) + { + var trimmed = segment.Trim(); + if (trimmed.Length > 0) + { + yield return trimmed; + } + } + } +} diff --git a/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj b/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj index ba3ae6420645..0f618ff6030e 100644 --- a/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj +++ b/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj @@ -31,6 +31,7 @@ + diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index 7dc5c58110bf..9d391ba19659 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -1 +1,9 @@ #nullable enable +Microsoft.AspNetCore.Components.Environment +Microsoft.AspNetCore.Components.Environment.ChildContent.get -> Microsoft.AspNetCore.Components.RenderFragment? +Microsoft.AspNetCore.Components.Environment.ChildContent.set -> void +Microsoft.AspNetCore.Components.Environment.Environment() -> void +Microsoft.AspNetCore.Components.Environment.Exclude.get -> string? +Microsoft.AspNetCore.Components.Environment.Exclude.set -> void +Microsoft.AspNetCore.Components.Environment.Include.get -> string? +Microsoft.AspNetCore.Components.Environment.Include.set -> void diff --git a/src/Components/Components/src/Reflection/ComponentProperties.cs b/src/Components/Components/src/Reflection/ComponentProperties.cs index 353576925963..c7a463113e59 100644 --- a/src/Components/Components/src/Reflection/ComponentProperties.cs +++ b/src/Components/Components/src/Reflection/ComponentProperties.cs @@ -239,8 +239,8 @@ private static void ThrowForCaptureUnmatchedValuesConflict(Type targetType, stri { throw new InvalidOperationException( $"The property '{parameterName}' on component type '{targetType.FullName}' cannot be set explicitly " + - $"when also used to capture unmatched values. Unmatched values:" + Environment.NewLine + - string.Join(Environment.NewLine, unmatched.Keys)); + $"when also used to capture unmatched values. Unmatched values:" + System.Environment.NewLine + + string.Join(System.Environment.NewLine, unmatched.Keys)); } [DoesNotReturn] @@ -260,8 +260,8 @@ private static void ThrowForMultipleCaptureUnmatchedValuesParameters([Dynamicall throw new InvalidOperationException( $"Multiple properties were found on component type '{targetType.FullName}' with " + $"'{nameof(ParameterAttribute)}.{nameof(ParameterAttribute.CaptureUnmatchedValues)}'. Only a single property " + - $"per type can use '{nameof(ParameterAttribute)}.{nameof(ParameterAttribute.CaptureUnmatchedValues)}'. Properties:" + Environment.NewLine + - string.Join(Environment.NewLine, propertyNames)); + $"per type can use '{nameof(ParameterAttribute)}.{nameof(ParameterAttribute.CaptureUnmatchedValues)}'. Properties:" + System.Environment.NewLine + + string.Join(System.Environment.NewLine, propertyNames)); } [DoesNotReturn] diff --git a/src/Components/Components/test/EnvironmentComponentTest.cs b/src/Components/Components/test/EnvironmentComponentTest.cs new file mode 100644 index 000000000000..e93dc8827d4e --- /dev/null +++ b/src/Components/Components/test/EnvironmentComponentTest.cs @@ -0,0 +1,256 @@ +// 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.Components.Rendering; +using Microsoft.AspNetCore.Components.Test.Helpers; +using Microsoft.Extensions.Hosting; +using Moq; + +namespace Microsoft.AspNetCore.Components; + +public class EnvironmentComponentTest +{ + [Theory] + [InlineData("Development", "Development")] + [InlineData("development", "Development")] + [InlineData("DEVELOPMENT", "Development")] + [InlineData(" development", "Development")] + [InlineData("development ", "Development")] + [InlineData(" development ", "Development")] + [InlineData("Development,Production", "Development")] + [InlineData("Production,Development", "Development")] + [InlineData("Development , Production", "Development")] + [InlineData(" Development,Production ", "Development")] + [InlineData("Development , Production", "Development")] + [InlineData("Development\t,Production", "Development")] + [InlineData("Development,\tProduction", "Development")] + [InlineData(" Development,Production ", "Development")] + [InlineData("Development,Staging,Production", "Development")] + [InlineData("Staging,Development,Production", "Development")] + [InlineData("Staging,Production,Development", "Development")] + [InlineData("Test", "Test")] + [InlineData("Test,Staging", "Test")] + public void ShowsContentWhenCurrentEnvironmentIsInIncludeList(string includeAttribute, string environmentName) + { + ShouldShowContentWithInclude(includeAttribute, environmentName); + } + + [Theory] + [InlineData("NotDevelopment", "Development")] + [InlineData("NOTDEVELOPMENT", "Development")] + [InlineData("NotDevelopment,AlsoNotDevelopment", "Development")] + [InlineData("Doesn'tMatchAtAll", "Development")] + [InlineData("Development and a space", "Development")] + [InlineData("Development and a space,SomethingElse", "Development")] + public void HidesContentWhenCurrentEnvironmentIsNotInIncludeList(string includeAttribute, string environmentName) + { + ShouldHideContentWithInclude(includeAttribute, environmentName); + } + + [Theory] + [InlineData(null, "Development")] + [InlineData("", "Development")] + [InlineData(" ", "Development")] + [InlineData(", ", "Development")] + [InlineData(",", "Development")] + public void ShowsContentWhenNoIncludeOrExcludeIsSpecified(string includeAttribute, string environmentName) + { + // When no Include is specified and Exclude is not specified or empty, + // the component should render its content + var (renderer, componentId) = CreateEnvironmentComponent(environmentName); + + var parameters = ParameterView.FromDictionary(new Dictionary + { + { nameof(Environment.Include), includeAttribute }, + { nameof(Environment.ChildContent), (RenderFragment)(builder => builder.AddContent(0, "Test Content")) }, + }); + + renderer.RenderRootComponent(componentId, parameters); + + var batch = renderer.Batches.Single(); + Assert.Contains(batch.ReferenceFrames, frame => + frame.FrameType == RenderTree.RenderTreeFrameType.Text && + frame.TextContent == "Test Content"); + } + + [Theory] + [InlineData("Production", "Development")] + [InlineData("production", "Development")] + [InlineData("PRODUCTION", "Development")] + [InlineData("Production,Staging", "Development")] + public void ShowsContentWhenCurrentEnvironmentIsNotInExcludeList(string excludeAttribute, string environmentName) + { + var (renderer, componentId) = CreateEnvironmentComponent(environmentName); + + var parameters = ParameterView.FromDictionary(new Dictionary + { + { nameof(Environment.Exclude), excludeAttribute }, + { nameof(Environment.ChildContent), (RenderFragment)(builder => builder.AddContent(0, "Test Content")) }, + }); + + renderer.RenderRootComponent(componentId, parameters); + + var batch = renderer.Batches.Single(); + Assert.Contains(batch.ReferenceFrames, frame => + frame.FrameType == RenderTree.RenderTreeFrameType.Text && + frame.TextContent == "Test Content"); + } + + [Theory] + [InlineData("Development", "Development")] + [InlineData("development", "Development")] + [InlineData("DEVELOPMENT", "Development")] + [InlineData("Development,Staging", "Development")] + [InlineData("Production,Development", "Development")] + public void HidesContentWhenCurrentEnvironmentIsInExcludeList(string excludeAttribute, string environmentName) + { + var (renderer, componentId) = CreateEnvironmentComponent(environmentName); + + var parameters = ParameterView.FromDictionary(new Dictionary + { + { nameof(Environment.Exclude), excludeAttribute }, + { nameof(Environment.ChildContent), (RenderFragment)(builder => builder.AddContent(0, "Test Content")) }, + }); + + renderer.RenderRootComponent(componentId, parameters); + + var batch = renderer.Batches.Single(); + Assert.DoesNotContain(batch.ReferenceFrames, frame => + frame.FrameType == RenderTree.RenderTreeFrameType.Text && + frame.TextContent == "Test Content"); + } + + [Theory] + [InlineData("Development", "Development", "Development")] // In include, also in exclude -> hide + [InlineData("Development,Staging", "Staging", "Staging")] // In include, also in exclude -> hide + public void ExcludeTakesPrecedenceOverInclude(string includeAttribute, string excludeAttribute, string environmentName) + { + var (renderer, componentId) = CreateEnvironmentComponent(environmentName); + + var parameters = ParameterView.FromDictionary(new Dictionary + { + { nameof(Environment.Include), includeAttribute }, + { nameof(Environment.Exclude), excludeAttribute }, + { nameof(Environment.ChildContent), (RenderFragment)(builder => builder.AddContent(0, "Test Content")) }, + }); + + renderer.RenderRootComponent(componentId, parameters); + + var batch = renderer.Batches.Single(); + Assert.DoesNotContain(batch.ReferenceFrames, frame => + frame.FrameType == RenderTree.RenderTreeFrameType.Text && + frame.TextContent == "Test Content"); + } + + [Theory] + [InlineData("Development", "Production", "Development")] // In include, not in exclude -> show + [InlineData("Development,Staging", "Production", "Staging")] // In include, not in exclude -> show + public void ShowsContentWhenInIncludeButNotInExclude(string includeAttribute, string excludeAttribute, string environmentName) + { + var (renderer, componentId) = CreateEnvironmentComponent(environmentName); + + var parameters = ParameterView.FromDictionary(new Dictionary + { + { nameof(Environment.Include), includeAttribute }, + { nameof(Environment.Exclude), excludeAttribute }, + { nameof(Environment.ChildContent), (RenderFragment)(builder => builder.AddContent(0, "Test Content")) }, + }); + + renderer.RenderRootComponent(componentId, parameters); + + var batch = renderer.Batches.Single(); + Assert.Contains(batch.ReferenceFrames, frame => + frame.FrameType == RenderTree.RenderTreeFrameType.Text && + frame.TextContent == "Test Content"); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData(" ")] + [InlineData("\t")] + public void HidesContentWhenEnvironmentNameIsNullOrEmpty(string environmentName) + { + var (renderer, componentId) = CreateEnvironmentComponent(environmentName); + + var parameters = ParameterView.FromDictionary(new Dictionary + { + { nameof(Environment.Include), "Development" }, + { nameof(Environment.ChildContent), (RenderFragment)(builder => builder.AddContent(0, "Test Content")) }, + }); + + renderer.RenderRootComponent(componentId, parameters); + + var batch = renderer.Batches.Single(); + Assert.DoesNotContain(batch.ReferenceFrames, frame => + frame.FrameType == RenderTree.RenderTreeFrameType.Text && + frame.TextContent == "Test Content"); + } + + [Fact] + public void RendersNothingWhenChildContentIsNull() + { + var (renderer, componentId) = CreateEnvironmentComponent("Development"); + + var parameters = ParameterView.FromDictionary(new Dictionary + { + { nameof(Environment.Include), "Development" }, + }); + + renderer.RenderRootComponent(componentId, parameters); + + var batch = renderer.Batches.Single(); + Assert.DoesNotContain(batch.ReferenceFrames, f => f.FrameType == RenderTree.RenderTreeFrameType.Text); + } + + private void ShouldShowContentWithInclude(string includeAttribute, string environmentName) + { + var (renderer, componentId) = CreateEnvironmentComponent(environmentName); + + var parameters = ParameterView.FromDictionary(new Dictionary + { + { nameof(Environment.Include), includeAttribute }, + { nameof(Environment.ChildContent), (RenderFragment)(builder => builder.AddContent(0, "Test Content")) }, + }); + + renderer.RenderRootComponent(componentId, parameters); + + var batch = renderer.Batches.Single(); + Assert.Contains(batch.ReferenceFrames, frame => + frame.FrameType == RenderTree.RenderTreeFrameType.Text && + frame.TextContent == "Test Content"); + } + + private void ShouldHideContentWithInclude(string includeAttribute, string environmentName) + { + var (renderer, componentId) = CreateEnvironmentComponent(environmentName); + + var parameters = ParameterView.FromDictionary(new Dictionary + { + { nameof(Environment.Include), includeAttribute }, + { nameof(Environment.ChildContent), (RenderFragment)(builder => builder.AddContent(0, "Test Content")) }, + }); + + renderer.RenderRootComponent(componentId, parameters); + + var batch = renderer.Batches.Single(); + Assert.DoesNotContain(batch.ReferenceFrames, frame => + frame.FrameType == RenderTree.RenderTreeFrameType.Text && + frame.TextContent == "Test Content"); + } + + private static (TestRenderer Renderer, int ComponentId) CreateEnvironmentComponent(string environmentName) + { + var serviceProvider = new TestServiceProvider(); + var hostEnvironment = new Mock(); + hostEnvironment.SetupProperty(h => h.EnvironmentName, environmentName); + serviceProvider.AddService(hostEnvironment.Object); + + var renderer = new TestRenderer(serviceProvider); + var component = (Environment)renderer.InstantiateComponent(); + var componentId = renderer.AssignRootComponentId(component); + + return (renderer, componentId); + } +} diff --git a/src/Components/Components/test/Microsoft.AspNetCore.Components.Tests.csproj b/src/Components/Components/test/Microsoft.AspNetCore.Components.Tests.csproj index 732ebbb65892..2765ae81b741 100644 --- a/src/Components/Components/test/Microsoft.AspNetCore.Components.Tests.csproj +++ b/src/Components/Components/test/Microsoft.AspNetCore.Components.Tests.csproj @@ -9,6 +9,7 @@ + diff --git a/src/Components/Components/test/ParameterViewTest.Assignment.cs b/src/Components/Components/test/ParameterViewTest.Assignment.cs index 827f0e4694ae..0b55c2122d21 100644 --- a/src/Components/Components/test/ParameterViewTest.Assignment.cs +++ b/src/Components/Components/test/ParameterViewTest.Assignment.cs @@ -383,8 +383,8 @@ public void SettingCaptureUnmatchedValuesParameterExplicitlyAndImplicitly_Throws // Assert Assert.Equal( $"The property '{nameof(HasCaptureUnmatchedValuesProperty.CaptureUnmatchedValues)}' on component type '{typeof(HasCaptureUnmatchedValuesProperty).FullName}' cannot be set explicitly when " + - $"also used to capture unmatched values. Unmatched values:" + Environment.NewLine + - $"test1" + Environment.NewLine + + $"also used to capture unmatched values. Unmatched values:" + System.Environment.NewLine + + $"test1" + System.Environment.NewLine + $"test2", ex.Message); } @@ -407,8 +407,8 @@ public void SettingCaptureUnmatchedValuesParameterExplicitlyAndImplicitly_Revers // Assert Assert.Equal( $"The property '{nameof(HasCaptureUnmatchedValuesProperty.CaptureUnmatchedValues)}' on component type '{typeof(HasCaptureUnmatchedValuesProperty).FullName}' cannot be set explicitly when " + - $"also used to capture unmatched values. Unmatched values:" + Environment.NewLine + - $"test2" + Environment.NewLine + + $"also used to capture unmatched values. Unmatched values:" + System.Environment.NewLine + + $"test2" + System.Environment.NewLine + $"test1", ex.Message); } @@ -428,8 +428,8 @@ public void HasDuplicateCaptureUnmatchedValuesParameters_Throws() $"Multiple properties were found on component type '{typeof(HasDuplicateCaptureUnmatchedValuesProperty).FullName}' " + $"with '{nameof(ParameterAttribute)}.{nameof(ParameterAttribute.CaptureUnmatchedValues)}'. " + $"Only a single property per type can use '{nameof(ParameterAttribute)}.{nameof(ParameterAttribute.CaptureUnmatchedValues)}'. " + - $"Properties:" + Environment.NewLine + - $"{nameof(HasDuplicateCaptureUnmatchedValuesProperty.CaptureUnmatchedValuesProp1)}" + Environment.NewLine + + $"Properties:" + System.Environment.NewLine + + $"{nameof(HasDuplicateCaptureUnmatchedValuesProperty.CaptureUnmatchedValuesProp1)}" + System.Environment.NewLine + $"{nameof(HasDuplicateCaptureUnmatchedValuesProperty.CaptureUnmatchedValuesProp2)}", ex.Message); } diff --git a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyCultureProvider.cs b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyCultureProvider.cs index 4d9f51634da0..471757d74e8b 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyCultureProvider.cs +++ b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyCultureProvider.cs @@ -43,7 +43,7 @@ public void ThrowIfCultureChangeIsUnsupported() // It allows us to capture the initial .NET culture that is configured based on the browser language. // The current method is invoked as part of WebAssemblyHost.RunAsync i.e. after user code in Program.MainAsync has run // thus allows us to detect if the culture was changed by user code. - if (Environment.GetEnvironmentVariable("__BLAZOR_SHARDED_ICU") == "1" && + if (System.Environment.GetEnvironmentVariable("__BLAZOR_SHARDED_ICU") == "1" && (!CultureInfo.CurrentCulture.Name.Equals(InitialCulture.Name, StringComparison.Ordinal))) { throw new InvalidOperationException("Blazor detected a change in the application's culture that is not supported with the current project configuration. " + diff --git a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHost.cs b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHost.cs index afc21cf445ab..119f88040d77 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHost.cs +++ b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHost.cs @@ -149,7 +149,7 @@ internal async Task RunAsyncCore(CancellationToken cancellationToken, WebAssembl WebAssemblyNavigationManager.Instance.CreateLogger(loggerFactory); RootComponentOperationBatch? initialOperationBatch = null; - if (Environment.GetEnvironmentVariable("__BLAZOR_WEBASSEMBLY_WAIT_FOR_ROOT_COMPONENTS") == "true") + if (System.Environment.GetEnvironmentVariable("__BLAZOR_WEBASSEMBLY_WAIT_FOR_ROOT_COMPONENTS") == "true") { // In Blazor web, we wait for the JS side to tell us about the components available // before we render the initial set of components. Any additional update goes through diff --git a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs index c9971669d6c0..bacd10097d9c 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs +++ b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs @@ -15,6 +15,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration.Json; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.JSInterop; using static Microsoft.AspNetCore.Internal.LinkerFlags; @@ -185,6 +186,7 @@ private WebAssemblyHostEnvironment InitializeEnvironment() var hostEnvironment = new WebAssemblyHostEnvironment(applicationEnvironment, WebAssemblyNavigationManager.Instance.BaseUri); Services.AddSingleton(hostEnvironment); + Services.AddSingleton(sp => new WebAssemblyHostEnvironmentAdapter(sp.GetRequiredService())); var configFiles = new[] { diff --git a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostEnvironmentAdapter.cs b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostEnvironmentAdapter.cs new file mode 100644 index 000000000000..8df63b4136f3 --- /dev/null +++ b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostEnvironmentAdapter.cs @@ -0,0 +1,44 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting; + +/// +/// An implementation of that wraps . +/// +internal sealed class WebAssemblyHostEnvironmentAdapter : IHostEnvironment +{ + private readonly IWebAssemblyHostEnvironment _webAssemblyHostEnvironment; + + public WebAssemblyHostEnvironmentAdapter(IWebAssemblyHostEnvironment webAssemblyHostEnvironment) + { + _webAssemblyHostEnvironment = webAssemblyHostEnvironment; + } + + public string EnvironmentName + { + get => _webAssemblyHostEnvironment.Environment; + set => throw new NotSupportedException("Setting the environment name is not supported in WebAssembly."); + } + + public string ApplicationName + { + get => string.Empty; + set => throw new NotSupportedException("Setting the application name is not supported in WebAssembly."); + } + + public string ContentRootPath + { + get => _webAssemblyHostEnvironment.BaseAddress; + set => throw new NotSupportedException("Setting the content root path is not supported in WebAssembly."); + } + + public IFileProvider ContentRootFileProvider + { + get => new NullFileProvider(); + set => throw new NotSupportedException("Setting the content root file provider is not supported in WebAssembly."); + } +} diff --git a/src/Components/WebAssembly/WebAssembly/src/Microsoft.AspNetCore.Components.WebAssembly.csproj b/src/Components/WebAssembly/WebAssembly/src/Microsoft.AspNetCore.Components.WebAssembly.csproj index ba479f5352f7..291249ed6766 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Microsoft.AspNetCore.Components.WebAssembly.csproj +++ b/src/Components/WebAssembly/WebAssembly/src/Microsoft.AspNetCore.Components.WebAssembly.csproj @@ -19,6 +19,7 @@ + diff --git a/src/Components/WebAssembly/WebAssembly/src/Rendering/WebAssemblyDispatcher.cs b/src/Components/WebAssembly/WebAssembly/src/Rendering/WebAssemblyDispatcher.cs index 1928b8224736..3705101c7006 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Rendering/WebAssemblyDispatcher.cs +++ b/src/Components/WebAssembly/WebAssembly/src/Rendering/WebAssemblyDispatcher.cs @@ -13,7 +13,7 @@ internal sealed class WebAssemblyDispatcher : Dispatcher internal static int _mainManagedThreadId; // we really need the UI thread not just the right context, because JS objects have thread affinity - public override bool CheckAccess() => _mainManagedThreadId == Environment.CurrentManagedThreadId; + public override bool CheckAccess() => _mainManagedThreadId == System.Environment.CurrentManagedThreadId; public override Task InvokeAsync(Action workItem) { diff --git a/src/Components/WebAssembly/WebAssembly/src/Services/WebAssemblyConsoleLogger.cs b/src/Components/WebAssembly/WebAssembly/src/Services/WebAssemblyConsoleLogger.cs index c2328cc47dc2..17edbf5f9f6a 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Services/WebAssemblyConsoleLogger.cs +++ b/src/Components/WebAssembly/WebAssembly/src/Services/WebAssemblyConsoleLogger.cs @@ -14,7 +14,7 @@ internal sealed class WebAssemblyConsoleLogger : ILogger, ILogger { private const string _loglevelPadding = ": "; private static readonly string _messagePadding = new(' ', GetLogLevelString(LogLevel.Information).Length + _loglevelPadding.Length); - private static readonly string _newLineWithMessagePadding = Environment.NewLine + _messagePadding; + private static readonly string _newLineWithMessagePadding = System.Environment.NewLine + _messagePadding; private static readonly StringBuilder _logBuilder = new StringBuilder(); private readonly string _name; @@ -121,7 +121,7 @@ private static void CreateDefaultLogMessage(StringBuilder logBuilder, LogLevel l var len = logBuilder.Length; logBuilder.Append(message); - logBuilder.Replace(Environment.NewLine, _newLineWithMessagePadding, len, message.Length); + logBuilder.Replace(System.Environment.NewLine, _newLineWithMessagePadding, len, message.Length); } // Example: diff --git a/src/Components/WebAssembly/WebAssembly/test/Hosting/WebAssemblyCultureProviderTest.cs b/src/Components/WebAssembly/WebAssembly/test/Hosting/WebAssemblyCultureProviderTest.cs index 2406a919b2f6..b7e8d142aace 100644 --- a/src/Components/WebAssembly/WebAssembly/test/Hosting/WebAssemblyCultureProviderTest.cs +++ b/src/Components/WebAssembly/WebAssembly/test/Hosting/WebAssemblyCultureProviderTest.cs @@ -43,7 +43,7 @@ public void GetCultures_ReturnCultureClosureWithUICulture(string cultureName, st public void ThrowIfCultureChangeIsUnsupported_ThrowsIfCulturesAreDifferentAndICUShardingIsUsed() { // Arrange - Environment.SetEnvironmentVariable("__BLAZOR_SHARDED_ICU", "1"); + System.Environment.SetEnvironmentVariable("__BLAZOR_SHARDED_ICU", "1"); try { // WebAssembly is initialized with en-US @@ -59,7 +59,7 @@ public void ThrowIfCultureChangeIsUnsupported_ThrowsIfCulturesAreDifferentAndICU } finally { - Environment.SetEnvironmentVariable("__BLAZOR_SHARDED_ICU", null); + System.Environment.SetEnvironmentVariable("__BLAZOR_SHARDED_ICU", null); } } } From 73d6e7d84911f377ef11998e31dcaf441d1333fb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 2 Dec 2025 23:06:03 +0000 Subject: [PATCH 3/7] Address code review: return empty string for ContentRootPath in WebAssembly adapter Co-authored-by: javiercn <6995051+javiercn@users.noreply.github.com> --- .../src/Hosting/WebAssemblyHostEnvironmentAdapter.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostEnvironmentAdapter.cs b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostEnvironmentAdapter.cs index 8df63b4136f3..900a875bf44a 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostEnvironmentAdapter.cs +++ b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostEnvironmentAdapter.cs @@ -32,7 +32,7 @@ public string ApplicationName public string ContentRootPath { - get => _webAssemblyHostEnvironment.BaseAddress; + get => string.Empty; set => throw new NotSupportedException("Setting the content root path is not supported in WebAssembly."); } From 0b21f28b114df6866cead99805d4c1999fa8a216 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 3 Dec 2025 14:42:06 +0000 Subject: [PATCH 4/7] Move Environment component to Components.Web as EnvironmentBoundary and add E2E tests Co-authored-by: javiercn <6995051+javiercn@users.noreply.github.com> --- .../Microsoft.AspNetCore.Components.csproj | 1 - .../Components/src/PublicAPI.Unshipped.txt | 8 --- .../src/Reflection/ComponentProperties.cs | 8 +-- ...crosoft.AspNetCore.Components.Tests.csproj | 1 - .../test/ParameterViewTest.Assignment.cs | 12 ++-- .../src/EnvironmentBoundary.cs} | 16 ++--- ...Microsoft.AspNetCore.Components.Web.csproj | 1 + .../Web/src/PublicAPI.Unshipped.txt | 8 +++ .../test/EnvironmentBoundaryTest.cs} | 63 ++++++++--------- ...oft.AspNetCore.Components.Web.Tests.csproj | 1 + .../src/Hosting/WebAssemblyCultureProvider.cs | 2 +- .../src/Hosting/WebAssemblyHost.cs | 2 +- .../src/Rendering/WebAssemblyDispatcher.cs | 2 +- .../src/Services/WebAssemblyConsoleLogger.cs | 4 +- .../Hosting/WebAssemblyCultureProviderTest.cs | 4 +- .../E2ETest/Tests/EnvironmentBoundaryTest.cs | 67 +++++++++++++++++++ .../EnvironmentBoundaryContainer.razor | 42 ++++++++++++ 17 files changed, 176 insertions(+), 66 deletions(-) rename src/Components/{Components/src/EnvironmentComponent.cs => Web/src/EnvironmentBoundary.cs} (91%) rename src/Components/{Components/test/EnvironmentComponentTest.cs => Web/test/EnvironmentBoundaryTest.cs} (75%) create mode 100644 src/Components/test/E2ETest/Tests/EnvironmentBoundaryTest.cs create mode 100644 src/Components/test/testassets/BasicTestApp/EnvironmentBoundaryContainer.razor diff --git a/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj b/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj index 0f618ff6030e..ba3ae6420645 100644 --- a/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj +++ b/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj @@ -31,7 +31,6 @@ - diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index 9d391ba19659..7dc5c58110bf 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -1,9 +1 @@ #nullable enable -Microsoft.AspNetCore.Components.Environment -Microsoft.AspNetCore.Components.Environment.ChildContent.get -> Microsoft.AspNetCore.Components.RenderFragment? -Microsoft.AspNetCore.Components.Environment.ChildContent.set -> void -Microsoft.AspNetCore.Components.Environment.Environment() -> void -Microsoft.AspNetCore.Components.Environment.Exclude.get -> string? -Microsoft.AspNetCore.Components.Environment.Exclude.set -> void -Microsoft.AspNetCore.Components.Environment.Include.get -> string? -Microsoft.AspNetCore.Components.Environment.Include.set -> void diff --git a/src/Components/Components/src/Reflection/ComponentProperties.cs b/src/Components/Components/src/Reflection/ComponentProperties.cs index c7a463113e59..353576925963 100644 --- a/src/Components/Components/src/Reflection/ComponentProperties.cs +++ b/src/Components/Components/src/Reflection/ComponentProperties.cs @@ -239,8 +239,8 @@ private static void ThrowForCaptureUnmatchedValuesConflict(Type targetType, stri { throw new InvalidOperationException( $"The property '{parameterName}' on component type '{targetType.FullName}' cannot be set explicitly " + - $"when also used to capture unmatched values. Unmatched values:" + System.Environment.NewLine + - string.Join(System.Environment.NewLine, unmatched.Keys)); + $"when also used to capture unmatched values. Unmatched values:" + Environment.NewLine + + string.Join(Environment.NewLine, unmatched.Keys)); } [DoesNotReturn] @@ -260,8 +260,8 @@ private static void ThrowForMultipleCaptureUnmatchedValuesParameters([Dynamicall throw new InvalidOperationException( $"Multiple properties were found on component type '{targetType.FullName}' with " + $"'{nameof(ParameterAttribute)}.{nameof(ParameterAttribute.CaptureUnmatchedValues)}'. Only a single property " + - $"per type can use '{nameof(ParameterAttribute)}.{nameof(ParameterAttribute.CaptureUnmatchedValues)}'. Properties:" + System.Environment.NewLine + - string.Join(System.Environment.NewLine, propertyNames)); + $"per type can use '{nameof(ParameterAttribute)}.{nameof(ParameterAttribute.CaptureUnmatchedValues)}'. Properties:" + Environment.NewLine + + string.Join(Environment.NewLine, propertyNames)); } [DoesNotReturn] diff --git a/src/Components/Components/test/Microsoft.AspNetCore.Components.Tests.csproj b/src/Components/Components/test/Microsoft.AspNetCore.Components.Tests.csproj index 2765ae81b741..732ebbb65892 100644 --- a/src/Components/Components/test/Microsoft.AspNetCore.Components.Tests.csproj +++ b/src/Components/Components/test/Microsoft.AspNetCore.Components.Tests.csproj @@ -9,7 +9,6 @@ - diff --git a/src/Components/Components/test/ParameterViewTest.Assignment.cs b/src/Components/Components/test/ParameterViewTest.Assignment.cs index 0b55c2122d21..827f0e4694ae 100644 --- a/src/Components/Components/test/ParameterViewTest.Assignment.cs +++ b/src/Components/Components/test/ParameterViewTest.Assignment.cs @@ -383,8 +383,8 @@ public void SettingCaptureUnmatchedValuesParameterExplicitlyAndImplicitly_Throws // Assert Assert.Equal( $"The property '{nameof(HasCaptureUnmatchedValuesProperty.CaptureUnmatchedValues)}' on component type '{typeof(HasCaptureUnmatchedValuesProperty).FullName}' cannot be set explicitly when " + - $"also used to capture unmatched values. Unmatched values:" + System.Environment.NewLine + - $"test1" + System.Environment.NewLine + + $"also used to capture unmatched values. Unmatched values:" + Environment.NewLine + + $"test1" + Environment.NewLine + $"test2", ex.Message); } @@ -407,8 +407,8 @@ public void SettingCaptureUnmatchedValuesParameterExplicitlyAndImplicitly_Revers // Assert Assert.Equal( $"The property '{nameof(HasCaptureUnmatchedValuesProperty.CaptureUnmatchedValues)}' on component type '{typeof(HasCaptureUnmatchedValuesProperty).FullName}' cannot be set explicitly when " + - $"also used to capture unmatched values. Unmatched values:" + System.Environment.NewLine + - $"test2" + System.Environment.NewLine + + $"also used to capture unmatched values. Unmatched values:" + Environment.NewLine + + $"test2" + Environment.NewLine + $"test1", ex.Message); } @@ -428,8 +428,8 @@ public void HasDuplicateCaptureUnmatchedValuesParameters_Throws() $"Multiple properties were found on component type '{typeof(HasDuplicateCaptureUnmatchedValuesProperty).FullName}' " + $"with '{nameof(ParameterAttribute)}.{nameof(ParameterAttribute.CaptureUnmatchedValues)}'. " + $"Only a single property per type can use '{nameof(ParameterAttribute)}.{nameof(ParameterAttribute.CaptureUnmatchedValues)}'. " + - $"Properties:" + System.Environment.NewLine + - $"{nameof(HasDuplicateCaptureUnmatchedValuesProperty.CaptureUnmatchedValuesProp1)}" + System.Environment.NewLine + + $"Properties:" + Environment.NewLine + + $"{nameof(HasDuplicateCaptureUnmatchedValuesProperty.CaptureUnmatchedValuesProp1)}" + Environment.NewLine + $"{nameof(HasDuplicateCaptureUnmatchedValuesProperty.CaptureUnmatchedValuesProp2)}", ex.Message); } diff --git a/src/Components/Components/src/EnvironmentComponent.cs b/src/Components/Web/src/EnvironmentBoundary.cs similarity index 91% rename from src/Components/Components/src/EnvironmentComponent.cs rename to src/Components/Web/src/EnvironmentBoundary.cs index 47a38724aef0..07cefb6ff33d 100644 --- a/src/Components/Components/src/EnvironmentComponent.cs +++ b/src/Components/Web/src/EnvironmentBoundary.cs @@ -4,7 +4,7 @@ using Microsoft.AspNetCore.Components.Rendering; using Microsoft.Extensions.Hosting; -namespace Microsoft.AspNetCore.Components; +namespace Microsoft.AspNetCore.Components.Web; /// /// A component that renders its child content only when the current hosting environment @@ -17,23 +17,23 @@ namespace Microsoft.AspNetCore.Components; /// /// The following example shows how to conditionally render content based on the environment: /// -/// <Environment Include="Development"> +/// <EnvironmentBoundary Include="Development"> /// <div class="alert alert-warning"> /// You are running in Development mode. Debug features are enabled. /// </div> -/// </Environment> +/// </EnvironmentBoundary> /// -/// <Environment Include="Development, Staging"> +/// <EnvironmentBoundary Include="Development, Staging"> /// <p>This is a pre-production environment.</p> -/// </Environment> +/// </EnvironmentBoundary> /// -/// <Environment Exclude="Production"> +/// <EnvironmentBoundary Exclude="Production"> /// <p>Debug information: @DateTime.Now</p> -/// </Environment> +/// </EnvironmentBoundary> /// /// /// -public sealed class Environment : ComponentBase +public sealed class EnvironmentBoundary : ComponentBase { private static readonly char[] NameSeparator = [',']; diff --git a/src/Components/Web/src/Microsoft.AspNetCore.Components.Web.csproj b/src/Components/Web/src/Microsoft.AspNetCore.Components.Web.csproj index 495a5b7529aa..294881d45b9b 100644 --- a/src/Components/Web/src/Microsoft.AspNetCore.Components.Web.csproj +++ b/src/Components/Web/src/Microsoft.AspNetCore.Components.Web.csproj @@ -23,6 +23,7 @@ + diff --git a/src/Components/Web/src/PublicAPI.Unshipped.txt b/src/Components/Web/src/PublicAPI.Unshipped.txt index 369f33715778..8a2990b22bfa 100644 --- a/src/Components/Web/src/PublicAPI.Unshipped.txt +++ b/src/Components/Web/src/PublicAPI.Unshipped.txt @@ -1,4 +1,12 @@ #nullable enable +Microsoft.AspNetCore.Components.Web.EnvironmentBoundary +Microsoft.AspNetCore.Components.Web.EnvironmentBoundary.ChildContent.get -> Microsoft.AspNetCore.Components.RenderFragment? +Microsoft.AspNetCore.Components.Web.EnvironmentBoundary.ChildContent.set -> void +Microsoft.AspNetCore.Components.Web.EnvironmentBoundary.EnvironmentBoundary() -> void +Microsoft.AspNetCore.Components.Web.EnvironmentBoundary.Exclude.get -> string? +Microsoft.AspNetCore.Components.Web.EnvironmentBoundary.Exclude.set -> void +Microsoft.AspNetCore.Components.Web.EnvironmentBoundary.Include.get -> string? +Microsoft.AspNetCore.Components.Web.EnvironmentBoundary.Include.set -> void Microsoft.AspNetCore.Components.Web.Media.FileDownload Microsoft.AspNetCore.Components.Web.Media.FileDownload.ChildContent.get -> Microsoft.AspNetCore.Components.RenderFragment? Microsoft.AspNetCore.Components.Web.Media.FileDownload.ChildContent.set -> void diff --git a/src/Components/Components/test/EnvironmentComponentTest.cs b/src/Components/Web/test/EnvironmentBoundaryTest.cs similarity index 75% rename from src/Components/Components/test/EnvironmentComponentTest.cs rename to src/Components/Web/test/EnvironmentBoundaryTest.cs index e93dc8827d4e..32e46d3c3f65 100644 --- a/src/Components/Components/test/EnvironmentComponentTest.cs +++ b/src/Components/Web/test/EnvironmentBoundaryTest.cs @@ -3,12 +3,13 @@ using Microsoft.AspNetCore.Components.Rendering; using Microsoft.AspNetCore.Components.Test.Helpers; +using Microsoft.AspNetCore.Components.Web; using Microsoft.Extensions.Hosting; using Moq; namespace Microsoft.AspNetCore.Components; -public class EnvironmentComponentTest +public class EnvironmentBoundaryTest { [Theory] [InlineData("Development", "Development")] @@ -57,12 +58,12 @@ public void ShowsContentWhenNoIncludeOrExcludeIsSpecified(string includeAttribut { // When no Include is specified and Exclude is not specified or empty, // the component should render its content - var (renderer, componentId) = CreateEnvironmentComponent(environmentName); + var (renderer, componentId) = CreateEnvironmentBoundaryComponent(environmentName); var parameters = ParameterView.FromDictionary(new Dictionary { - { nameof(Environment.Include), includeAttribute }, - { nameof(Environment.ChildContent), (RenderFragment)(builder => builder.AddContent(0, "Test Content")) }, + { nameof(EnvironmentBoundary.Include), includeAttribute }, + { nameof(EnvironmentBoundary.ChildContent), (RenderFragment)(builder => builder.AddContent(0, "Test Content")) }, }); renderer.RenderRootComponent(componentId, parameters); @@ -80,12 +81,12 @@ public void ShowsContentWhenNoIncludeOrExcludeIsSpecified(string includeAttribut [InlineData("Production,Staging", "Development")] public void ShowsContentWhenCurrentEnvironmentIsNotInExcludeList(string excludeAttribute, string environmentName) { - var (renderer, componentId) = CreateEnvironmentComponent(environmentName); + var (renderer, componentId) = CreateEnvironmentBoundaryComponent(environmentName); var parameters = ParameterView.FromDictionary(new Dictionary { - { nameof(Environment.Exclude), excludeAttribute }, - { nameof(Environment.ChildContent), (RenderFragment)(builder => builder.AddContent(0, "Test Content")) }, + { nameof(EnvironmentBoundary.Exclude), excludeAttribute }, + { nameof(EnvironmentBoundary.ChildContent), (RenderFragment)(builder => builder.AddContent(0, "Test Content")) }, }); renderer.RenderRootComponent(componentId, parameters); @@ -104,12 +105,12 @@ public void ShowsContentWhenCurrentEnvironmentIsNotInExcludeList(string excludeA [InlineData("Production,Development", "Development")] public void HidesContentWhenCurrentEnvironmentIsInExcludeList(string excludeAttribute, string environmentName) { - var (renderer, componentId) = CreateEnvironmentComponent(environmentName); + var (renderer, componentId) = CreateEnvironmentBoundaryComponent(environmentName); var parameters = ParameterView.FromDictionary(new Dictionary { - { nameof(Environment.Exclude), excludeAttribute }, - { nameof(Environment.ChildContent), (RenderFragment)(builder => builder.AddContent(0, "Test Content")) }, + { nameof(EnvironmentBoundary.Exclude), excludeAttribute }, + { nameof(EnvironmentBoundary.ChildContent), (RenderFragment)(builder => builder.AddContent(0, "Test Content")) }, }); renderer.RenderRootComponent(componentId, parameters); @@ -125,13 +126,13 @@ public void HidesContentWhenCurrentEnvironmentIsInExcludeList(string excludeAttr [InlineData("Development,Staging", "Staging", "Staging")] // In include, also in exclude -> hide public void ExcludeTakesPrecedenceOverInclude(string includeAttribute, string excludeAttribute, string environmentName) { - var (renderer, componentId) = CreateEnvironmentComponent(environmentName); + var (renderer, componentId) = CreateEnvironmentBoundaryComponent(environmentName); var parameters = ParameterView.FromDictionary(new Dictionary { - { nameof(Environment.Include), includeAttribute }, - { nameof(Environment.Exclude), excludeAttribute }, - { nameof(Environment.ChildContent), (RenderFragment)(builder => builder.AddContent(0, "Test Content")) }, + { nameof(EnvironmentBoundary.Include), includeAttribute }, + { nameof(EnvironmentBoundary.Exclude), excludeAttribute }, + { nameof(EnvironmentBoundary.ChildContent), (RenderFragment)(builder => builder.AddContent(0, "Test Content")) }, }); renderer.RenderRootComponent(componentId, parameters); @@ -147,13 +148,13 @@ public void ExcludeTakesPrecedenceOverInclude(string includeAttribute, string ex [InlineData("Development,Staging", "Production", "Staging")] // In include, not in exclude -> show public void ShowsContentWhenInIncludeButNotInExclude(string includeAttribute, string excludeAttribute, string environmentName) { - var (renderer, componentId) = CreateEnvironmentComponent(environmentName); + var (renderer, componentId) = CreateEnvironmentBoundaryComponent(environmentName); var parameters = ParameterView.FromDictionary(new Dictionary { - { nameof(Environment.Include), includeAttribute }, - { nameof(Environment.Exclude), excludeAttribute }, - { nameof(Environment.ChildContent), (RenderFragment)(builder => builder.AddContent(0, "Test Content")) }, + { nameof(EnvironmentBoundary.Include), includeAttribute }, + { nameof(EnvironmentBoundary.Exclude), excludeAttribute }, + { nameof(EnvironmentBoundary.ChildContent), (RenderFragment)(builder => builder.AddContent(0, "Test Content")) }, }); renderer.RenderRootComponent(componentId, parameters); @@ -172,12 +173,12 @@ public void ShowsContentWhenInIncludeButNotInExclude(string includeAttribute, st [InlineData("\t")] public void HidesContentWhenEnvironmentNameIsNullOrEmpty(string environmentName) { - var (renderer, componentId) = CreateEnvironmentComponent(environmentName); + var (renderer, componentId) = CreateEnvironmentBoundaryComponent(environmentName); var parameters = ParameterView.FromDictionary(new Dictionary { - { nameof(Environment.Include), "Development" }, - { nameof(Environment.ChildContent), (RenderFragment)(builder => builder.AddContent(0, "Test Content")) }, + { nameof(EnvironmentBoundary.Include), "Development" }, + { nameof(EnvironmentBoundary.ChildContent), (RenderFragment)(builder => builder.AddContent(0, "Test Content")) }, }); renderer.RenderRootComponent(componentId, parameters); @@ -191,11 +192,11 @@ public void HidesContentWhenEnvironmentNameIsNullOrEmpty(string environmentName) [Fact] public void RendersNothingWhenChildContentIsNull() { - var (renderer, componentId) = CreateEnvironmentComponent("Development"); + var (renderer, componentId) = CreateEnvironmentBoundaryComponent("Development"); var parameters = ParameterView.FromDictionary(new Dictionary { - { nameof(Environment.Include), "Development" }, + { nameof(EnvironmentBoundary.Include), "Development" }, }); renderer.RenderRootComponent(componentId, parameters); @@ -206,12 +207,12 @@ public void RendersNothingWhenChildContentIsNull() private void ShouldShowContentWithInclude(string includeAttribute, string environmentName) { - var (renderer, componentId) = CreateEnvironmentComponent(environmentName); + var (renderer, componentId) = CreateEnvironmentBoundaryComponent(environmentName); var parameters = ParameterView.FromDictionary(new Dictionary { - { nameof(Environment.Include), includeAttribute }, - { nameof(Environment.ChildContent), (RenderFragment)(builder => builder.AddContent(0, "Test Content")) }, + { nameof(EnvironmentBoundary.Include), includeAttribute }, + { nameof(EnvironmentBoundary.ChildContent), (RenderFragment)(builder => builder.AddContent(0, "Test Content")) }, }); renderer.RenderRootComponent(componentId, parameters); @@ -224,12 +225,12 @@ private void ShouldShowContentWithInclude(string includeAttribute, string enviro private void ShouldHideContentWithInclude(string includeAttribute, string environmentName) { - var (renderer, componentId) = CreateEnvironmentComponent(environmentName); + var (renderer, componentId) = CreateEnvironmentBoundaryComponent(environmentName); var parameters = ParameterView.FromDictionary(new Dictionary { - { nameof(Environment.Include), includeAttribute }, - { nameof(Environment.ChildContent), (RenderFragment)(builder => builder.AddContent(0, "Test Content")) }, + { nameof(EnvironmentBoundary.Include), includeAttribute }, + { nameof(EnvironmentBoundary.ChildContent), (RenderFragment)(builder => builder.AddContent(0, "Test Content")) }, }); renderer.RenderRootComponent(componentId, parameters); @@ -240,7 +241,7 @@ private void ShouldHideContentWithInclude(string includeAttribute, string enviro frame.TextContent == "Test Content"); } - private static (TestRenderer Renderer, int ComponentId) CreateEnvironmentComponent(string environmentName) + private static (TestRenderer Renderer, int ComponentId) CreateEnvironmentBoundaryComponent(string environmentName) { var serviceProvider = new TestServiceProvider(); var hostEnvironment = new Mock(); @@ -248,7 +249,7 @@ private static (TestRenderer Renderer, int ComponentId) CreateEnvironmentCompone serviceProvider.AddService(hostEnvironment.Object); var renderer = new TestRenderer(serviceProvider); - var component = (Environment)renderer.InstantiateComponent(); + var component = (EnvironmentBoundary)renderer.InstantiateComponent(); var componentId = renderer.AssignRootComponentId(component); return (renderer, componentId); diff --git a/src/Components/Web/test/Microsoft.AspNetCore.Components.Web.Tests.csproj b/src/Components/Web/test/Microsoft.AspNetCore.Components.Web.Tests.csproj index d4fe6205d0ad..b065dbe59dfd 100644 --- a/src/Components/Web/test/Microsoft.AspNetCore.Components.Web.Tests.csproj +++ b/src/Components/Web/test/Microsoft.AspNetCore.Components.Web.Tests.csproj @@ -10,6 +10,7 @@ + diff --git a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyCultureProvider.cs b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyCultureProvider.cs index 471757d74e8b..4d9f51634da0 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyCultureProvider.cs +++ b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyCultureProvider.cs @@ -43,7 +43,7 @@ public void ThrowIfCultureChangeIsUnsupported() // It allows us to capture the initial .NET culture that is configured based on the browser language. // The current method is invoked as part of WebAssemblyHost.RunAsync i.e. after user code in Program.MainAsync has run // thus allows us to detect if the culture was changed by user code. - if (System.Environment.GetEnvironmentVariable("__BLAZOR_SHARDED_ICU") == "1" && + if (Environment.GetEnvironmentVariable("__BLAZOR_SHARDED_ICU") == "1" && (!CultureInfo.CurrentCulture.Name.Equals(InitialCulture.Name, StringComparison.Ordinal))) { throw new InvalidOperationException("Blazor detected a change in the application's culture that is not supported with the current project configuration. " + diff --git a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHost.cs b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHost.cs index 119f88040d77..afc21cf445ab 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHost.cs +++ b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHost.cs @@ -149,7 +149,7 @@ internal async Task RunAsyncCore(CancellationToken cancellationToken, WebAssembl WebAssemblyNavigationManager.Instance.CreateLogger(loggerFactory); RootComponentOperationBatch? initialOperationBatch = null; - if (System.Environment.GetEnvironmentVariable("__BLAZOR_WEBASSEMBLY_WAIT_FOR_ROOT_COMPONENTS") == "true") + if (Environment.GetEnvironmentVariable("__BLAZOR_WEBASSEMBLY_WAIT_FOR_ROOT_COMPONENTS") == "true") { // In Blazor web, we wait for the JS side to tell us about the components available // before we render the initial set of components. Any additional update goes through diff --git a/src/Components/WebAssembly/WebAssembly/src/Rendering/WebAssemblyDispatcher.cs b/src/Components/WebAssembly/WebAssembly/src/Rendering/WebAssemblyDispatcher.cs index 3705101c7006..1928b8224736 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Rendering/WebAssemblyDispatcher.cs +++ b/src/Components/WebAssembly/WebAssembly/src/Rendering/WebAssemblyDispatcher.cs @@ -13,7 +13,7 @@ internal sealed class WebAssemblyDispatcher : Dispatcher internal static int _mainManagedThreadId; // we really need the UI thread not just the right context, because JS objects have thread affinity - public override bool CheckAccess() => _mainManagedThreadId == System.Environment.CurrentManagedThreadId; + public override bool CheckAccess() => _mainManagedThreadId == Environment.CurrentManagedThreadId; public override Task InvokeAsync(Action workItem) { diff --git a/src/Components/WebAssembly/WebAssembly/src/Services/WebAssemblyConsoleLogger.cs b/src/Components/WebAssembly/WebAssembly/src/Services/WebAssemblyConsoleLogger.cs index 17edbf5f9f6a..c2328cc47dc2 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Services/WebAssemblyConsoleLogger.cs +++ b/src/Components/WebAssembly/WebAssembly/src/Services/WebAssemblyConsoleLogger.cs @@ -14,7 +14,7 @@ internal sealed class WebAssemblyConsoleLogger : ILogger, ILogger { private const string _loglevelPadding = ": "; private static readonly string _messagePadding = new(' ', GetLogLevelString(LogLevel.Information).Length + _loglevelPadding.Length); - private static readonly string _newLineWithMessagePadding = System.Environment.NewLine + _messagePadding; + private static readonly string _newLineWithMessagePadding = Environment.NewLine + _messagePadding; private static readonly StringBuilder _logBuilder = new StringBuilder(); private readonly string _name; @@ -121,7 +121,7 @@ private static void CreateDefaultLogMessage(StringBuilder logBuilder, LogLevel l var len = logBuilder.Length; logBuilder.Append(message); - logBuilder.Replace(System.Environment.NewLine, _newLineWithMessagePadding, len, message.Length); + logBuilder.Replace(Environment.NewLine, _newLineWithMessagePadding, len, message.Length); } // Example: diff --git a/src/Components/WebAssembly/WebAssembly/test/Hosting/WebAssemblyCultureProviderTest.cs b/src/Components/WebAssembly/WebAssembly/test/Hosting/WebAssemblyCultureProviderTest.cs index b7e8d142aace..2406a919b2f6 100644 --- a/src/Components/WebAssembly/WebAssembly/test/Hosting/WebAssemblyCultureProviderTest.cs +++ b/src/Components/WebAssembly/WebAssembly/test/Hosting/WebAssemblyCultureProviderTest.cs @@ -43,7 +43,7 @@ public void GetCultures_ReturnCultureClosureWithUICulture(string cultureName, st public void ThrowIfCultureChangeIsUnsupported_ThrowsIfCulturesAreDifferentAndICUShardingIsUsed() { // Arrange - System.Environment.SetEnvironmentVariable("__BLAZOR_SHARDED_ICU", "1"); + Environment.SetEnvironmentVariable("__BLAZOR_SHARDED_ICU", "1"); try { // WebAssembly is initialized with en-US @@ -59,7 +59,7 @@ public void ThrowIfCultureChangeIsUnsupported_ThrowsIfCulturesAreDifferentAndICU } finally { - System.Environment.SetEnvironmentVariable("__BLAZOR_SHARDED_ICU", null); + Environment.SetEnvironmentVariable("__BLAZOR_SHARDED_ICU", null); } } } diff --git a/src/Components/test/E2ETest/Tests/EnvironmentBoundaryTest.cs b/src/Components/test/E2ETest/Tests/EnvironmentBoundaryTest.cs new file mode 100644 index 000000000000..118e82715c70 --- /dev/null +++ b/src/Components/test/E2ETest/Tests/EnvironmentBoundaryTest.cs @@ -0,0 +1,67 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using BasicTestApp; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; +using Microsoft.AspNetCore.E2ETesting; +using OpenQA.Selenium; +using Xunit.Abstractions; + +namespace Microsoft.AspNetCore.Components.E2ETest.Tests; + +public class EnvironmentBoundaryTest : ServerTestBase> +{ + public EnvironmentBoundaryTest( + BrowserFixture browserFixture, + ToggleExecutionModeServerFixture serverFixture, + ITestOutputHelper output) + : base(browserFixture, serverFixture, output) + { + } + + protected override void InitializeAsyncCore() + { + Navigate(ServerPathBase); + Browser.MountTestComponent(); + } + + [Fact] + public void RendersContentWhenEnvironmentMatches() + { + // By default, E2E test runs in Development environment + var container = Browser.Exists(By.Id("environment-boundary-test")); + + // Verify Development-specific content is visible + var devContent = container.FindElement(By.Id("dev-only-content")); + Assert.Equal("This content is only visible in Development.", devContent.Text); + + // Verify non-production content is visible (we're in Development) + var nonProdContent = container.FindElement(By.Id("non-production-content")); + Assert.Equal("This content is visible in all environments except Production.", nonProdContent.Text); + + // Verify Development+Staging content is visible + var devStagingContent = container.FindElement(By.Id("dev-staging-content")); + Assert.Equal("This content is visible in Development and Staging.", devStagingContent.Text); + } + + [Fact] + public void HidesContentWhenEnvironmentDoesNotMatch() + { + var container = Browser.Exists(By.Id("environment-boundary-test")); + + // Production-only content should not be visible in Development + Browser.Empty(() => container.FindElements(By.Id("prod-only-content"))); + + // Content excluded from Development should not be visible + Browser.Empty(() => container.FindElements(By.Id("non-dev-content"))); + } + + [Fact] + public void DisplaysCurrentEnvironment() + { + // Verify the environment is displayed correctly + var currentEnvElement = Browser.Exists(By.Id("current-environment")); + Assert.Contains("Development", currentEnvElement.Text); + } +} diff --git a/src/Components/test/testassets/BasicTestApp/EnvironmentBoundaryContainer.razor b/src/Components/test/testassets/BasicTestApp/EnvironmentBoundaryContainer.razor new file mode 100644 index 000000000000..015a1bd342b4 --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/EnvironmentBoundaryContainer.razor @@ -0,0 +1,42 @@ +@using Microsoft.AspNetCore.Components.Web + +

Environment Boundary Test

+ +
+ +
+ This content is only visible in Development. +
+
+ + +
+ This content is visible in all environments except Production. +
+
+ + +
+ This content is visible in Development and Staging. +
+
+ + +
+ This content is only visible in Production. +
+
+ + +
+ This content is visible in all environments except Development. +
+
+
+ +

Current Environment: @HostEnvironment.EnvironmentName

+ +@code { + [Inject] + public Microsoft.Extensions.Hosting.IHostEnvironment HostEnvironment { get; set; } = default!; +} From 4b1e0c7e075449efecc5a0da82e6bdeb336d791a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 3 Dec 2025 16:14:44 +0000 Subject: [PATCH 5/7] Remove redundant Microsoft.Extensions.Hosting.Abstractions references and XML doc comments Co-authored-by: javiercn <6995051+javiercn@users.noreply.github.com> --- .../Web/test/Microsoft.AspNetCore.Components.Web.Tests.csproj | 1 - .../src/Hosting/WebAssemblyHostEnvironmentAdapter.cs | 3 --- .../src/Microsoft.AspNetCore.Components.WebAssembly.csproj | 1 - 3 files changed, 5 deletions(-) diff --git a/src/Components/Web/test/Microsoft.AspNetCore.Components.Web.Tests.csproj b/src/Components/Web/test/Microsoft.AspNetCore.Components.Web.Tests.csproj index b065dbe59dfd..d4fe6205d0ad 100644 --- a/src/Components/Web/test/Microsoft.AspNetCore.Components.Web.Tests.csproj +++ b/src/Components/Web/test/Microsoft.AspNetCore.Components.Web.Tests.csproj @@ -10,7 +10,6 @@ -
diff --git a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostEnvironmentAdapter.cs b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostEnvironmentAdapter.cs index 900a875bf44a..bfa262f70c2d 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostEnvironmentAdapter.cs +++ b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostEnvironmentAdapter.cs @@ -6,9 +6,6 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting; -/// -/// An implementation of that wraps . -/// internal sealed class WebAssemblyHostEnvironmentAdapter : IHostEnvironment { private readonly IWebAssemblyHostEnvironment _webAssemblyHostEnvironment; diff --git a/src/Components/WebAssembly/WebAssembly/src/Microsoft.AspNetCore.Components.WebAssembly.csproj b/src/Components/WebAssembly/WebAssembly/src/Microsoft.AspNetCore.Components.WebAssembly.csproj index 291249ed6766..ba479f5352f7 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Microsoft.AspNetCore.Components.WebAssembly.csproj +++ b/src/Components/WebAssembly/WebAssembly/src/Microsoft.AspNetCore.Components.WebAssembly.csproj @@ -19,7 +19,6 @@ - From d2cbc231171632f6aa6a08ac9a66b5db428dc689 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 3 Dec 2025 22:53:31 +0000 Subject: [PATCH 6/7] Fix E2E test by adding EnvironmentBoundaryContainer to Index.razor and align null environment behavior with MVC Co-authored-by: javiercn <6995051+javiercn@users.noreply.github.com> --- src/Components/Web/src/EnvironmentBoundary.cs | 7 ++++++ .../Web/test/EnvironmentBoundaryTest.cs | 23 +++++++++++++++++++ .../test/testassets/BasicTestApp/Index.razor | 1 + 3 files changed, 31 insertions(+) diff --git a/src/Components/Web/src/EnvironmentBoundary.cs b/src/Components/Web/src/EnvironmentBoundary.cs index 07cefb6ff33d..2ef9bdb52476 100644 --- a/src/Components/Web/src/EnvironmentBoundary.cs +++ b/src/Components/Web/src/EnvironmentBoundary.cs @@ -80,6 +80,13 @@ private bool ShouldRenderContent() if (string.IsNullOrEmpty(currentEnvironmentName)) { + // For consistency with MVC EnvironmentTagHelper, render content when environment name is not set + // and no Include/Exclude are specified. + if (string.IsNullOrWhiteSpace(Include) && string.IsNullOrWhiteSpace(Exclude)) + { + return true; + } + return false; } diff --git a/src/Components/Web/test/EnvironmentBoundaryTest.cs b/src/Components/Web/test/EnvironmentBoundaryTest.cs index 32e46d3c3f65..95e1aa901705 100644 --- a/src/Components/Web/test/EnvironmentBoundaryTest.cs +++ b/src/Components/Web/test/EnvironmentBoundaryTest.cs @@ -189,6 +189,29 @@ public void HidesContentWhenEnvironmentNameIsNullOrEmpty(string environmentName) frame.TextContent == "Test Content"); } + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ShowsContentWhenEnvironmentNameIsNullOrEmptyAndNoIncludeExcludeSpecified(string environmentName) + { + // For consistency with MVC EnvironmentTagHelper, render content when environment name is not set + // and no Include/Exclude are specified. + var (renderer, componentId) = CreateEnvironmentBoundaryComponent(environmentName); + + var parameters = ParameterView.FromDictionary(new Dictionary + { + { nameof(EnvironmentBoundary.ChildContent), (RenderFragment)(builder => builder.AddContent(0, "Test Content")) }, + }); + + renderer.RenderRootComponent(componentId, parameters); + + var batch = renderer.Batches.Single(); + Assert.Contains(batch.ReferenceFrames, frame => + frame.FrameType == RenderTree.RenderTreeFrameType.Text && + frame.TextContent == "Test Content"); + } + [Fact] public void RendersNothingWhenChildContentIsNull() { diff --git a/src/Components/test/testassets/BasicTestApp/Index.razor b/src/Components/test/testassets/BasicTestApp/Index.razor index f9cc718f7ca1..0b02a85af02c 100644 --- a/src/Components/test/testassets/BasicTestApp/Index.razor +++ b/src/Components/test/testassets/BasicTestApp/Index.razor @@ -31,6 +31,7 @@ + From 8eda1825e41de27dc9d3563fc0f748b690bdf9f4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Dec 2025 11:50:36 +0000 Subject: [PATCH 7/7] Fix E2E test: use Browser.DoesNotExist and wait for environment before assertions Co-authored-by: javiercn <6995051+javiercn@users.noreply.github.com> --- .../test/E2ETest/Tests/EnvironmentBoundaryTest.cs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/Components/test/E2ETest/Tests/EnvironmentBoundaryTest.cs b/src/Components/test/E2ETest/Tests/EnvironmentBoundaryTest.cs index 118e82715c70..96587c035aad 100644 --- a/src/Components/test/E2ETest/Tests/EnvironmentBoundaryTest.cs +++ b/src/Components/test/E2ETest/Tests/EnvironmentBoundaryTest.cs @@ -29,7 +29,10 @@ protected override void InitializeAsyncCore() [Fact] public void RendersContentWhenEnvironmentMatches() { - // By default, E2E test runs in Development environment + // Wait for the component to render and verify it's in Development + var currentEnvElement = Browser.Exists(By.Id("current-environment")); + Browser.Contains("Development", () => currentEnvElement.Text); + var container = Browser.Exists(By.Id("environment-boundary-test")); // Verify Development-specific content is visible @@ -48,13 +51,17 @@ public void RendersContentWhenEnvironmentMatches() [Fact] public void HidesContentWhenEnvironmentDoesNotMatch() { + // Wait for the component to render and verify it's in Development + var currentEnvElement = Browser.Exists(By.Id("current-environment")); + Browser.Contains("Development", () => currentEnvElement.Text); + var container = Browser.Exists(By.Id("environment-boundary-test")); // Production-only content should not be visible in Development - Browser.Empty(() => container.FindElements(By.Id("prod-only-content"))); + Browser.DoesNotExist(By.Id("prod-only-content")); // Content excluded from Development should not be visible - Browser.Empty(() => container.FindElements(By.Id("non-dev-content"))); + Browser.DoesNotExist(By.Id("non-dev-content")); } [Fact] @@ -62,6 +69,6 @@ public void DisplaysCurrentEnvironment() { // Verify the environment is displayed correctly var currentEnvElement = Browser.Exists(By.Id("current-environment")); - Assert.Contains("Development", currentEnvElement.Text); + Browser.Contains("Development", () => currentEnvElement.Text); } }