Blazor server-side Fluxor Dispatch Action on Browser tab close

2k views Asked by At

First SO post so please let me know if my question is not adequately put together!

Use Case: The user opens the browser, and presses a "pay on device" button. I dispatch a PayOnDevice action which updates the UI to a loading state. I have a HandlePayOnDevice [Effect] method which pickups up that action and asynchronously starts the device to take a payment. When the user inserts the card into the device the payment succeeds/fails, the async method resolves and updates the UI to success/fail. However, if the browser closes before the async method resolves, I'd like to let the device finish and tell another service that a device payment succeeded or failed.

Issue: I'm able to technically do this by overriding the virtual Dispose(bool disposing) method of the FluxorComponent to dispatch an action "BrowserClosed". Then when the original device async method resolves, I can check the IState to see if the browser closed to know whether to update the UI or update some other system with the result. Here's that override Dispose method:

    protected override void Dispose(bool disposing)
    {
        Dispatcher.Dispatch(new BrowserClosedAction());
        base.Dispose(disposing);

    }

The issue with calling Dispatch in this dispose method is that something with the rendering logic breaks because the component is disposed so an error is thrown (but the IState is still updated so my Effect method can still discern whether to update UI or a different service):

Unhandled exception in circuit '2y97VfGYbGWD9xJIhtsbLOfj9PsQChpDlqBrGQdVXTQ'.
System.ObjectDisposedException: Cannot process pending renders after the renderer has been disposed.
Object name: 'Renderer'.
   at Microsoft.AspNetCore.Components.RenderTree.Renderer.ProcessPendingRender()
   at Microsoft.AspNetCore.Components.Server.Circuits.RemoteRenderer.ProcessPendingRender()
   at Microsoft.AspNetCore.Components.RenderTree.Renderer.AddToRenderQueue(Int32 componentId, RenderFragment renderFragment)
   at Microsoft.AspNetCore.Components.ComponentBase.StateHasChanged()
   at Microsoft.AspNetCore.Components.Rendering.RendererSynchronizationContextDispatcher.InvokeAsync(Action workItem)
   at Microsoft.AspNetCore.Components.ComponentBase.InvokeAsync(Action workItem)
   at Fluxor.Blazor.Web.Components.FluxorComponent.<OnInitialized>b__8_0(IState _)
   at Fluxor.StateSubscriber.<>c__DisplayClass2_1.<Subscribe>b__1(Object s, EventArgs a)
   at Fluxor.Feature`1.TriggerStateChangedCallbacks(TState newState)   at Fluxor.Feature`1.set_State(TState value)
   at Fluxor.Store.DequeueActions()
   at Fluxor.Store.Dispatch(Object action)
   at MyComponent.Dispose(Boolean disposing) in C:\MyComponentPath\MyComponent.razor:line XXX
   at Fluxor.Blazor.Web.Components.FluxorComponent.Dispose()        
   at Microsoft.AspNetCore.Components.Rendering.ComponentState.Dispose()
   at Microsoft.AspNetCore.Components.RenderTree.Renderer.Dispose(Boolean disposing)

Technically even though this error is thrown, I can still do what I need to do, but I don't think I should go this route due to the error (which I hope is avoidable!) and am wondering if there is another way to accomplish this. I think there might be a way to create a Scoped CircuitHandler to dispatch an action, but I don't know how to get the IState/Dispatcher injected into that circuit handler. This Circuit handler fails:

public class BrowserClosedHandler : CircuitHandler
{
    BrowserClosedHandler(IState<S.AppState> appState, IDispatcher dispatcher)
    {
        AppState = appState;
        Dispatcher = dispatcher;
    } 
    private IState<S.AppState> AppState;
    private IDispatcher Dispatcher;
    public override Task OnConnectionDownAsync(Circuit circuit, 
        CancellationToken cancellationToken)
    {
        Dispatcher.Dispatch(new BrowserClosedAction());
        return Task.CompletedTask;
    }
}
...
// SERVICES SETUP
services.AddScoped<CircuitHandler, BrowserClosedHandler>();
services.AddFluxor(o => o
    .ScanAssemblies(typeof(Program).Assembly)
    .UseRouting()
);

This results in an error on startup:

The application failed to start correctly
System.AggregateException: Some services are not able to be constructed (Error while validating the service descriptor 'ServiceType: Microsoft.AspNetCore.Components.Server.Circuits.CircuitHandler Lifetime: Scoped ImplementationType: ECC.Startup+BrowserClosedHandler': A suitable constructor for type 'ECC.Startup+BrowserClosedHandler' could not be located. Ensure the type is concrete and services are registered for all parameters of a public constructor.)
 ---> System.InvalidOperationException: Error while validating the service descriptor 'ServiceType: Microsoft.AspNetCore.Components.Server.Circuits.CircuitHandler Lifetime: Scoped ImplementationType: ECC.Startup+BrowserClosedHandler': A suitable constructor for type 'ECC.Startup+BrowserClosedHandler' could not be located. Ensure the type 
is concrete and services are registered for all parameters of a public constructor.
 ---> System.InvalidOperationException: A suitable constructor for type 'ECC.Startup+BrowserClosedHandler' could not be located. Ensure 
the type is concrete and services are registered for all parameters 
of a public constructor.
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteFactory.CreateConstructorCallSite(ResultCache lifetime, Type serviceType, Type implementationType, CallSiteChain callSiteChain)        
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteFactory.TryCreateExact(ServiceDescriptor descriptor, Type serviceType, CallSiteChain callSiteChain, Int32 slot)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteFactory.GetCallSite(ServiceDescriptor serviceDescriptor, CallSiteChain callSiteChain)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.ServiceProviderEngine.ValidateService(ServiceDescriptor descriptor)        
   --- End of inner exception stack trace ---
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.ServiceProviderEngine.ValidateService(ServiceDescriptor descriptor)        
   at Microsoft.Extensions.DependencyInjection.ServiceProvider..ctor(IEnumerable`1 serviceDescriptors, ServiceProviderOptions options)  
   --- End of inner exception stack trace ---
   at Microsoft.Extensions.DependencyInjection.ServiceProvider..ctor(IEnumerable`1 serviceDescriptors, ServiceProviderOptions options)  
   at Microsoft.Extensions.DependencyInjection.ServiceCollectionContainerBuilderExtensions.BuildServiceProvider(IServiceCollection services, ServiceProviderOptions options)
   at Microsoft.Extensions.DependencyInjection.DefaultServiceProviderFactory.CreateServiceProvider(IServiceCollection containerBuilder) 
   at Microsoft.Extensions.Hosting.Internal.ServiceFactoryAdapter`1.CreateServiceProvider(Object containerBuilder)
   at Microsoft.Extensions.Hosting.HostBuilder.CreateServiceProvider()
   at Microsoft.Extensions.Hosting.HostBuilder.Build()
   at ECC.Program.Main(String[] args) in C:\SomePath\Program.cs:line XXX

Any help would be greatly appreciated!

1

There are 1 answers

3
Peter Morris On

When you use FluxorComponent as a base it will scan the component for all IState<T> properties and subscribe to them. Whenever the value of this state changes the FluxorComponent will call StateHasChanged to re-render the component.

When the component is Disposed, FluxorComponent will remove all subscriptions to avoid memory leaks and attempts to render a disposed component.

In your case this is what is happeneing

  1. Blazor deactivates the component so it can no longer render
  2. It calls Dispose
  3. Your overridden Dispose dispatches an action that gives a new state
  4. The subscription to IState<T> fires, and StateHasChanged is executed against a component that cannot render.

The fix is to call base.Dispose(disposing) first so that the FluxorComponent base class can unsubscribe from the state before you dispatch your action.