From 4d3e6cc6cc94cac176359603e29017671d4aefcf Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Fri, 26 Sep 2025 18:34:44 +0200 Subject: [PATCH 1/5] Add failing test --- .../Components/test/RendererTest.cs | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/src/Components/Components/test/RendererTest.cs b/src/Components/Components/test/RendererTest.cs index 492b5c8cc2f9..81b657be3160 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,47 @@ public async Task DisposingRenderer_UnsubsribesFromHotReloadManager() Assert.False(hotReloadManager.IsSubscribedTo); } + [Fact] + public async Task HotReload_ReRenderPreservesAsyncLocalValues_FailsToday() + { + // This is a regression test for the reported issue: AsyncLocal values are lost during hot reload. + // The desired (correct) behavior is that AsyncLocal values flow into the hot-reload re-render. + // Currently, because the hot reload callback is raised from a context without the original ExecutionContext, + // the AsyncLocal value is lost and the assertion below will FAIL (demonstrating the bug). + + await using var renderer = new TestRenderer(); + var hotReloadManager = new HotReloadManager { MetadataUpdateSupported = true }; + renderer.HotReloadManager = hotReloadManager; + + var component = new AsyncLocalCaptureComponent(); + + // Establish AsyncLocal value before registering hot reload handler / rendering. + AsyncLocalCaptureComponent.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 = AsyncLocalCaptureComponent.TestAsyncLocal.Value; + var thread = new Thread(() => + { + // Simulate environment where the ambient value is not present on the hot reload thread. + AsyncLocalCaptureComponent.TestAsyncLocal.Value = null; + var evtField = typeof(HotReloadManager).GetField("OnDeltaApplied", BindingFlags.Instance | BindingFlags.NonPublic); + var del = (Action)evtField.GetValue(hotReloadManager); + del?.Invoke(); + }); + thread.Start(); + thread.Join(); + + // EXPECTED (desired) correct behavior: value should still be present. + // ACTUAL today: this will be null, so the test fails, confirming the bug. + Assert.Equal(expected, component.HotReloadValue); + } + [Fact] public void ThrowsForUnknownRenderMode_OnComponentType() { @@ -5180,6 +5222,31 @@ protected override Task UpdateDisplayAsync(in RenderBatch renderBatch) => Task.CompletedTask; } + private class AsyncLocalCaptureComponent : IComponent + { + public static readonly AsyncLocal TestAsyncLocal = new(); + + 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 = TestAsyncLocal.Value; + } + return Task.CompletedTask; + } + } + private class TestComponent : IComponent, IDisposable { private RenderHandle _renderHandle; From 43978c6a261cb65326a603be82a41b54c2782049 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Fri, 26 Sep 2025 18:39:38 +0200 Subject: [PATCH 2/5] Fix the async locals not being available on hot reload --- .../Components/src/RenderTree/Renderer.cs | 36 +++++++++++++++++-- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/src/Components/Components/src/RenderTree/Renderer.cs b/src/Components/Components/src/RenderTree/Renderer.cs index 0b2095f6f0e1..0bf1ca8c59d2 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 = System.Threading.ExecutionContext.Capture(); + _hotReloadRenderHandler = new HotReloadRenderHandler(this, executionContext); + HotReloadManager.OnDeltaApplied += _hotReloadRenderHandler.OnDeltaApplied; } } @@ -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.OnDeltaApplied; } // It's important that we handle all exceptions here before reporting any of them. @@ -1371,4 +1377,28 @@ public async ValueTask DisposeAsync() } } } + + private sealed class HotReloadRenderHandler + { + private readonly Renderer _renderer; + private readonly System.Threading.ExecutionContext? _executionContext; + + public HotReloadRenderHandler(Renderer renderer, System.Threading.ExecutionContext? executionContext) + { + _renderer = renderer; + _executionContext = executionContext; // May be null if flow is suppressed + } + + public void OnDeltaApplied() + { + if (_executionContext is null) + { + _renderer.RenderRootComponentsOnHotReload(); + } + else + { + System.Threading.ExecutionContext.Run(_executionContext, static s => ((Renderer)s!).RenderRootComponentsOnHotReload(), _renderer); + } + } + } } From 67c7fc999ca2944075f98ea14f563be9a41cc0b6 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Fri, 26 Sep 2025 18:56:11 +0200 Subject: [PATCH 3/5] Cleanup --- .../Components/src/RenderTree/Renderer.cs | 22 ++++++------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/src/Components/Components/src/RenderTree/Renderer.cs b/src/Components/Components/src/RenderTree/Renderer.cs index 0bf1ca8c59d2..2eef79bb589a 100644 --- a/src/Components/Components/src/RenderTree/Renderer.cs +++ b/src/Components/Components/src/RenderTree/Renderer.cs @@ -6,6 +6,7 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Linq; +using System.Threading; using Microsoft.AspNetCore.Components.HotReload; using Microsoft.AspNetCore.Components.Infrastructure; using Microsoft.AspNetCore.Components.Reflection; @@ -235,9 +236,9 @@ protected internal int AssignRootComponentId(IComponent component) // 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 = System.Threading.ExecutionContext.Capture(); + var executionContext = ExecutionContext.Capture(); _hotReloadRenderHandler = new HotReloadRenderHandler(this, executionContext); - HotReloadManager.OnDeltaApplied += _hotReloadRenderHandler.OnDeltaApplied; + HotReloadManager.OnDeltaApplied += _hotReloadRenderHandler.RerenderOnHotReload; } } @@ -1242,7 +1243,7 @@ protected virtual void Dispose(bool disposing) if (_hotReloadInitialized && HotReloadManager.MetadataUpdateSupported && _hotReloadRenderHandler is not null) { - HotReloadManager.OnDeltaApplied -= _hotReloadRenderHandler.OnDeltaApplied; + HotReloadManager.OnDeltaApplied -= _hotReloadRenderHandler.RerenderOnHotReload; } // It's important that we handle all exceptions here before reporting any of them. @@ -1378,18 +1379,9 @@ public async ValueTask DisposeAsync() } } - private sealed class HotReloadRenderHandler + private sealed class HotReloadRenderHandler(Renderer renderer, ExecutionContext? executionContext) { - private readonly Renderer _renderer; - private readonly System.Threading.ExecutionContext? _executionContext; - - public HotReloadRenderHandler(Renderer renderer, System.Threading.ExecutionContext? executionContext) - { - _renderer = renderer; - _executionContext = executionContext; // May be null if flow is suppressed - } - - public void OnDeltaApplied() + public void RerenderOnHotReload() { if (_executionContext is null) { @@ -1397,7 +1389,7 @@ public void OnDeltaApplied() } else { - System.Threading.ExecutionContext.Run(_executionContext, static s => ((Renderer)s!).RenderRootComponentsOnHotReload(), _renderer); + ExecutionContext.Run(executionContext, static s => ((Renderer)s!).RenderRootComponentsOnHotReload(), renderer); } } } From 9971a3b365741dea8c8f00c8e4365db8beb14da1 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Fri, 26 Sep 2025 19:16:09 +0200 Subject: [PATCH 4/5] Fix the test --- .../Components/src/RenderTree/Renderer.cs | 5 ++- .../Components/test/RendererTest.cs | 33 ++++++++----------- 2 files changed, 16 insertions(+), 22 deletions(-) diff --git a/src/Components/Components/src/RenderTree/Renderer.cs b/src/Components/Components/src/RenderTree/Renderer.cs index 2eef79bb589a..ad0864443da4 100644 --- a/src/Components/Components/src/RenderTree/Renderer.cs +++ b/src/Components/Components/src/RenderTree/Renderer.cs @@ -6,7 +6,6 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Linq; -using System.Threading; using Microsoft.AspNetCore.Components.HotReload; using Microsoft.AspNetCore.Components.Infrastructure; using Microsoft.AspNetCore.Components.Reflection; @@ -1383,9 +1382,9 @@ private sealed class HotReloadRenderHandler(Renderer renderer, ExecutionContext? { public void RerenderOnHotReload() { - if (_executionContext is null) + if (executionContext is null) { - _renderer.RenderRootComponentsOnHotReload(); + renderer.RenderRootComponentsOnHotReload(); } else { diff --git a/src/Components/Components/test/RendererTest.cs b/src/Components/Components/test/RendererTest.cs index 81b657be3160..0fd72362543a 100644 --- a/src/Components/Components/test/RendererTest.cs +++ b/src/Components/Components/test/RendererTest.cs @@ -5029,21 +5029,17 @@ public async Task DisposingRenderer_UnsubsribesFromHotReloadManager() } [Fact] - public async Task HotReload_ReRenderPreservesAsyncLocalValues_FailsToday() + public async Task HotReload_ReRenderPreservesAsyncLocalValues() { - // This is a regression test for the reported issue: AsyncLocal values are lost during hot reload. - // The desired (correct) behavior is that AsyncLocal values flow into the hot-reload re-render. - // Currently, because the hot reload callback is raised from a context without the original ExecutionContext, - // the AsyncLocal value is lost and the assertion below will FAIL (demonstrating the bug). - await using var renderer = new TestRenderer(); - var hotReloadManager = new HotReloadManager { MetadataUpdateSupported = true }; - renderer.HotReloadManager = hotReloadManager; + + renderer.HotReloadManager = HotReloadManager.Default; + HotReloadManager.Default.MetadataUpdateSupported = true; var component = new AsyncLocalCaptureComponent(); // Establish AsyncLocal value before registering hot reload handler / rendering. - AsyncLocalCaptureComponent.TestAsyncLocal.Value = "AmbientValue"; + ServiceAccessor.TestAsyncLocal.Value = "AmbientValue"; var componentId = renderer.AssignRootComponentId(component); await renderer.Dispatcher.InvokeAsync(() => renderer.RenderRootComponentAsync(componentId)); @@ -5052,20 +5048,16 @@ public async Task HotReload_ReRenderPreservesAsyncLocalValues_FailsToday() Assert.Null(component.HotReloadValue); // Simulate hot reload delta applied from a fresh thread (different ExecutionContext) so the AsyncLocal value is lost. - var expected = AsyncLocalCaptureComponent.TestAsyncLocal.Value; + var expected = ServiceAccessor.TestAsyncLocal.Value; var thread = new Thread(() => { // Simulate environment where the ambient value is not present on the hot reload thread. - AsyncLocalCaptureComponent.TestAsyncLocal.Value = null; - var evtField = typeof(HotReloadManager).GetField("OnDeltaApplied", BindingFlags.Instance | BindingFlags.NonPublic); - var del = (Action)evtField.GetValue(hotReloadManager); - del?.Invoke(); + ServiceAccessor.TestAsyncLocal.Value = null; + HotReloadManager.UpdateApplication([]); }); thread.Start(); thread.Join(); - // EXPECTED (desired) correct behavior: value should still be present. - // ACTUAL today: this will be null, so the test fails, confirming the bug. Assert.Equal(expected, component.HotReloadValue); } @@ -5222,10 +5214,13 @@ protected override Task UpdateDisplayAsync(in RenderBatch renderBatch) => Task.CompletedTask; } - private class AsyncLocalCaptureComponent : IComponent + private class ServiceAccessor { - public static readonly AsyncLocal TestAsyncLocal = new(); + public static AsyncLocal TestAsyncLocal = new AsyncLocal(); + } + private class AsyncLocalCaptureComponent : IComponent + { private bool _initialized; private RenderHandle _renderHandle; public string HotReloadValue { get; private set; } @@ -5241,7 +5236,7 @@ public Task SetParametersAsync(ParameterView parameters) else { // Hot reload re-render path. - HotReloadValue = TestAsyncLocal.Value; + HotReloadValue = ServiceAccessor.TestAsyncLocal.Value; } return Task.CompletedTask; } From 2d9ee69b46ae26ff612e6139bbd197f4ab69193b Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Fri, 26 Sep 2025 19:20:22 +0200 Subject: [PATCH 5/5] Cleanup --- src/Components/Components/test/RendererTest.cs | 5 +++-- src/Components/Shared/src/HotReloadManager.cs | 3 +++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Components/Components/test/RendererTest.cs b/src/Components/Components/test/RendererTest.cs index 0fd72362543a..90d46f746d03 100644 --- a/src/Components/Components/test/RendererTest.cs +++ b/src/Components/Components/test/RendererTest.cs @@ -5033,7 +5033,8 @@ public async Task HotReload_ReRenderPreservesAsyncLocalValues() { await using var renderer = new TestRenderer(); - renderer.HotReloadManager = HotReloadManager.Default; + var hotReloadManager = new HotReloadManager { MetadataUpdateSupported = true }; + renderer.HotReloadManager = hotReloadManager; HotReloadManager.Default.MetadataUpdateSupported = true; var component = new AsyncLocalCaptureComponent(); @@ -5053,7 +5054,7 @@ public async Task HotReload_ReRenderPreservesAsyncLocalValues() { // Simulate environment where the ambient value is not present on the hot reload thread. ServiceAccessor.TestAsyncLocal.Value = null; - HotReloadManager.UpdateApplication([]); + hotReloadManager.TriggerOnDeltaApplied(); }); thread.Start(); thread.Join(); 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(); }