Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 24 additions & 3 deletions src/Components/Components/src/RenderTree/Renderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ public abstract partial class Renderer : IDisposable, IAsyncDisposable
private bool _rendererIsDisposed;

private bool _hotReloadInitialized;
private HotReloadRenderHandler? _hotReloadRenderHandler;

/// <summary>
/// Allows the caller to handle exceptions from the SynchronizationContext when one is available.
Expand Down Expand Up @@ -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;
}
}

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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);
}
}
}
}
63 changes: 63 additions & 0 deletions src/Components/Components/test/RendererTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()
{
Expand Down Expand Up @@ -5180,6 +5215,34 @@ protected override Task UpdateDisplayAsync(in RenderBatch renderBatch)
=> Task.CompletedTask;
}

private class ServiceAccessor
{
public static AsyncLocal<string> TestAsyncLocal = new AsyncLocal<string>();
}

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;
Expand Down
3 changes: 3 additions & 0 deletions src/Components/Shared/src/HotReloadManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,7 @@ internal sealed class HotReloadManager
/// MetadataUpdateHandler event. This is invoked by the hot reload host via reflection.
/// </summary>
public static void UpdateApplication(Type[]? _) => Default.OnDeltaApplied?.Invoke();

// For testing purposes only
internal void TriggerOnDeltaApplied() => OnDeltaApplied?.Invoke();
}
Loading