diff --git a/src/Components/Components/src/RenderTree/Renderer.cs b/src/Components/Components/src/RenderTree/Renderer.cs index 0b2095f6f0e1..ad0864443da4 100644 --- a/src/Components/Components/src/RenderTree/Renderer.cs +++ b/src/Components/Components/src/RenderTree/Renderer.cs @@ -54,6 +54,7 @@ public abstract partial class Renderer : IDisposable, IAsyncDisposable private bool _rendererIsDisposed; private bool _hotReloadInitialized; + private HotReloadRenderHandler? _hotReloadRenderHandler; /// /// Allows the caller to handle exceptions from the SynchronizationContext when one is available. @@ -231,7 +232,12 @@ protected internal int AssignRootComponentId(IComponent component) _hotReloadInitialized = true; if (HotReloadManager.MetadataUpdateSupported) { - HotReloadManager.OnDeltaApplied += RenderRootComponentsOnHotReload; + // Capture the current ExecutionContext so AsyncLocal values present during initial root component + // registration flow through to hot reload re-renders. Without this, hot reload callbacks execute + // on a thread without the original ambient context and AsyncLocal values appear null. + var executionContext = ExecutionContext.Capture(); + _hotReloadRenderHandler = new HotReloadRenderHandler(this, executionContext); + HotReloadManager.OnDeltaApplied += _hotReloadRenderHandler.RerenderOnHotReload; } } @@ -1234,9 +1240,9 @@ protected virtual void Dispose(bool disposing) _rendererIsDisposed = true; } - if (_hotReloadInitialized && HotReloadManager.MetadataUpdateSupported) + if (_hotReloadInitialized && HotReloadManager.MetadataUpdateSupported && _hotReloadRenderHandler is not null) { - HotReloadManager.OnDeltaApplied -= RenderRootComponentsOnHotReload; + HotReloadManager.OnDeltaApplied -= _hotReloadRenderHandler.RerenderOnHotReload; } // It's important that we handle all exceptions here before reporting any of them. @@ -1371,4 +1377,19 @@ public async ValueTask DisposeAsync() } } } + + private sealed class HotReloadRenderHandler(Renderer renderer, ExecutionContext? executionContext) + { + public void RerenderOnHotReload() + { + if (executionContext is null) + { + renderer.RenderRootComponentsOnHotReload(); + } + else + { + ExecutionContext.Run(executionContext, static s => ((Renderer)s!).RenderRootComponentsOnHotReload(), renderer); + } + } + } } diff --git a/src/Components/Components/test/RendererTest.cs b/src/Components/Components/test/RendererTest.cs index 492b5c8cc2f9..90d46f746d03 100644 --- a/src/Components/Components/test/RendererTest.cs +++ b/src/Components/Components/test/RendererTest.cs @@ -4,6 +4,7 @@ using System.Collections.Concurrent; using System.Diagnostics; using System.Globalization; +using System.Reflection; using System.Runtime.ExceptionServices; using Microsoft.AspNetCore.Components.CompilerServices; using Microsoft.AspNetCore.Components.HotReload; @@ -5027,6 +5028,40 @@ public async Task DisposingRenderer_UnsubsribesFromHotReloadManager() Assert.False(hotReloadManager.IsSubscribedTo); } + [Fact] + public async Task HotReload_ReRenderPreservesAsyncLocalValues() + { + await using var renderer = new TestRenderer(); + + var hotReloadManager = new HotReloadManager { MetadataUpdateSupported = true }; + renderer.HotReloadManager = hotReloadManager; + HotReloadManager.Default.MetadataUpdateSupported = true; + + var component = new AsyncLocalCaptureComponent(); + + // Establish AsyncLocal value before registering hot reload handler / rendering. + ServiceAccessor.TestAsyncLocal.Value = "AmbientValue"; + + var componentId = renderer.AssignRootComponentId(component); + await renderer.Dispatcher.InvokeAsync(() => renderer.RenderRootComponentAsync(componentId)); + + // Sanity: initial render should not have captured a hot-reload value yet. + Assert.Null(component.HotReloadValue); + + // Simulate hot reload delta applied from a fresh thread (different ExecutionContext) so the AsyncLocal value is lost. + var expected = ServiceAccessor.TestAsyncLocal.Value; + var thread = new Thread(() => + { + // Simulate environment where the ambient value is not present on the hot reload thread. + ServiceAccessor.TestAsyncLocal.Value = null; + hotReloadManager.TriggerOnDeltaApplied(); + }); + thread.Start(); + thread.Join(); + + Assert.Equal(expected, component.HotReloadValue); + } + [Fact] public void ThrowsForUnknownRenderMode_OnComponentType() { @@ -5180,6 +5215,34 @@ protected override Task UpdateDisplayAsync(in RenderBatch renderBatch) => Task.CompletedTask; } + private class ServiceAccessor + { + public static AsyncLocal TestAsyncLocal = new AsyncLocal(); + } + + private class AsyncLocalCaptureComponent : IComponent + { + private bool _initialized; + private RenderHandle _renderHandle; + public string HotReloadValue { get; private set; } + + public void Attach(RenderHandle renderHandle) => _renderHandle = renderHandle; + + public Task SetParametersAsync(ParameterView parameters) + { + if (!_initialized) + { + _initialized = true; // First (normal) render, don't capture. + } + else + { + // Hot reload re-render path. + HotReloadValue = ServiceAccessor.TestAsyncLocal.Value; + } + return Task.CompletedTask; + } + } + private class TestComponent : IComponent, IDisposable { private RenderHandle _renderHandle; diff --git a/src/Components/Shared/src/HotReloadManager.cs b/src/Components/Shared/src/HotReloadManager.cs index b760a65004b9..f3ca59cf2651 100644 --- a/src/Components/Shared/src/HotReloadManager.cs +++ b/src/Components/Shared/src/HotReloadManager.cs @@ -25,4 +25,7 @@ internal sealed class HotReloadManager /// MetadataUpdateHandler event. This is invoked by the hot reload host via reflection. /// public static void UpdateApplication(Type[]? _) => Default.OnDeltaApplied?.Invoke(); + + // For testing purposes only + internal void TriggerOnDeltaApplied() => OnDeltaApplied?.Invoke(); }