Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
11 changes: 2 additions & 9 deletions src/Try.Core/CompilationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,6 @@ public class CompilationService
"@using MudBlazor"
];

private const string MudBlazorServices = @"
<MudDialogProvider FullWidth=""true"" MaxWidth=""MaxWidth.ExtraSmall"" />
<MudSnackbarProvider/>

";

// Creating the initial compilation + reading references is on the order of 250ms without caching
// so making sure it doesn't happen for each run.
private static CSharpCompilation _baseCompilation;
Expand Down Expand Up @@ -129,6 +123,7 @@ public async Task<CompileToAssemblyResult> CompileToAssemblyAsync(
var codeHash = ComputeCodeHash(codeFiles);
if (_cachedResult != null && _lastCodeHash == codeHash)
{
_cachedResult.IsFromCache = true;
return _cachedResult;
}

Expand Down Expand Up @@ -258,9 +253,7 @@ private async Task<IReadOnlyList<CompileToCSharpResult>> CompileToCSharpAsync(
{
if (codeFile.Type == CodeFileType.Razor)
{
var fileContent = index == 0 ? MudBlazorServices : string.Empty;
fileContent += codeFile.Content;
var projectItem = CreateRazorProjectItem(codeFile.Path, fileContent);
var projectItem = CreateRazorProjectItem(codeFile.Path, codeFile.Content);

var codeDocument = projectEngine.ProcessDeclarationOnly(projectItem);
var cSharpDocument = codeDocument.GetCSharpDocument();
Expand Down
2 changes: 2 additions & 0 deletions src/Try.Core/CompileToAssemblyResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,7 @@ public class CompileToAssemblyResult
public IEnumerable<CompilationDiagnostic> Diagnostics { get; set; } = [];

public byte[] AssemblyBytes { get; set; }

public bool IsFromCache { get; set; }
}
}
3 changes: 0 additions & 3 deletions src/Try.Core/CoreConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,6 @@ void ButtonOnClick()
}
}";

public const string DefaultUserComponentsAssemblyBytes =
"TVqQAAMAAAAEAAAA//8AALgAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAA4fug4AtAnNIbgBTM0hVGhpcyBwcm9ncmFtIGNhbm5vdCBiZSBydW4gaW4gRE9TIG1vZGUuDQ0KJAAAAAAAAABQRQAATAEDAF0sHZkAAAAAAAAAAOAAIiALATAAAAgAAAAGAAAAAAAA9iYAAAAgAAAAQAAAAAAAEAAgAAAAAgAABAAAAAAAAAAEAAAAAAAAAACAAAAAAgAAAAAAAAMAYIUAABAAABAAAAAAEAAAEAAAAAAAABAAAAAAAAAAAAAAAKImAABPAAAAAEAAANQDAAAAAAAAAAAAAAAAAAAAAAAAAGAAAAwAAACoJQAAVAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAACAAAAAAAAAAAAAAACCAAAEgAAAAAAAAAAAAAAC50ZXh0AAAA/AYAAAAgAAAACAAAAAIAAAAAAAAAAAAAAAAAACAAAGAucnNyYwAAANQDAAAAQAAAAAQAAAAKAAAAAAAAAAAAAAAAAABAAABALnJlbG9jAAAMAAAAAGAAAAACAAAADgAAAAAAAAAAAAAAAAAAQAAAQgAAAAAAAAAAAAAAAAAAAADWJgAAAAAAAEgAAAACAAUAXCAAAEwFAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAYqHgIoDAAACioAAEJTSkIBAAEAAAAAAAwAAAB2NC4wLjMwMzE5AAAAAAUAbAAAAKQBAAAjfgAAEAIAAEwCAAAjU3RyaW5ncwAAAABcBAAABAAAACNVUwBgBAAAEAAAACNHVUlEAAAAcAQAANwAAAAjQmxvYgAAAAAAAAACAAABRxUAAAkAAAAA+gEzABYAAAEAAAAOAAAAAgAAAAIAAAABAAAADAAAAAsAAAABAAAAAgAAAAAAhQEBAAAAAAAGAOsA6gEGAD0B6gEGADcA1wEPAAoCAAAGACQBowEGALQAowEGAHEAowEGAI4AowEGAAsBowEGAEsAowEGANMA6gEKAGIAGQIKACkAGQIKALUBWwEAAAAAAQAAAAAAAQABAAEAEACcATkCNQABAAEAUCAAAAAAxAAKACcAAQBSIAAAAACGGNEBBgACAAAAAQDHAQkA0QEBABEA0QEGABkA0QEKACkA0QEQADEA0QEQADkA0QEQAEEA0QEQAEkA0QEQAFEA0QEQAFkA0QEBAGEA0QEQAGkA0QEGACcAUwDRAC4ACwAtAC4AEwA2AC4AGwBVAC4AIwBeAC4AKwB2AC4AMwCDAC4AOwCQAC4AQwBeAC4ASwBeAEMAWwDEAASAAAABAAAAAAAAAAAAAAAAADkCAAAJAAAAAAAAAAAAAAAVABoAAAAAAAkAAAAAAAAAAAAAAB4AGQIAAAAAAAAAAAA8TW9kdWxlPgBCdWlsZFJlbmRlclRyZWUAU3lzdGVtLlJ1bnRpbWUAQ29tcG9uZW50QmFzZQBEZWJ1Z2dhYmxlQXR0cmlidXRlAEFzc2VtYmx5VGl0bGVBdHRyaWJ1dGUAUm91dGVBdHRyaWJ1dGUAQXNzZW1ibHlGaWxlVmVyc2lvbkF0dHJpYnV0ZQBBc3NlbWJseUluZm9ybWF0aW9uYWxWZXJzaW9uQXR0cmlidXRlAEFzc2VtYmx5Q29uZmlndXJhdGlvbkF0dHJpYnV0ZQBSZWZTYWZldHlSdWxlc0F0dHJpYnV0ZQBDb21waWxhdGlvblJlbGF4YXRpb25zQXR0cmlidXRlAEFzc2VtYmx5UHJvZHVjdEF0dHJpYnV0ZQBBc3NlbWJseUNvbXBhbnlBdHRyaWJ1dGUAUnVudGltZUNvbXBhdGliaWxpdHlBdHRyaWJ1dGUATWljcm9zb2Z0LkFzcE5ldENvcmUuQ29tcG9uZW50cy5SZW5kZXJpbmcAVHJ5LlVzZXJDb21wb25lbnRzLmRsbABfX01haW4AU3lzdGVtLlJlZmxlY3Rpb24AUmVuZGVyVHJlZUJ1aWxkZXIAX19idWlsZGVyAC5jdG9yAFN5c3RlbS5EaWFnbm9zdGljcwBTeXN0ZW0uUnVudGltZS5Db21waWxlclNlcnZpY2VzAERlYnVnZ2luZ01vZGVzAE1pY3Jvc29mdC5Bc3BOZXRDb3JlLkNvbXBvbmVudHMAVHJ5LlVzZXJDb21wb25lbnRzAAAAAAAdJaLVPNvETL37jSE7YZGLAAQgAQEIAyAAAQUgAQEREQQgAQEOCLA/X38R1Qo6CK25eTgp3a5gBSABARI5CAEACAAAAAAAHgEAAQBU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AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==";

public const string DefaultRazorFileContentFormat = "<h1>{0}</h1>";

public static readonly string DefaultCSharpFileContentFormat =
Expand Down
35 changes: 29 additions & 6 deletions src/TryMudBlazor.Client/App.razor
Original file line number Diff line number Diff line change
@@ -1,11 +1,34 @@
@using System.Reflection
@using TryMudBlazor.Client.Pages
@using TryMudBlazor.Client.Services
@implements IDisposable

<Router AppAssembly="@typeof(Program).Assembly"
AdditionalAssemblies="@(new List<System.Reflection.Assembly> { typeof(Try.UserComponents.__Main).Assembly })">
AdditionalAssemblies="@_additionalAssemblies"
NotFoundPage="typeof(NotFound)">
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The NotFoundPage property expects a Type, but it's being set to a string expression "typeof(NotFound)". This should be @typeof(NotFound) or simply typeof(NotFound) without quotes. As written, this will cause a compilation error since the Router component expects Type, not string.

Suggested change
NotFoundPage="typeof(NotFound)">
NotFoundPage="@typeof(NotFound)">

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As written, this will cause a compilation error since the Router component expects Type, not string.

No comments here...

<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(EmptyLayout)" />
</Found>
<NotFound>
<LayoutView Layout="@typeof(EmptyLayout)">
<p>Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>

@code {
private readonly Assembly[] _additionalAssemblies;
private readonly IDisposable _subscription;

public App(UserComponentsAssemblyService assemblyService)
{
_additionalAssemblies = [assemblyService.Assembly];
_subscription = assemblyService.Subscribe(HandleAssemblyChanged);
}

private Task HandleAssemblyChanged(Assembly assembly)
{
_additionalAssemblies[0] = assembly;
return InvokeAsync(StateHasChanged);
}

public void Dispose()
{
_subscription.Dispose();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using System.Reflection;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Try.UserComponents;

namespace TryMudBlazor.Client.Extensions;

internal static class WebAssemblyHostBuilderExtensions
{
private static readonly MethodInfo ConfigureMethod = ResolveConfigureMethod();

public static void TryInvokeUserStartup(this WebAssemblyHostBuilder builder)
=> ConfigureMethod?.Invoke(null, [builder]);
Comment on lines +9 to +12
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ConfigureMethod is resolved once at static initialization and cached. If the UserComponents assembly is hot-reloaded with a different UserStartup class definition, the cached method will still point to the old assembly's method. Since UserStartup changes trigger a full reload (as seen in UserMainPage.razor lines 57-62), this is acceptable, but consider adding a comment explaining this limitation or the relationship to the hot reload logic.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

@ScarletKuro ScarletKuro Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tested it before commit, and tests showed it's all good. The startup doesn't get cached, and once i edit it everything is working as expected because full iframe reload happens on edit, and it will get new usercomponents and new TryInvokeUserStartup invocation


private static MethodInfo ResolveConfigureMethod()
{
var assembly = typeof(__Main).Assembly;

var startupType =
assembly.GetType("UserStartup", throwOnError: false, ignoreCase: true) ??
assembly.GetType("Try.UserComponents.UserStartup", throwOnError: false, ignoreCase: true);

var method = startupType?.GetMethod("Configure", BindingFlags.Static | BindingFlags.Public);
if (method is null)
return null;

var parameters = method.GetParameters();
if (parameters.Length != 1 || parameters[0].ParameterType != typeof(WebAssemblyHostBuilder))
return null;

return method;
}
}
2 changes: 2 additions & 0 deletions src/TryMudBlazor.Client/Models/TryConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ public static class Editor
public static class CodeExecution
{
public const string UpdateUserComponentsDll = "Try.CodeExecution.updateUserComponentsDll";
public const string ClearUserComponentsDll = "Try.CodeExecution.clearUserComponentsDll";
public const string HotReloadIframe = "Try.CodeExecution.hotReloadIframe";
}
}
}
4 changes: 4 additions & 0 deletions src/TryMudBlazor.Client/Pages/NotFound.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
@page "/not-found"
@layout EmptyLayout

<p>Sorry, there's nothing at this address.</p>
20 changes: 1 addition & 19 deletions src/TryMudBlazor.Client/Pages/Repl.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ public partial class Repl : IDisposable
{
[Inject] private LayoutService LayoutService { get; set; }

private const string MainComponentCodePrefix = "@page \"/__main\"\n";
private const string MainUserPagePath = "/__main";

private DotNetObjectReference<Repl> dotNetInstance;
Expand Down Expand Up @@ -166,19 +165,10 @@ private async Task CompileAsync()
await Task.Yield();

CompileToAssemblyResult compilationResult = null;
CodeFile mainComponent = null;
string originalMainComponentContent = null;
try
{
this.UpdateActiveCodeFileContent();

// Add the necessary main component code prefix and store the original content so we can revert right after compilation.
if (this.CodeFiles.TryGetValue(CoreConstants.MainComponentFilePath, out mainComponent))
{
originalMainComponentContent = mainComponent.Content;
mainComponent.Content = MainComponentCodePrefix + originalMainComponentContent.Replace(MainComponentCodePrefix, "");
}

compilationResult = await this.CompilationService.CompileToAssemblyAsync(
this.CodeFiles.Values,
this.UpdateLoaderTextAsync);
Expand All @@ -193,21 +183,13 @@ private async Task CompileAsync()
}
finally
{
if (mainComponent != null)
{
mainComponent.Content = originalMainComponentContent;
}

this.Loading = false;
}

if (compilationResult?.AssemblyBytes?.Length > 0)
{
// Make sure the DLL is updated before reloading the user page
this.JsRuntime.InvokeVoid(Try.CodeExecution.UpdateUserComponentsDll, compilationResult.AssemblyBytes);

// TODO: Add error page in iframe
this.JsRuntime.InvokeVoid(Try.ReloadIframe, "user-page-window", MainUserPagePath);
this.JsRuntime.InvokeVoid(Try.CodeExecution.HotReloadIframe, "user-page-window", MainUserPagePath);
}
}

Expand Down
91 changes: 91 additions & 0 deletions src/TryMudBlazor.Client/Pages/UserMainPage.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
@page "/__main"
@using Microsoft.JSInterop
@using System.Runtime.Loader
@using Try.UserComponents
@using TryMudBlazor.Client.Services
@inject IJSRuntime JSRuntime
@inject UserComponentsAssemblyService AssemblyService
@implements IAsyncDisposable

<MudDialogProvider FullWidth="true" MaxWidth="MaxWidth.ExtraSmall" />
<MudSnackbarProvider />

<DynamicComponent Type="@_type" />

@code {
private Type _type = typeof(__Main);
private AssemblyLoadContext _loadContext;
private readonly Lazy<DotNetObjectReference<UserMainPage>> _ref;

public UserMainPage()
{
_ref = new Lazy<DotNetObjectReference<UserMainPage>>(CreateDotNetObject());
}

protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await JSRuntime.InvokeVoidAsync("Try.registerHotReload", _ref.Value);
}
}

[JSInvokable]
public async Task HotReload()
{
try
{
var base64 = await JSRuntime.InvokeAsync<string>("sessionStorage.getItem", "TryMudBlazor.UserComponentsDllBase64");
if (base64 == null)
{
// sessionStorage was cleared (e.g. crash recovery) — fall back to a full reload
// so loadBootResource picks up the safe static DLL.
await JSRuntime.InvokeVoidAsync("Try.requestFullReload", "/__main");
return;
}

var bytes = Convert.FromBase64String(base64);

// Swap to a fresh collectible context; previous one becomes eligible for GC
// once Blazor disposes the old component instance below.
var previousContext = _loadContext;
_loadContext = new AssemblyLoadContext(name: null, isCollectible: true);
var assembly = _loadContext.LoadFromStream(new MemoryStream(bytes));
previousContext?.Unload();
Comment on lines +51 to +54
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The previous AssemblyLoadContext is unloaded immediately after loading the new assembly, but the old component instance that references types from the previous context may still be active at this point. The comment on line 49-50 suggests waiting for Blazor to dispose the old component, but the code doesn't actually wait. Consider moving the previousContext.Unload() call to after StateHasChanged completes, or use a delayed/queued unload mechanism to ensure the old component has been disposed first. This could lead to ObjectDisposedException or TypeLoadException when the old component tries to access its types during disposal.

Copilot uses AI. Check for mistakes.

// UserStartup requires DI re-registration — fall back to full iframe reload
if (assembly.GetType("UserStartup") is not null ||
assembly.GetType("Try.UserComponents.UserStartup") is not null)
{
await JSRuntime.InvokeVoidAsync("Try.requestFullReload", "/__main");
return;
}

var newType = assembly.GetType("Try.UserComponents.__Main");
if (newType is not null)
{
// Update the Router's AdditionalAssemblies before rendering the new __Main
// so that any NavigateTo calls inside it can resolve user-defined @page routes.
await AssemblyService.UpdateAssemblyAsync(assembly);
_type = newType;
await InvokeAsync(StateHasChanged);
}
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the user's compiled assembly doesn't contain a Try.UserComponents.__Main type (newType is null), the code silently does nothing. This could leave the UI in an inconsistent state where the assembly was updated but the component wasn't re-rendered. Consider logging this scenario or falling back to a full reload to ensure the user sees an error or updated state.

Suggested change
}
}
else
{
// No __Main component found in the updated assembly — fall back to a full reload
// to avoid leaving the UI in an inconsistent state.
await JSRuntime.InvokeVoidAsync("Try.requestFullReload", "/__main");
}

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is impossible scenario

}
catch
{
Comment on lines +74 to +75
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The catch block at line 74 swallows all exceptions without logging them. This makes debugging difficult when hot reload fails for unexpected reasons. Consider logging the exception before falling back to a full reload, especially during development. At minimum, log to the browser console using JSRuntime or Console.WriteLine.

Suggested change
catch
{
catch (Exception ex)
{
Console.WriteLine($"Hot reload failed, falling back to full reload: {ex}");

Copilot uses AI. Check for mistakes.
await JSRuntime.InvokeVoidAsync("Try.requestFullReload", "/__main");
}
}
Comment on lines +33 to +78
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If HotReload is called multiple times in quick succession (e.g., rapid user edits), there's a potential for race conditions. The _loadContext could be unloaded while a previous HotReload call is still processing, or _type could be updated mid-render. Consider adding a semaphore or checking if a reload is already in progress, and either queuing subsequent requests or ignoring them until the current reload completes.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not called on users edits, for now. I prefer yagni here


public async ValueTask DisposeAsync()
{
if (_ref.IsValueCreated)
{
await JSRuntime.InvokeVoidAsync("Try.unregisterHotReload");
_ref.Value.Dispose();
}
_loadContext?.Unload();
}

private DotNetObjectReference<UserMainPage> CreateDotNetObject() => DotNetObjectReference.Create(this);
}
22 changes: 4 additions & 18 deletions src/TryMudBlazor.Client/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ namespace TryMudBlazor.Client
using Services.UserPreferences;
using Try.Core;
using Try.UserComponents;
using TryMudBlazor.Client.Extensions;
using TryMudBlazor.Client.Models;
using TryMudBlazor.Client.Services;

Expand All @@ -32,6 +33,7 @@ public static async Task Main(string[] args)
builder.Services.AddScoped(_ => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
builder.Services.AddScoped<SnippetsService>();
builder.Services.AddSingleton(new CompilationService());
builder.Services.AddSingleton<UserComponentsAssemblyService>();

builder.Services
.AddOptions<SnippetsOptions>()
Expand All @@ -46,7 +48,7 @@ public static async Task Main(string[] args)
var jsRuntime = GetJsRuntime();
try
{
ExecuteUserDefinedConfiguration(builder);
builder.TryInvokeUserStartup();
}
catch (Exception exception)
{
Expand All @@ -55,28 +57,12 @@ public static async Task Main(string[] args)
var actualException = exception is TargetInvocationException tie ? tie.InnerException : exception;
await Console.Error.WriteLineAsync($"Error on app startup: {actualException}");

jsRuntime.InvokeVoid(Try.CodeExecution.UpdateUserComponentsDll, CoreConstants.DefaultUserComponentsAssemblyBytes);
jsRuntime.InvokeVoid(Try.CodeExecution.ClearUserComponentsDll);
}

await builder.Build().RunAsync();
}

private static void ExecuteUserDefinedConfiguration(WebAssemblyHostBuilder builder)
{
var userComponentsAssembly = typeof(__Main).Assembly;
var startupType = userComponentsAssembly.GetType("UserStartup", throwOnError: false, ignoreCase: true)
?? userComponentsAssembly.GetType("Try.UserComponents.UserStartup", throwOnError: false, ignoreCase: true);
if (startupType == null)
return;
var configureMethod = startupType.GetMethod("Configure", BindingFlags.Static | BindingFlags.Public);
if (configureMethod == null)
return;
var configureMethodParams = configureMethod.GetParameters();
if (configureMethodParams.Length != 1 || configureMethodParams[0].ParameterType != typeof(WebAssemblyHostBuilder))
return;
configureMethod.Invoke(obj: null, new object[] { builder });
}

private static IJSInProcessRuntime GetJsRuntime()
{
const string defaultJsRuntimeTypeName = "DefaultWebAssemblyJSRuntime";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public void Log<TState>(
{
if (exception?.ToString()?.Contains(CompilationService.DefaultRootNamespace) ?? false)
{
_jsRuntime.InvokeVoid(Try.CodeExecution.UpdateUserComponentsDll, CoreConstants.DefaultUserComponentsAssemblyBytes);
_jsRuntime.InvokeVoid(Try.CodeExecution.ClearUserComponentsDll);
}
}

Expand Down
31 changes: 31 additions & 0 deletions src/TryMudBlazor.Client/Services/UserComponentsAssemblyService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using System.Reflection;
using Try.UserComponents;

namespace TryMudBlazor.Client.Services;

public class UserComponentsAssemblyService
{
private readonly List<Func<Assembly, Task>> _callbacks = [];

public Assembly Assembly { get; private set; } = typeof(__Main).Assembly;

public IDisposable Subscribe(Func<Assembly, Task> callback)
{
_callbacks.Add(callback);
return new Subscription(() => _callbacks.Remove(callback));
}

public async Task UpdateAssemblyAsync(Assembly assembly)
{
Assembly = assembly;
foreach (var callback in _callbacks)
{
Comment on lines +14 to +22
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The UpdateAssemblyAsync method iterates through and invokes callbacks without any synchronization. If a subscription is removed (via Dispose) while UpdateAssemblyAsync is iterating through _callbacks, this could result in a collection modified exception or skipped callbacks. Consider using a thread-safe collection like ConcurrentBag, or taking a snapshot of the callbacks list before iteration (e.g., _callbacks.ToArray()).

Suggested change
_callbacks.Add(callback);
return new Subscription(() => _callbacks.Remove(callback));
}
public async Task UpdateAssemblyAsync(Assembly assembly)
{
Assembly = assembly;
foreach (var callback in _callbacks)
{
lock (_callbacks)
{
_callbacks.Add(callback);
}
return new Subscription(() =>
{
lock (_callbacks)
{
_callbacks.Remove(callback);
}
});
}
public async Task UpdateAssemblyAsync(Assembly assembly)
{
Assembly = assembly;
Func<Assembly, Task>[] callbacksSnapshot;
lock (_callbacks)
{
callbacksSnapshot = _callbacks.ToArray();
}
foreach (var callback in callbacksSnapshot)
{

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No point, there is only one reporter and one consumer for now. The dispose can be called only by the App.razor for now, which lives as long as the app is open.

await callback(assembly);
}
}

private sealed class Subscription(Action unsubscribe) : IDisposable
{
public void Dispose() => unsubscribe();
}
}
1 change: 1 addition & 0 deletions src/TryMudBlazor.Client/TryMudBlazor.Client.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
<PropertyGroup>
<PublishTrimmed>false</PublishTrimmed>
<WasmEnableWebcil>true</WasmEnableWebcil>
<OverrideHtmlAssetPlaceholders>true</OverrideHtmlAssetPlaceholders>
</PropertyGroup>

<ItemGroup>
Expand Down
Loading
Loading