Skip to content
Draft
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
7 changes: 7 additions & 0 deletions src/Components/WebView/WebView/src/IpcReceiver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions src/Components/WebView/WebView/src/PageContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
30 changes: 30 additions & 0 deletions src/Components/WebView/WebView/src/Services/WebViewJSRuntime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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)
Expand All @@ -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.");
Expand All @@ -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();
Expand All @@ -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);
}

Expand All @@ -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.");
}
}
}
59 changes: 59 additions & 0 deletions src/Components/WebView/WebView/test/WebViewManagerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<SingletonService>();
Assert.Single(singleton.DisposedComponentExceptions);
Assert.IsType<JSDisconnectedException>(singleton.DisposedComponentExceptions[0]);
}

private static IServiceCollection RegisterTestServices()
{
return new ServiceCollection().AddSingleton<SingletonService>().AddScoped<ScopedService>();
Expand Down Expand Up @@ -166,6 +187,7 @@ public Task SetParametersAsync(ParameterView parameters)
private class SingletonService
{
public List<ScopedService> Services { get; } = new();
public List<Exception> DisposedComponentExceptions { get; } = new();

public void Add(ScopedService service)
{
Expand All @@ -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);
}
}
}
}
Loading