diff --git a/src/Components/Components/src/ComponentBase.cs b/src/Components/Components/src/ComponentBase.cs index 1780a78a6a84..56cf2f3d6138 100644 --- a/src/Components/Components/src/ComponentBase.cs +++ b/src/Components/Components/src/ComponentBase.cs @@ -3,6 +3,7 @@ using System; using System.Threading.Tasks; +using Microsoft.AspNetCore.Components.HotReload; using Microsoft.AspNetCore.Components.Rendering; namespace Microsoft.AspNetCore.Components @@ -21,7 +22,7 @@ namespace Microsoft.AspNetCore.Components /// Optional base class for components. Alternatively, components may /// implement directly. /// - public abstract class ComponentBase : IComponent, IHandleEvent, IHandleAfterRender + public abstract class ComponentBase : IComponent, IHandleEvent, IHandleAfterRender, IReceiveHotReloadContext { private readonly RenderFragment _renderFragment; private RenderHandle _renderHandle; @@ -29,6 +30,7 @@ public abstract class ComponentBase : IComponent, IHandleEvent, IHandleAfterRend private bool _hasNeverRendered = true; private bool _hasPendingQueuedRender; private bool _hasCalledOnAfterRender; + private HotReloadContext? _hotReloadContext; /// /// Constructs an instance of . @@ -102,7 +104,7 @@ protected void StateHasChanged() return; } - if (_hasNeverRendered || ShouldRender()) + if (_hasNeverRendered || ShouldRender() || (_hotReloadContext?.IsHotReloading ?? false)) { _hasPendingQueuedRender = true; @@ -329,5 +331,10 @@ Task IHandleAfterRender.OnAfterRenderAsync() // have to use "async void" and do their own exception handling in // the case where they want to start an async task. } + + void IReceiveHotReloadContext.Receive(HotReloadContext context) + { + _hotReloadContext = context; + } } } diff --git a/src/Components/Components/src/HotReload/HotReloadContext.cs b/src/Components/Components/src/HotReload/HotReloadContext.cs new file mode 100644 index 000000000000..35948bdc6859 --- /dev/null +++ b/src/Components/Components/src/HotReload/HotReloadContext.cs @@ -0,0 +1,16 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Components.HotReload +{ + /// + /// A context that indicates when a component is being rendered because of a hot reload operation. + /// + public sealed class HotReloadContext + { + /// + /// Gets a value that indicates if the application is re-rendering in response to a hot-reload change. + /// + public bool IsHotReloading { get; internal set; } + } +} diff --git a/src/Components/Components/src/HotReload/HotReloadEnvironment.cs b/src/Components/Components/src/HotReload/HotReloadEnvironment.cs new file mode 100644 index 000000000000..fef2cd9255cd --- /dev/null +++ b/src/Components/Components/src/HotReload/HotReloadEnvironment.cs @@ -0,0 +1,22 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.AspNetCore.Components.HotReload +{ + internal class HotReloadEnvironment + { + public static readonly HotReloadEnvironment Instance = new(Environment.GetEnvironmentVariable("DOTNET_MODIFIABLE_ASSEMBLIES") == "debug"); + + public HotReloadEnvironment(bool isHotReloadEnabled) + { + IsHotReloadEnabled = isHotReloadEnabled; + } + + /// + /// Gets a value that determines if HotReload is configured for this application. + /// + public bool IsHotReloadEnabled { get; } + } +} diff --git a/src/Components/Components/src/HotReload/HotReloadManager.cs b/src/Components/Components/src/HotReload/HotReloadManager.cs new file mode 100644 index 000000000000..bcf59b7ea6b5 --- /dev/null +++ b/src/Components/Components/src/HotReload/HotReloadManager.cs @@ -0,0 +1,20 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Reflection; + +[assembly: AssemblyMetadata("ReceiveHotReloadDeltaNotification", "Microsoft.AspNetCore.Components.HotReload.HotReloadManager")] + +namespace Microsoft.AspNetCore.Components.HotReload +{ + internal static class HotReloadManager + { + internal static event Action? OnDeltaApplied; + + public static void DeltaApplied() + { + OnDeltaApplied?.Invoke(); + } + } +} diff --git a/src/Components/Components/src/HotReload/IReceiveHotReloadContext.cs b/src/Components/Components/src/HotReload/IReceiveHotReloadContext.cs new file mode 100644 index 000000000000..8de60e382310 --- /dev/null +++ b/src/Components/Components/src/HotReload/IReceiveHotReloadContext.cs @@ -0,0 +1,17 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Components.HotReload +{ + /// + /// Allows a component to receive a . + /// + public interface IReceiveHotReloadContext : IComponent + { + /// + /// Configures a component to use the hot reload context. + /// + /// The hot reload context. + void Receive(HotReloadContext context); + } +} diff --git a/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj b/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj index 2ee1e93ba030..3c4e0bc9a706 100644 --- a/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj +++ b/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj @@ -1,4 +1,4 @@ - + $(DefaultNetCoreTargetFramework) @@ -33,6 +33,10 @@ + + + + ToDictionary() return result; } + internal ParameterView Clone() + { + if (ReferenceEquals(_frames, _emptyFrames)) + { + return Empty; + } + + var numEntries = GetEntryCount(); + var cloneBuffer = new RenderTreeFrame[1 + numEntries]; + cloneBuffer[0] = RenderTreeFrame.PlaceholderChildComponentWithSubtreeLength(1 + numEntries); + _frames.AsSpan(1, numEntries).CopyTo(cloneBuffer.AsSpan(1)); + + return new ParameterView(Lifetime, cloneBuffer, _ownerIndex); + } + internal ParameterView WithCascadingParameters(IReadOnlyList cascadingParameters) => new ParameterView(_lifetime, _frames, _ownerIndex, cascadingParameters); @@ -189,11 +204,7 @@ internal void CaptureSnapshot(ArrayBuilder builder) { builder.Clear(); - var numEntries = 0; - foreach (var entry in this) - { - numEntries++; - } + var numEntries = GetEntryCount(); // We need to prefix the captured frames with an "owner" frame that // describes the length of the buffer so that ParameterView @@ -207,6 +218,17 @@ internal void CaptureSnapshot(ArrayBuilder builder) } } + private int GetEntryCount() + { + var numEntries = 0; + foreach (var _ in this) + { + numEntries++; + } + + return numEntries; + } + /// /// Creates a new from the given . /// diff --git a/src/Components/Components/src/Properties/AssemblyInfo.cs b/src/Components/Components/src/Properties/AssemblyInfo.cs index 8ff5dba8445c..66e087922b0d 100644 --- a/src/Components/Components/src/Properties/AssemblyInfo.cs +++ b/src/Components/Components/src/Properties/AssemblyInfo.cs @@ -8,5 +8,6 @@ [assembly: InternalsVisibleTo("Microsoft.AspNetCore.Components.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] [assembly: InternalsVisibleTo("Microsoft.AspNetCore.Components.Web.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] [assembly: InternalsVisibleTo("Microsoft.AspNetCore.Components.ProtectedBrowserStorage.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] +[assembly: InternalsVisibleTo("Components.TestServer, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7")] diff --git a/src/Components/Components/src/Properties/ILLink.Substitutions.xml b/src/Components/Components/src/Properties/ILLink.Substitutions.xml new file mode 100644 index 000000000000..edd5fb323df5 --- /dev/null +++ b/src/Components/Components/src/Properties/ILLink.Substitutions.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index 3032b67cb877..ab4b3f271702 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -8,6 +8,11 @@ Microsoft.AspNetCore.Components.ComponentApplicationState.PersistAsJson( Microsoft.AspNetCore.Components.ComponentApplicationState.PersistState(string! key, byte[]! value) -> void Microsoft.AspNetCore.Components.ComponentApplicationState.TryTakeAsJson(string! key, out TValue? instance) -> bool Microsoft.AspNetCore.Components.ComponentApplicationState.TryTakePersistedState(string! key, out byte[]? value) -> bool +Microsoft.AspNetCore.Components.HotReload.HotReloadContext +Microsoft.AspNetCore.Components.HotReload.HotReloadContext.HotReloadContext() -> void +Microsoft.AspNetCore.Components.HotReload.HotReloadContext.IsHotReloading.get -> bool +Microsoft.AspNetCore.Components.HotReload.IReceiveHotReloadContext +Microsoft.AspNetCore.Components.HotReload.IReceiveHotReloadContext.Receive(Microsoft.AspNetCore.Components.HotReload.HotReloadContext! context) -> void Microsoft.AspNetCore.Components.Lifetime.ComponentApplicationLifetime Microsoft.AspNetCore.Components.Lifetime.ComponentApplicationLifetime.ComponentApplicationLifetime(Microsoft.Extensions.Logging.ILogger! logger) -> void Microsoft.AspNetCore.Components.Lifetime.ComponentApplicationLifetime.PersistStateAsync(Microsoft.AspNetCore.Components.Lifetime.IComponentApplicationStateStore! store, Microsoft.AspNetCore.Components.RenderTree.Renderer! renderer) -> System.Threading.Tasks.Task! diff --git a/src/Components/Components/src/RenderTree/RenderTreeDiffBuilder.cs b/src/Components/Components/src/RenderTree/RenderTreeDiffBuilder.cs index 80bf99724e0b..674684a65367 100644 --- a/src/Components/Components/src/RenderTree/RenderTreeDiffBuilder.cs +++ b/src/Components/Components/src/RenderTree/RenderTreeDiffBuilder.cs @@ -5,8 +5,6 @@ using System; using System.Collections.Generic; -using System.Diagnostics; -using System.Runtime.CompilerServices; using Microsoft.AspNetCore.Components.Rendering; namespace Microsoft.AspNetCore.Components.RenderTree @@ -535,15 +533,25 @@ private static void UpdateRetainedChildComponent( // comparisons it wants with the old values. Later we could choose to pass the // old parameter values if we wanted. By default, components always rerender // after any SetParameters call, which is safe but now always optimal for perf. + + // When performing hot reload, we want to force all components to re-render. + // We do this using two mechanisms - we call SetParametersAsync even if the parameters + // are unchanged and we ignore ComponentBase.ShouldRender + var oldParameters = new ParameterView(ParameterViewLifetime.Unbound, oldTree, oldComponentIndex); var newParametersLifetime = new ParameterViewLifetime(diffContext.BatchBuilder); var newParameters = new ParameterView(newParametersLifetime, newTree, newComponentIndex); - if (!newParameters.DefinitelyEquals(oldParameters)) + if (!newParameters.DefinitelyEquals(oldParameters) || IsHotReloading(diffContext.Renderer)) { componentState.SetDirectParameters(newParameters); } } + /// + /// Intentionally authored as a separate method so we can trim this code. + /// + private static bool IsHotReloading(Renderer renderer) => renderer.HotReloadContext.IsHotReloading; + private static int NextSiblingIndex(in RenderTreeFrame frame, int frameIndex) { switch (frame.FrameTypeField) @@ -696,8 +704,8 @@ private static void AppendDiffEntriesForFramesWithSameSequence( break; } - // We don't handle attributes here, they have their own diff logic. - // See AppendDiffEntriesForAttributeFrame + // We don't handle attributes here, they have their own diff logic. + // See AppendDiffEntriesForAttributeFrame default: throw new NotImplementedException($"Encountered unsupported frame type during diffing: {newTree[newFrameIndex].FrameTypeField}"); } diff --git a/src/Components/Components/src/RenderTree/Renderer.cs b/src/Components/Components/src/RenderTree/Renderer.cs index 38276aeaf426..0748d1675754 100644 --- a/src/Components/Components/src/RenderTree/Renderer.cs +++ b/src/Components/Components/src/RenderTree/Renderer.cs @@ -9,6 +9,7 @@ using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; +using Microsoft.AspNetCore.Components.HotReload; using Microsoft.AspNetCore.Components.Rendering; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -32,13 +33,16 @@ public abstract partial class Renderer : IDisposable, IAsyncDisposable private readonly Dictionary _eventHandlerIdReplacements = new Dictionary(); private readonly ILogger _logger; private readonly ComponentFactory _componentFactory; + private HotReloadContext _hotReloadContext; + private HotReloadEnvironment? _hotReloadEnvironment; + private List<(ComponentState, ParameterView)>? _rootComponents; private int _nextComponentId = 0; // TODO: change to 'long' when Mono .NET->JS interop supports it private bool _isBatchInProgress; private ulong _lastEventHandlerId; - private List _pendingTasks; + private List? _pendingTasks; private bool _disposed; - private Task _disposeTask; + private Task? _disposeTask; /// /// Allows the caller to handle exceptions from the SynchronizationContext when one is available. @@ -92,6 +96,22 @@ public Renderer(IServiceProvider serviceProvider, ILoggerFactory loggerFactory, _serviceProvider = serviceProvider; _logger = loggerFactory.CreateLogger(); _componentFactory = new ComponentFactory(componentActivator); + + InitializeHotReload(serviceProvider); + } + + private void InitializeHotReload(IServiceProvider serviceProvider) + { + _hotReloadContext = new(); + + // HotReloadEnvironment is a test-specific feature and may not be available in a running app. We'll fallback to the default instance + // if the test fixture does not provide one. + _hotReloadEnvironment = serviceProvider.GetService() ?? HotReloadEnvironment.Instance; + + if (_hotReloadEnvironment.IsHotReloadEnabled) + { + HotReloadManager.OnDeltaApplied += RenderRootComponentsOnHotReload; + } } private static IComponentActivator GetComponentActivatorOrDefault(IServiceProvider serviceProvider) @@ -101,7 +121,7 @@ private static IComponentActivator GetComponentActivatorOrDefault(IServiceProvid } /// - /// Gets the associated with this . + /// Gets the associated with this . /// public abstract Dispatcher Dispatcher { get; } @@ -111,13 +131,54 @@ private static IComponentActivator GetComponentActivatorOrDefault(IServiceProvid /// protected internal ElementReferenceContext? ElementReferenceContext { get; protected set; } + internal HotReloadContext HotReloadContext => _hotReloadContext; + + private async void RenderRootComponentsOnHotReload() + { + await Dispatcher.InvokeAsync(() => + { + if (_rootComponents is null) + { + return; + } + + HotReloadContext.IsHotReloading = true; + try + { + foreach (var (componentState, initialParameters) in _rootComponents) + { + componentState.SetDirectParameters(initialParameters); + } + } + finally + { + HotReloadContext.IsHotReloading = false; + } + }); + } + /// /// Constructs a new component of the specified type. /// /// The type of the component to instantiate. /// The component instance. protected IComponent InstantiateComponent([DynamicallyAccessedMembers(Component)] Type componentType) - => _componentFactory.InstantiateComponent(_serviceProvider, componentType); + { + var component = _componentFactory.InstantiateComponent(_serviceProvider, componentType); + InstatiateComponentForHotReload(component); + return component; + } + + /// + /// Intentionally authored as a separate method call so we can trim this code. + /// + private void InstatiateComponentForHotReload(IComponent component) + { + if (_hotReloadEnvironment is not null && _hotReloadEnvironment.IsHotReloadEnabled && component is IReceiveHotReloadContext receiveHotReloadContext) + { + receiveHotReloadContext.Receive(HotReloadContext); + } + } /// /// Associates the with the , assigning @@ -182,6 +243,8 @@ protected async Task RenderRootComponentAsync(int componentId, ParameterView ini // During the asynchronous rendering process we want to wait up until all components have // finished rendering so that we can produce the complete output. var componentState = GetRequiredComponentState(componentId); + CaptureRootComponentForHotReload(initialParameters, componentState); + componentState.SetDirectParameters(initialParameters); try @@ -195,6 +258,20 @@ protected async Task RenderRootComponentAsync(int componentId, ParameterView ini } } + /// + /// Intentionally authored as a separate method call so we can trim this code. + /// + private void CaptureRootComponentForHotReload(ParameterView initialParameters, ComponentState componentState) + { + if (_hotReloadEnvironment?.IsHotReloadEnabled ?? false) + { + // when we're doing hot-reload, stash away the parameters used while rendering root components. + // We'll use this to trigger re-renders on hot reload updates. + _rootComponents ??= new(); + _rootComponents.Add((componentState, initialParameters.Clone())); + } + } + /// /// Allows derived types to handle exceptions during rendering. Defaults to rethrowing the original exception. /// @@ -260,7 +337,7 @@ public virtual Task DispatchEventAsync(ulong eventHandlerId, EventFieldInfo? fie UpdateRenderTreeToMatchClientState(latestEquivalentEventHandlerId, fieldInfo); } - Task task = null; + Task? task = null; try { // The event handler might request multiple renders in sequence. Capture them @@ -884,6 +961,8 @@ public void Dispose() /// public async ValueTask DisposeAsync() { + DisposeForHotReload(); + if (_disposed) { return; @@ -906,5 +985,16 @@ public async ValueTask DisposeAsync() } } } + + /// + /// Intentionally authored as a separate method call so we can trim this code. + /// + private void DisposeForHotReload() + { + if (_hotReloadEnvironment?.IsHotReloadEnabled ?? false) + { + HotReloadManager.OnDeltaApplied -= RenderRootComponentsOnHotReload; + } + } } } diff --git a/src/Components/Components/test/ParameterViewTest.cs b/src/Components/Components/test/ParameterViewTest.cs index 5193e8fe296c..6cf26a7a7ee3 100644 --- a/src/Components/Components/test/ParameterViewTest.cs +++ b/src/Components/Components/test/ParameterViewTest.cs @@ -348,6 +348,67 @@ public void CannotReadAfterLifetimeExpiry() Assert.Equal($"The {nameof(ParameterView)} instance can no longer be read because it has expired. {nameof(ParameterView)} can only be read synchronously and must not be stored for later use.", ex.Message); } + [Fact] + public void Clone_EmptyParameterView() + { + // Arrange + var initial = ParameterView.Empty; + + // Act + var cloned = initial.Clone(); + + // Assert + Assert.Empty(ToEnumerable(cloned)); + } + + [Fact] + public void Clone_ParameterViewSingleParameter() + { + // Arrange + var attribute1Value = new object(); + var initial = new ParameterView(ParameterViewLifetime.Unbound, new[] + { + RenderTreeFrame.Element(0, "some element").WithElementSubtreeLength(2), + RenderTreeFrame.Attribute(1, "attribute 1", attribute1Value), + }, 0); + + + // Act + var cloned = initial.Clone(); + + // Assert + Assert.Collection( + ToEnumerable(cloned), + p => AssertParameter("attribute 1", attribute1Value, expectedIsCascading: false)); + } + + [Fact] + public void Clone_ParameterPreservesOrder() + { + // Arrange + var attribute1Value = new object(); + var attribute2Value = new object(); + var attribute3Value = new object(); + var initial = new ParameterView(ParameterViewLifetime.Unbound, new[] + { + RenderTreeFrame.Element(0, "some element").WithElementSubtreeLength(4), + RenderTreeFrame.Attribute(1, "attribute 1", attribute1Value), + RenderTreeFrame.Attribute(1, "attribute 2", attribute2Value), + RenderTreeFrame.Attribute(1, "attribute 3", attribute3Value), + }, 0); + + + // Act + var cloned = initial.Clone(); + + // Assert + Assert.Collection( + ToEnumerable(cloned), + p => AssertParameter("attribute 1", attribute1Value, expectedIsCascading: false), + p => AssertParameter("attribute 2", attribute2Value, expectedIsCascading: false), + p => AssertParameter("attribute 3", attribute3Value, expectedIsCascading: false)); + } + private Action AssertParameter(string expectedName, object expectedValue, bool expectedIsCascading) { return parameter => diff --git a/src/Components/Web.JS/src/Boot.WebAssembly.ts b/src/Components/Web.JS/src/Boot.WebAssembly.ts index 1fa5f26023c6..5fd2f2bffc46 100644 --- a/src/Components/Web.JS/src/Boot.WebAssembly.ts +++ b/src/Components/Web.JS/src/Boot.WebAssembly.ts @@ -38,6 +38,10 @@ async function boot(options?: Partial): Promise { Blazor._internal.InputFile = WasmInputFile; + Blazor._internal.applyHotReload = (id: string, metadataDelta: string, ilDeta: string) => { + DotNet.invokeMethod('Microsoft.AspNetCore.Components.WebAssembly', 'ApplyHotReloadDelta', id, metadataDelta, ilDeta); + }; + // Configure JS interop Blazor._internal.invokeJSFromDotNet = invokeJSFromDotNet; diff --git a/src/Components/Web.JS/src/GlobalExports.ts b/src/Components/Web.JS/src/GlobalExports.ts index 7cc3c3747280..43af8414820b 100644 --- a/src/Components/Web.JS/src/GlobalExports.ts +++ b/src/Components/Web.JS/src/GlobalExports.ts @@ -45,7 +45,8 @@ interface IBlazor { readSatelliteAssemblies?: () => System_Array, getLazyAssemblies?: any dotNetCriticalError?: any - getSatelliteAssemblies?: any + getSatelliteAssemblies?: any, + applyHotReload?: (id: string, metadataDelta: string, ilDelta: string) => void } } @@ -61,4 +62,4 @@ export const Blazor: IBlazor = { }; // Make the following APIs available in global scope for invocation from JS -window['Blazor'] = Blazor; \ No newline at end of file +window['Blazor'] = Blazor; diff --git a/src/Components/WebAssembly/WebAssembly/src/Infrastructure/JSInteropMethods.cs b/src/Components/WebAssembly/WebAssembly/src/Infrastructure/JSInteropMethods.cs index c6eb32b09229..c4c486c8e17b 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Infrastructure/JSInteropMethods.cs +++ b/src/Components/WebAssembly/WebAssembly/src/Infrastructure/JSInteropMethods.cs @@ -1,8 +1,12 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System; using System.ComponentModel; +using System.Linq; +using System.Reflection; using System.Threading.Tasks; +using Microsoft.AspNetCore.Components.HotReload; using Microsoft.AspNetCore.Components.RenderTree; using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.WebAssembly.Rendering; @@ -40,5 +44,23 @@ public static Task DispatchEvent(WebEventDescriptor eventDescriptor, string even webEvent.EventFieldInfo, webEvent.EventArgs); } + + /// + /// For framework use only. + /// + [JSInvokable(nameof(ApplyHotReloadDelta))] + public static void ApplyHotReloadDelta(string moduleId, byte[] metadataDelta, byte[] ilDeta) + { + var moduleIdGuid = Guid.Parse(moduleId); + var assembly = AppDomain.CurrentDomain.GetAssemblies().FirstOrDefault(a => a.Modules.FirstOrDefault() is Module m && m.ModuleVersionId == moduleIdGuid); + + if (assembly is not null) + { + System.Reflection.Metadata.AssemblyExtensions.ApplyUpdate(assembly, metadataDelta, ilDeta, ReadOnlySpan.Empty); + } + + // Remove this once there's a runtime API to subscribe to. + typeof(ComponentBase).Assembly.GetType("Microsoft.AspNetCore.Components.HotReload.HotReloadManager")!.GetMethod("DeltaApplied", BindingFlags.NonPublic | BindingFlags.Static)!.Invoke(null, null); + } } } diff --git a/src/Components/WebAssembly/WebAssembly/src/Microsoft.AspNetCore.Components.WebAssembly.WarningSuppressions.xml b/src/Components/WebAssembly/WebAssembly/src/Microsoft.AspNetCore.Components.WebAssembly.WarningSuppressions.xml index 732104ae1516..986fa0232bcd 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Microsoft.AspNetCore.Components.WebAssembly.WarningSuppressions.xml +++ b/src/Components/WebAssembly/WebAssembly/src/Microsoft.AspNetCore.Components.WebAssembly.WarningSuppressions.xml @@ -19,6 +19,12 @@ member M:Microsoft.AspNetCore.Components.WebAssembly.Hosting.WebAssemblyHostBuilder.InitializeRegisteredRootComponents(Microsoft.JSInterop.IJSUnmarshalledRuntime) + + ILLink + IL2026 + member + M:Microsoft.AspNetCore.Components.WebAssembly.Infrastructure.JSInteropMethods.ApplyHotReloadDelta(System.String,System.Byte[],System.Byte[]) + ILLink IL2026 @@ -43,5 +49,11 @@ member M:Microsoft.AspNetCore.Components.WebAssemblyComponentParameterDeserializer.DeserializeParameters(System.Collections.Generic.IList{Microsoft.AspNetCore.Components.ComponentParameter},System.Collections.Generic.IList{System.Object}) + + ILLink + IL2075 + member + M:Microsoft.AspNetCore.Components.WebAssembly.Infrastructure.JSInteropMethods.ApplyHotReloadDelta(System.String,System.Byte[],System.Byte[]) + \ No newline at end of file diff --git a/src/Components/WebAssembly/WebAssembly/src/PublicAPI.Unshipped.txt b/src/Components/WebAssembly/WebAssembly/src/PublicAPI.Unshipped.txt index 1de9e94bc802..bcdda6e1d927 100644 --- a/src/Components/WebAssembly/WebAssembly/src/PublicAPI.Unshipped.txt +++ b/src/Components/WebAssembly/WebAssembly/src/PublicAPI.Unshipped.txt @@ -38,5 +38,6 @@ static Microsoft.AspNetCore.Components.WebAssembly.Http.WebAssemblyHttpRequestMe static Microsoft.AspNetCore.Components.WebAssembly.Http.WebAssemblyHttpRequestMessageExtensions.SetBrowserRequestMode(this System.Net.Http.HttpRequestMessage! requestMessage, Microsoft.AspNetCore.Components.WebAssembly.Http.BrowserRequestMode requestMode) -> System.Net.Http.HttpRequestMessage! static Microsoft.AspNetCore.Components.WebAssembly.Http.WebAssemblyHttpRequestMessageExtensions.SetBrowserRequestOption(this System.Net.Http.HttpRequestMessage! requestMessage, string! name, object! value) -> System.Net.Http.HttpRequestMessage! static Microsoft.AspNetCore.Components.WebAssembly.Http.WebAssemblyHttpRequestMessageExtensions.SetBrowserResponseStreamingEnabled(this System.Net.Http.HttpRequestMessage! requestMessage, bool streamingEnabled) -> System.Net.Http.HttpRequestMessage! +static Microsoft.AspNetCore.Components.WebAssembly.Infrastructure.JSInteropMethods.ApplyHotReloadDelta(string! moduleId, byte[]! metadataDelta, byte[]! ilDeta) -> void static Microsoft.AspNetCore.Components.WebAssembly.Infrastructure.JSInteropMethods.DispatchEvent(Microsoft.AspNetCore.Components.RenderTree.WebEventDescriptor! eventDescriptor, string! eventArgsJson) -> System.Threading.Tasks.Task! static Microsoft.AspNetCore.Components.WebAssembly.Infrastructure.JSInteropMethods.NotifyLocationChanged(string! uri, bool isInterceptedLink) -> void diff --git a/src/Components/test/E2ETest/ServerExecutionTests/HotReloadTest.cs b/src/Components/test/E2ETest/ServerExecutionTests/HotReloadTest.cs new file mode 100644 index 000000000000..e813e232b83b --- /dev/null +++ b/src/Components/test/E2ETest/ServerExecutionTests/HotReloadTest.cs @@ -0,0 +1,55 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; +using BasicTestApp.HotReload; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; +using Microsoft.AspNetCore.E2ETesting; +using OpenQA.Selenium; +using TestServer; +using Xunit; +using Xunit.Abstractions; +using System.Net.Http; + +namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests +{ + public class HotReloadTest : ServerTestBase> + { + public HotReloadTest( + BrowserFixture browserFixture, + BasicTestAppServerSiteFixture serverFixture, + ITestOutputHelper output) + : base(browserFixture, serverFixture, output) + { + } + + public override async Task InitializeAsync() + { + await base.InitializeAsync(Guid.NewGuid().ToString()); + } + + protected override void InitializeAsyncCore() + { + Navigate(ServerPathBase, noReload: false); + Browser.MountTestComponent(); + } + + [Fact] + public async Task InvokingRender_CausesComponentToRender() + { + Browser.Equal("This component was rendered 1 time(s).", () => Browser.Exists(By.TagName("h2")).Text); + Browser.Equal("Initial title", () => Browser.Exists(By.TagName("h3")).Text); + Browser.Equal("Component with ShouldRender=false was rendered 1 time(s).", () => Browser.Exists(By.TagName("h4")).Text); + + using var client = new HttpClient { BaseAddress = _serverFixture.RootUri }; + var response = await client.GetAsync("/rerender"); + response.EnsureSuccessStatusCode(); + + Browser.Equal("This component was rendered 2 time(s).", () => Browser.Exists(By.TagName("h2")).Text); + Browser.Equal("Initial title", () => Browser.Exists(By.TagName("h3")).Text); + Browser.Equal("Component with ShouldRender=false was rendered 2 time(s).", () => Browser.Exists(By.TagName("h4")).Text); + } + } +} diff --git a/src/Components/test/testassets/BasicTestApp/HotReload/ComponentWithParameter.razor b/src/Components/test/testassets/BasicTestApp/HotReload/ComponentWithParameter.razor new file mode 100644 index 000000000000..ee99fbdb6db3 --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/HotReload/ComponentWithParameter.razor @@ -0,0 +1,6 @@ +

@Title

+ +@code +{ + [Parameter] public string Title { get; set; } +} \ No newline at end of file diff --git a/src/Components/test/testassets/BasicTestApp/HotReload/ComponentWithShouldRender.razor b/src/Components/test/testassets/BasicTestApp/HotReload/ComponentWithShouldRender.razor new file mode 100644 index 000000000000..1cea3335f9da --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/HotReload/ComponentWithShouldRender.razor @@ -0,0 +1,8 @@ +

Component with ShouldRender=false was rendered @(++RenderCount) time(s).

+ +@code +{ + int RenderCount = 0; + + protected override bool ShouldRender() => false; +} \ No newline at end of file diff --git a/src/Components/test/testassets/BasicTestApp/HotReload/RenderOnHotReload.razor b/src/Components/test/testassets/BasicTestApp/HotReload/RenderOnHotReload.razor new file mode 100644 index 000000000000..00f35e56c292 --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/HotReload/RenderOnHotReload.razor @@ -0,0 +1,10 @@ +

This component was rendered @(++RenderCount) time(s).

+ + + + + +@code +{ + int RenderCount = 0; +} \ No newline at end of file diff --git a/src/Components/test/testassets/BasicTestApp/Index.razor b/src/Components/test/testassets/BasicTestApp/Index.razor index ba51153b4733..34a05f91dac4 100644 --- a/src/Components/test/testassets/BasicTestApp/Index.razor +++ b/src/Components/test/testassets/BasicTestApp/Index.razor @@ -92,6 +92,7 @@ + @System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription diff --git a/src/Components/test/testassets/TestServer/Controllers/ReloadController.cs b/src/Components/test/testassets/TestServer/Controllers/ReloadController.cs new file mode 100644 index 000000000000..162f4540099d --- /dev/null +++ b/src/Components/test/testassets/TestServer/Controllers/ReloadController.cs @@ -0,0 +1,20 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNetCore.Components.HotReload; +using Microsoft.AspNetCore.Mvc; + +namespace ComponentsApp.Server +{ + [ApiController] + public class ReloadController : ControllerBase + { + [HttpGet("/rerender")] + public IActionResult Rerender() + { + HotReloadManager.DeltaApplied(); + + return Ok(); + } + } +} diff --git a/src/Components/test/testassets/TestServer/HotReloadStartup.cs b/src/Components/test/testassets/TestServer/HotReloadStartup.cs new file mode 100644 index 000000000000..f9b0061c9d41 --- /dev/null +++ b/src/Components/test/testassets/TestServer/HotReloadStartup.cs @@ -0,0 +1,45 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Globalization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Components.HotReload; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace TestServer +{ + public class HotReloadStartup + { + public void ConfigureServices(IServiceCollection services) + { + services.AddSingleton(new HotReloadEnvironment(isHotReloadEnabled: true)); + services.AddControllers(); + services.AddRazorPages(); + services.AddServerSideBlazor(); + } + + public void Configure(IApplicationBuilder app, IWebHostEnvironment env) + { + var enUs = new CultureInfo("en-US"); + CultureInfo.DefaultThreadCurrentCulture = enUs; + CultureInfo.DefaultThreadCurrentUICulture = enUs; + + if (env.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + } + + app.UseBlazorFrameworkFiles(); + app.UseStaticFiles(); + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + endpoints.MapBlazorHub(); + endpoints.MapFallbackToPage("/_ServerHost"); + }); + } + } +} diff --git a/src/Components/test/testassets/TestServer/Program.cs b/src/Components/test/testassets/TestServer/Program.cs index 6351ada5b656..df8993c4c28d 100644 --- a/src/Components/test/testassets/TestServer/Program.cs +++ b/src/Components/test/testassets/TestServer/Program.cs @@ -29,6 +29,7 @@ public static async Task Main(string[] args) ["Globalization + Localization (Server-side)"] = (BuildWebHost(CreateAdditionalArgs(args)), "/subdir"), ["Server-side blazor"] = (BuildWebHost(CreateAdditionalArgs(args)), "/subdir"), ["Hosted client-side blazor"] = (BuildWebHost(CreateAdditionalArgs(args)), "/subdir"), + ["Hot Reload"] = (BuildWebHost(CreateAdditionalArgs(args)), "/subdir"), ["Dev server client-side blazor"] = CreateDevServerHost(CreateAdditionalArgs(args)) };