diff --git a/src/BuiltInTools/dotnet-watch/HotReload/BlazorWebAssemblyDeltaApplier.cs b/src/BuiltInTools/dotnet-watch/HotReload/BlazorWebAssemblyDeltaApplier.cs index 701e9edb8254..2b7b8bf560a8 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/BlazorWebAssemblyDeltaApplier.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/BlazorWebAssemblyDeltaApplier.cs @@ -3,18 +3,26 @@ using System.Buffers; using System.Collections.Immutable; +using Microsoft.Build.Graph; using Microsoft.CodeAnalysis.ExternalAccess.Watch.Api; namespace Microsoft.DotNet.Watch { - internal sealed class BlazorWebAssemblyDeltaApplier(IReporter reporter, BrowserRefreshServer browserRefreshServer, Version? targetFrameworkVersion) : SingleProcessDeltaApplier(reporter) + internal sealed class BlazorWebAssemblyDeltaApplier(IReporter reporter, BrowserRefreshServer browserRefreshServer, ProjectGraphNode project) : SingleProcessDeltaApplier(reporter) { - private const string DefaultCapabilities60 = "Baseline"; - private const string DefaultCapabilities70 = "Baseline AddMethodToExistingType AddStaticFieldToExistingType NewTypeDefinition ChangeCustomAttributes"; - private const string DefaultCapabilities80 = "Baseline AddMethodToExistingType AddStaticFieldToExistingType NewTypeDefinition ChangeCustomAttributes AddInstanceFieldToExistingType GenericAddMethodToExistingType GenericUpdateMethod UpdateParameters GenericAddFieldToExistingType"; + private static readonly ImmutableArray s_defaultCapabilities60 = + ["Baseline"]; + + private static readonly ImmutableArray s_defaultCapabilities70 = + ["Baseline", "AddMethodToExistingType", "AddStaticFieldToExistingType", "NewTypeDefinition", "ChangeCustomAttributes"]; + + private static readonly ImmutableArray s_defaultCapabilities80 = + ["Baseline", "AddMethodToExistingType", "AddStaticFieldToExistingType", "NewTypeDefinition", "ChangeCustomAttributes", + "AddInstanceFieldToExistingType", "GenericAddMethodToExistingType", "GenericUpdateMethod", "UpdateParameters", "GenericAddFieldToExistingType"]; + + private static readonly ImmutableArray s_defaultCapabilities90 = + s_defaultCapabilities80; - private ImmutableArray _cachedCapabilities; - private readonly SemaphoreSlim _capabilityRetrievalSemaphore = new(initialCount: 1); private int _updateId; public override void Dispose() @@ -31,109 +39,31 @@ public override async Task WaitForProcessRunningAsync(CancellationToken cancella // Alternatively, we could inject agent into blazor-devserver.dll and establish a connection on the named pipe. => await browserRefreshServer.WaitForClientConnectionAsync(cancellationToken); - public override async Task> GetApplyUpdateCapabilitiesAsync(CancellationToken cancellationToken) + public override Task> GetApplyUpdateCapabilitiesAsync(CancellationToken cancellationToken) { - var cachedCapabilities = _cachedCapabilities; - if (!cachedCapabilities.IsDefault) - { - return cachedCapabilities; - } + var capabilities = project.GetWebAssemblyCapabilities(); - await _capabilityRetrievalSemaphore.WaitAsync(cancellationToken); - try - { - if (_cachedCapabilities.IsDefault) - { - _cachedCapabilities = await RetrieveAsync(cancellationToken); - } - } - finally + if (capabilities.IsEmpty) { - _capabilityRetrievalSemaphore.Release(); - } + var targetFramework = project.GetTargetFrameworkVersion(); - return _cachedCapabilities; - - async Task> RetrieveAsync(CancellationToken cancellationToken) - { - var buffer = ArrayPool.Shared.Rent(32 * 1024); + Reporter.Verbose($"Using capabilities based on target framework: '{targetFramework}'."); - try + capabilities = targetFramework?.Major switch { - Reporter.Verbose("Connecting to the browser."); - - await browserRefreshServer.WaitForClientConnectionAsync(cancellationToken); - - string capabilities; - if (browserRefreshServer.Options.TestFlags.HasFlag(TestFlags.MockBrowser)) - { - // When testing return default capabilities without connecting to an actual browser. - capabilities = GetDefaultCapabilities(targetFrameworkVersion); - } - else - { - string? capabilityString = null; - - await browserRefreshServer.SendAndReceiveAsync( - request: _ => default(JsonGetApplyUpdateCapabilitiesRequest), - response: (value, reporter) => - { - var str = Encoding.UTF8.GetString(value); - if (str.StartsWith('!')) - { - reporter.Verbose($"Exception while reading WASM runtime capabilities: {str[1..]}"); - } - else if (str.Length == 0) - { - reporter.Verbose($"Unable to read WASM runtime capabilities"); - } - else if (capabilityString == null) - { - capabilityString = str; - } - else if (capabilityString != str) - { - reporter.Verbose($"Received different capabilities from different browsers:{Environment.NewLine}'{str}'{Environment.NewLine}'{capabilityString}'"); - } - }, - cancellationToken); - - if (capabilityString != null) - { - capabilities = capabilityString; - } - else - { - capabilities = GetDefaultCapabilities(targetFrameworkVersion); - Reporter.Verbose($"Falling back to default WASM capabilities: '{capabilities}'"); - } - } - - // Capabilities are expressed a space-separated string. - // e.g. https://github.com/dotnet/runtime/blob/14343bdc281102bf6fffa1ecdd920221d46761bc/src/coreclr/System.Private.CoreLib/src/System/Reflection/Metadata/AssemblyExtensions.cs#L87 - return capabilities.Split(' ').ToImmutableArray(); - } - catch (Exception e) when (!cancellationToken.IsCancellationRequested) - { - Reporter.Error($"Failed to read capabilities: {e.Message}"); - - // Do not attempt to retrieve capabilities again if it fails once, unless the operation is canceled. - return []; - } - finally - { - ArrayPool.Shared.Return(buffer); - } + 9 => s_defaultCapabilities90, + 8 => s_defaultCapabilities80, + 7 => s_defaultCapabilities70, + 6 => s_defaultCapabilities60, + _ => [], + }; + } + else + { + Reporter.Verbose($"Project specifies capabilities."); } - static string GetDefaultCapabilities(Version? targetFrameworkVersion) - => targetFrameworkVersion?.Major switch - { - >= 8 => DefaultCapabilities80, - >= 7 => DefaultCapabilities70, - >= 6 => DefaultCapabilities60, - _ => string.Empty, - }; + return Task.FromResult(capabilities); } public override async Task Apply(ImmutableArray updates, CancellationToken cancellationToken) diff --git a/src/BuiltInTools/dotnet-watch/HotReload/BlazorWebAssemblyHostedDeltaApplier.cs b/src/BuiltInTools/dotnet-watch/HotReload/BlazorWebAssemblyHostedDeltaApplier.cs index 1eda8a6f7986..f2eb00de24bc 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/BlazorWebAssemblyHostedDeltaApplier.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/BlazorWebAssemblyHostedDeltaApplier.cs @@ -3,13 +3,14 @@ using System.Collections.Immutable; +using Microsoft.Build.Graph; using Microsoft.CodeAnalysis.ExternalAccess.Watch.Api; namespace Microsoft.DotNet.Watch { - internal sealed class BlazorWebAssemblyHostedDeltaApplier(IReporter reporter, BrowserRefreshServer browserRefreshServer, Version? targetFrameworkVersion) : DeltaApplier(reporter) + internal sealed class BlazorWebAssemblyHostedDeltaApplier(IReporter reporter, BrowserRefreshServer browserRefreshServer, ProjectGraphNode project) : DeltaApplier(reporter) { - private readonly BlazorWebAssemblyDeltaApplier _wasmApplier = new(reporter, browserRefreshServer, targetFrameworkVersion); + private readonly BlazorWebAssemblyDeltaApplier _wasmApplier = new(reporter, browserRefreshServer, project); private readonly DefaultDeltaApplier _hostApplier = new(reporter); public override void Dispose() diff --git a/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs b/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs index b64873f7a74d..47c54b8657d1 100644 --- a/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs +++ b/src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs @@ -99,11 +99,11 @@ public async ValueTask StartSessionAsync(CancellationToken cancellationToken) _reporter.Report(MessageDescriptor.HotReloadSessionStarted); } - private static DeltaApplier CreateDeltaApplier(HotReloadProfile profile, Version? targetFramework, BrowserRefreshServer? browserRefreshServer, IReporter processReporter) + private static DeltaApplier CreateDeltaApplier(HotReloadProfile profile, ProjectGraphNode project, BrowserRefreshServer? browserRefreshServer, IReporter processReporter) => profile switch { - HotReloadProfile.BlazorWebAssembly => new BlazorWebAssemblyDeltaApplier(processReporter, browserRefreshServer!, targetFramework), - HotReloadProfile.BlazorHosted => new BlazorWebAssemblyHostedDeltaApplier(processReporter, browserRefreshServer!, targetFramework), + HotReloadProfile.BlazorWebAssembly => new BlazorWebAssemblyDeltaApplier(processReporter, browserRefreshServer!, project), + HotReloadProfile.BlazorHosted => new BlazorWebAssemblyHostedDeltaApplier(processReporter, browserRefreshServer!, project), _ => new DefaultDeltaApplier(processReporter), }; @@ -121,8 +121,7 @@ private static DeltaApplier CreateDeltaApplier(HotReloadProfile profile, Version { var projectPath = projectNode.ProjectInstance.FullPath; - var targetFramework = projectNode.GetTargetFrameworkVersion(); - var deltaApplier = CreateDeltaApplier(profile, targetFramework, browserRefreshServer, processReporter); + var deltaApplier = CreateDeltaApplier(profile, projectNode, browserRefreshServer, processReporter); var processExitedSource = new CancellationTokenSource(); var processCommunicationCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(processExitedSource.Token, cancellationToken); diff --git a/src/BuiltInTools/dotnet-watch/Utilities/ProjectGraphNodeExtensions.cs b/src/BuiltInTools/dotnet-watch/Utilities/ProjectGraphNodeExtensions.cs index dc758ef74305..ae520eaff1ba 100644 --- a/src/BuiltInTools/dotnet-watch/Utilities/ProjectGraphNodeExtensions.cs +++ b/src/BuiltInTools/dotnet-watch/Utilities/ProjectGraphNodeExtensions.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Immutable; using Microsoft.Build.Graph; using Microsoft.DotNet.Cli; @@ -17,6 +18,9 @@ public static string GetTargetFramework(this ProjectGraphNode projectNode) public static Version? GetTargetFrameworkVersion(this ProjectGraphNode projectNode) => EnvironmentVariableNames.TryParseTargetFrameworkVersion(projectNode.ProjectInstance.GetPropertyValue("TargetFrameworkVersion")); + public static ImmutableArray GetWebAssemblyCapabilities(this ProjectGraphNode projectNode) + => [.. projectNode.ProjectInstance.GetPropertyValue("WebAssemblyHotReloadCapabilities").Split(';').Select(static c => c.Trim()).Where(static c => c != "")]; + public static bool IsTargetFrameworkVersionOrNewer(this ProjectGraphNode projectNode, Version minVersion) => GetTargetFrameworkVersion(projectNode) is { } version && version >= minVersion; diff --git a/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs b/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs index a2060d3f9470..01c2eeae89d8 100644 --- a/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs +++ b/test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs @@ -231,12 +231,25 @@ class AppUpdateHandler } } - [Fact] - public async Task BlazorWasm() + [Theory] + [CombinatorialData] + public async Task BlazorWasm(bool projectSpecifiesCapabilities) { - var testAsset = TestAssets.CopyTestAsset("WatchBlazorWasm") + var testAsset = TestAssets.CopyTestAsset("WatchBlazorWasm", identifier: projectSpecifiesCapabilities.ToString()) .WithSource(); + if (projectSpecifiesCapabilities) + { + testAsset = testAsset.WithProjectChanges(proj => + { + proj.Root.Descendants() + .First(e => e.Name.LocalName == "PropertyGroup") + .Add(XElement.Parse(""" + Baseline;AddMethodToExistingType + """)); + }); + } + var port = TestOptions.GetTestPort(); App.Start(testAsset, ["--urls", "http://localhost:" + port], testFlags: TestFlags.MockBrowser); @@ -256,6 +269,16 @@ public async Task BlazorWasm() UpdateSourceFile(Path.Combine(testAsset.Path, "Pages", "Index.razor"), newSource); await App.AssertOutputLineStartsWith(MessageDescriptor.HotReloadSucceeded, "blazorwasm (net9.0)"); + + // check project specified capapabilities: + if (projectSpecifiesCapabilities) + { + App.AssertOutputContains("dotnet watch 🔥 Hot reload capabilities: Baseline AddMethodToExistingType."); + } + else + { + App.AssertOutputContains("dotnet watch 🔥 Hot reload capabilities: Baseline AddMethodToExistingType AddStaticFieldToExistingType NewTypeDefinition ChangeCustomAttributes AddInstanceFieldToExistingType GenericAddMethodToExistingType GenericUpdateMethod UpdateParameters GenericAddFieldToExistingType."); + } } [Fact]