Skip to content

Commit 57d9abe

Browse files
authored
[Blazor][HotReload] Capture the execution context before calling RenderRootComponentsOnHotReload (#63846)
Captures the execution context at the time a root component is initialized and runs the hot reload callback against that context.
1 parent 7632eaa commit 57d9abe

File tree

3 files changed

+90
-3
lines changed

3 files changed

+90
-3
lines changed

src/Components/Components/src/RenderTree/Renderer.cs

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ public abstract partial class Renderer : IDisposable, IAsyncDisposable
5454
private bool _rendererIsDisposed;
5555

5656
private bool _hotReloadInitialized;
57+
private HotReloadRenderHandler? _hotReloadRenderHandler;
5758

5859
/// <summary>
5960
/// Allows the caller to handle exceptions from the SynchronizationContext when one is available.
@@ -231,7 +232,12 @@ protected internal int AssignRootComponentId(IComponent component)
231232
_hotReloadInitialized = true;
232233
if (HotReloadManager.MetadataUpdateSupported)
233234
{
234-
HotReloadManager.OnDeltaApplied += RenderRootComponentsOnHotReload;
235+
// Capture the current ExecutionContext so AsyncLocal values present during initial root component
236+
// registration flow through to hot reload re-renders. Without this, hot reload callbacks execute
237+
// on a thread without the original ambient context and AsyncLocal values appear null.
238+
var executionContext = ExecutionContext.Capture();
239+
_hotReloadRenderHandler = new HotReloadRenderHandler(this, executionContext);
240+
HotReloadManager.OnDeltaApplied += _hotReloadRenderHandler.RerenderOnHotReload;
235241
}
236242
}
237243

@@ -1234,9 +1240,9 @@ protected virtual void Dispose(bool disposing)
12341240
_rendererIsDisposed = true;
12351241
}
12361242

1237-
if (_hotReloadInitialized && HotReloadManager.MetadataUpdateSupported)
1243+
if (_hotReloadInitialized && HotReloadManager.MetadataUpdateSupported && _hotReloadRenderHandler is not null)
12381244
{
1239-
HotReloadManager.OnDeltaApplied -= RenderRootComponentsOnHotReload;
1245+
HotReloadManager.OnDeltaApplied -= _hotReloadRenderHandler.RerenderOnHotReload;
12401246
}
12411247

12421248
// It's important that we handle all exceptions here before reporting any of them.
@@ -1371,4 +1377,19 @@ public async ValueTask DisposeAsync()
13711377
}
13721378
}
13731379
}
1380+
1381+
private sealed class HotReloadRenderHandler(Renderer renderer, ExecutionContext? executionContext)
1382+
{
1383+
public void RerenderOnHotReload()
1384+
{
1385+
if (executionContext is null)
1386+
{
1387+
renderer.RenderRootComponentsOnHotReload();
1388+
}
1389+
else
1390+
{
1391+
ExecutionContext.Run(executionContext, static s => ((Renderer)s!).RenderRootComponentsOnHotReload(), renderer);
1392+
}
1393+
}
1394+
}
13741395
}

src/Components/Components/test/RendererTest.cs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System.Collections.Concurrent;
55
using System.Diagnostics;
66
using System.Globalization;
7+
using System.Reflection;
78
using System.Runtime.ExceptionServices;
89
using Microsoft.AspNetCore.Components.CompilerServices;
910
using Microsoft.AspNetCore.Components.HotReload;
@@ -5027,6 +5028,40 @@ public async Task DisposingRenderer_UnsubsribesFromHotReloadManager()
50275028
Assert.False(hotReloadManager.IsSubscribedTo);
50285029
}
50295030

5031+
[Fact]
5032+
public async Task HotReload_ReRenderPreservesAsyncLocalValues()
5033+
{
5034+
await using var renderer = new TestRenderer();
5035+
5036+
var hotReloadManager = new HotReloadManager { MetadataUpdateSupported = true };
5037+
renderer.HotReloadManager = hotReloadManager;
5038+
HotReloadManager.Default.MetadataUpdateSupported = true;
5039+
5040+
var component = new AsyncLocalCaptureComponent();
5041+
5042+
// Establish AsyncLocal value before registering hot reload handler / rendering.
5043+
ServiceAccessor.TestAsyncLocal.Value = "AmbientValue";
5044+
5045+
var componentId = renderer.AssignRootComponentId(component);
5046+
await renderer.Dispatcher.InvokeAsync(() => renderer.RenderRootComponentAsync(componentId));
5047+
5048+
// Sanity: initial render should not have captured a hot-reload value yet.
5049+
Assert.Null(component.HotReloadValue);
5050+
5051+
// Simulate hot reload delta applied from a fresh thread (different ExecutionContext) so the AsyncLocal value is lost.
5052+
var expected = ServiceAccessor.TestAsyncLocal.Value;
5053+
var thread = new Thread(() =>
5054+
{
5055+
// Simulate environment where the ambient value is not present on the hot reload thread.
5056+
ServiceAccessor.TestAsyncLocal.Value = null;
5057+
hotReloadManager.TriggerOnDeltaApplied();
5058+
});
5059+
thread.Start();
5060+
thread.Join();
5061+
5062+
Assert.Equal(expected, component.HotReloadValue);
5063+
}
5064+
50305065
[Fact]
50315066
public void ThrowsForUnknownRenderMode_OnComponentType()
50325067
{
@@ -5180,6 +5215,34 @@ protected override Task UpdateDisplayAsync(in RenderBatch renderBatch)
51805215
=> Task.CompletedTask;
51815216
}
51825217

5218+
private class ServiceAccessor
5219+
{
5220+
public static AsyncLocal<string> TestAsyncLocal = new AsyncLocal<string>();
5221+
}
5222+
5223+
private class AsyncLocalCaptureComponent : IComponent
5224+
{
5225+
private bool _initialized;
5226+
private RenderHandle _renderHandle;
5227+
public string HotReloadValue { get; private set; }
5228+
5229+
public void Attach(RenderHandle renderHandle) => _renderHandle = renderHandle;
5230+
5231+
public Task SetParametersAsync(ParameterView parameters)
5232+
{
5233+
if (!_initialized)
5234+
{
5235+
_initialized = true; // First (normal) render, don't capture.
5236+
}
5237+
else
5238+
{
5239+
// Hot reload re-render path.
5240+
HotReloadValue = ServiceAccessor.TestAsyncLocal.Value;
5241+
}
5242+
return Task.CompletedTask;
5243+
}
5244+
}
5245+
51835246
private class TestComponent : IComponent, IDisposable
51845247
{
51855248
private RenderHandle _renderHandle;

src/Components/Shared/src/HotReloadManager.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,7 @@ internal sealed class HotReloadManager
2525
/// MetadataUpdateHandler event. This is invoked by the hot reload host via reflection.
2626
/// </summary>
2727
public static void UpdateApplication(Type[]? _) => Default.OnDeltaApplied?.Invoke();
28+
29+
// For testing purposes only
30+
internal void TriggerOnDeltaApplied() => OnDeltaApplied?.Invoke();
2831
}

0 commit comments

Comments
 (0)