diff --git a/src/Components/WebView/WebView/src/IpcReceiver.cs b/src/Components/WebView/WebView/src/IpcReceiver.cs index a1cb04e0893e..26d0e89a6ae1 100644 --- a/src/Components/WebView/WebView/src/IpcReceiver.cs +++ b/src/Components/WebView/WebView/src/IpcReceiver.cs @@ -46,6 +46,13 @@ public async Task OnMessageReceivedAsync(PageContext pageContext, string message throw new InvalidOperationException("Cannot receive IPC messages when no page is attached"); } + // If the page context's JS runtime has been disposed, ignore messages + // for the old page since they would target stale JS object references. + if (pageContext.JSRuntime.IsDisposed) + { + return; + } + switch (messageType) { case IpcCommon.IncomingMessageType.BeginInvokeDotNet: diff --git a/src/Components/WebView/WebView/src/PageContext.cs b/src/Components/WebView/WebView/src/PageContext.cs index 9b198fb8d0df..0dc8b1fee180 100644 --- a/src/Components/WebView/WebView/src/PageContext.cs +++ b/src/Components/WebView/WebView/src/PageContext.cs @@ -54,6 +54,12 @@ public PageContext( public async ValueTask DisposeAsync() { + // Prevent any further JS interop calls before disposing the renderer. + // This ensures components that invoke JS in their DisposeAsync see + // JSDisconnectedException instead of sending stale calls to the page. + // Matches the pattern used by CircuitHost.DisposeAsync for Blazor Server. + JSRuntime.MarkAsDisconnected(); + await Renderer.DisposeAsync(); await _serviceScope.DisposeAsync(); } diff --git a/src/Components/WebView/WebView/src/Services/WebViewJSRuntime.cs b/src/Components/WebView/WebView/src/Services/WebViewJSRuntime.cs index 7295575068cf..df9984f0679c 100644 --- a/src/Components/WebView/WebView/src/Services/WebViewJSRuntime.cs +++ b/src/Components/WebView/WebView/src/Services/WebViewJSRuntime.cs @@ -10,9 +10,12 @@ namespace Microsoft.AspNetCore.Components.WebView.Services; internal sealed class WebViewJSRuntime : JSRuntime { private IpcSender _ipcSender; + private bool _isDisposed; public ElementReferenceContext ElementReferenceContext { get; } + internal bool IsDisposed => _isDisposed; + public WebViewJSRuntime() { ElementReferenceContext = new WebElementReferenceContext(this); @@ -26,6 +29,11 @@ public void AttachToWebView(IpcSender ipcSender) _ipcSender = ipcSender; } + internal void MarkAsDisconnected() + { + _isDisposed = true; + } + public JsonSerializerOptions ReadJsonSerializerOptions() => JsonSerializerOptions; protected override void BeginInvokeJS(long taskId, string identifier, string argsJson, JSCallResultType resultType, long targetInstanceId) @@ -45,6 +53,8 @@ protected override void BeginInvokeJS(long taskId, string identifier, string arg protected override void BeginInvokeJS(in JSInvocationInfo invocationInfo) { + ThrowIfDisposed(); + if (_ipcSender is null) { throw new InvalidOperationException("Cannot invoke JavaScript outside of a WebView context."); @@ -55,6 +65,11 @@ protected override void BeginInvokeJS(in JSInvocationInfo invocationInfo) protected override void EndInvokeDotNet(DotNetInvocationInfo invocationInfo, in DotNetInvocationResult invocationResult) { + if (_isDisposed) + { + return; + } + var resultJsonOrErrorMessage = invocationResult.Success ? invocationResult.ResultJson : invocationResult.Exception.ToString(); @@ -63,6 +78,11 @@ protected override void EndInvokeDotNet(DotNetInvocationInfo invocationInfo, in protected override void SendByteArray(int id, byte[] data) { + if (_isDisposed) + { + return; + } + _ipcSender.SendByteArray(id, data); } @@ -73,4 +93,14 @@ protected override Task TransmitStreamAsync(long streamId, DotNetStreamReference { return TransmitDataStreamToJS.TransmitStreamAsync(this, "Blazor._internal.receiveWebViewDotNetDataStream", streamId, dotNetStreamReference); } + + private void ThrowIfDisposed() + { + if (_isDisposed) + { + throw new JSDisconnectedException( + "JavaScript interop calls cannot be issued at this time. This is because the WebView page has been " + + "disposed."); + } + } } diff --git a/src/Components/WebView/WebView/test/WebViewManagerTests.cs b/src/Components/WebView/WebView/test/WebViewManagerTests.cs index 4a76b94073ca..88ac6758604e 100644 --- a/src/Components/WebView/WebView/test/WebViewManagerTests.cs +++ b/src/Components/WebView/WebView/test/WebViewManagerTests.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.Extensions.DependencyInjection; +using Microsoft.JSInterop; namespace Microsoft.AspNetCore.Components.WebView; @@ -110,6 +111,26 @@ public async Task AddRootComponentsWithExistingSelector_Throws() Assert.Equal($"There is already a root component with selector '{arbitraryComponentSelector}'.", ex.Message); } + [Fact] + public async Task AttachingToNewPage_ThrowsJSDisconnectedExceptionDuringComponentDispose() + { + var services = RegisterTestServices().AddTestBlazorWebView().BuildServiceProvider(); + var fileProvider = new TestFileProvider(); + var webViewManager = new TestWebViewManager(services, fileProvider); + await webViewManager.AddRootComponentAsync(typeof(PerformJSInteropOnDisposeComponent), "#app", ParameterView.Empty); + + webViewManager.ReceiveAttachPageMessage(); + + // Simulate a page reload which disposes the old PageContext (including renderer) + // and creates a new one. Components with IAsyncDisposable that call JS interop + // should see JSDisconnectedException. + webViewManager.ReceiveAttachPageMessage(); + + var singleton = services.GetRequiredService(); + Assert.Single(singleton.DisposedComponentExceptions); + Assert.IsType(singleton.DisposedComponentExceptions[0]); + } + private static IServiceCollection RegisterTestServices() { return new ServiceCollection().AddSingleton().AddScoped(); @@ -166,6 +187,7 @@ public Task SetParametersAsync(ParameterView parameters) private class SingletonService { public List Services { get; } = new(); + public List DisposedComponentExceptions { get; } = new(); public void Add(ScopedService service) { @@ -192,4 +214,41 @@ public class AsyncDisposableService : IAsyncDisposable { public ValueTask DisposeAsync() => ValueTask.CompletedTask; } + + private class PerformJSInteropOnDisposeComponent : IComponent, IAsyncDisposable + { + private RenderHandle _handle; + + [Inject] public IJSRuntime JSRuntime { get; set; } = default!; + [Inject] public SingletonService Singleton { get; set; } = default!; + + public void Attach(RenderHandle renderHandle) + { + _handle = renderHandle; + } + + public Task SetParametersAsync(ParameterView parameters) + { + _handle.Render(builder => + { + builder.OpenElement(0, "p"); + builder.AddContent(1, "Hello world!"); + builder.CloseElement(); + }); + + return Task.CompletedTask; + } + + public async ValueTask DisposeAsync() + { + try + { + await JSRuntime.InvokeVoidAsync("SomeJsCleanupCode"); + } + catch (Exception ex) + { + Singleton.DisposedComponentExceptions.Add(ex); + } + } + } }