dotnet / aspnetcore

ASP.NET Core is a cross-platform .NET framework for building modern cloud-based web applications on Windows, Mac, or Linux.
https://asp.net
MIT License
35.36k stars 9.99k forks source link

NavigationManager.NavigateTo is calling Dispose, and unsubscribing from AppState event #19984

Closed yopez83 closed 4 years ago

yopez83 commented 4 years ago

AppState Pattern

I have component A and at OnInitializedAsync I subscribe method Foo to an event defined in AppState. I also implemented Dispose from IDisposable where I unsubscribe from the event. So far, it's what most of the docs out there recommend.

The bug

From A I navigate to component B using NavigationManager.NagivateTo("/componentBRoute"). Before navigating I've checked Foo remains subscribed. Upon navigation I've noticed AppState event was null. Then, I added a breakpoint to Dispose method from A, and indeed it's being called upon navigation. Causing Foo to unsubscribe from AppState. Maybe this isn't a bug and it's working as intended, but then how do I unsubscribe? Most importantly, how to avoid being unsubscribed upon navigation?

javiercn commented 4 years ago

@yopez83 thanks for contacting us.

I'm not really sure what behavior you think is wrong here. From what you describe everything is working as expected.

Can you clarify what you think the problem is or provide a minimal repro project that helps us understand the issue you are facing?

yopez83 commented 4 years ago

@javiercn thank you for your prompt response. Using the Blazor WebAssembly project template see below.

Program.cs

namespace BlazorApp2
{
    public class Program
    {
        public static async Task Main(string[] args)
        {
            var builder = WebAssemblyHostBuilder.CreateDefault(args);
            builder.Services.AddScoped<WeatherForecastStateProvider>();
            builder.RootComponents.Add<App>("app");

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

WeatherForecastStateProvider

namespace BlazorApp2
{
    public class WeatherForecastStateProvider
    {
        public WeatherForecast WeatherForecast { get; set; } = new WeatherForecast();

        public event Action OnChange;

        public void UpdateWeatherForecast() => OnChange?.Invoke();
    }
}

I moved the WeatherForecast model from FetchData.razor to a Models folder I created. I also added an identifier to be able to select by Id in order to update the item.

WeatherForecast model

namespace BlazorApp2.Models
{
    public class WeatherForecast
    {
        public Guid Id { get; }

        public DateTime Date { get; set; }

        public int TemperatureC { get; set; }

        public string Summary { get; set; }

        public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);

        public WeatherForecast()
        {
            Id = Guid.NewGuid();
        }
    }
}

FetchData.razor component

@page "/fetchdata"
@using BlazorApp2.Models
@inject HttpClient Http
@inject WeatherForecastStateProvider WeatherForecastStateProvider
@inject NavigationManager NavigationManager
@implements IDisposable

<h1>Weather forecast</h1>

<p>This component demonstrates fetching data from the server.</p>

@if (forecasts == null)
{
    <p><em>Loading...</em></p>
}
else
{
    <table class="table">
        <thead>
            <tr>
                <th>Date</th>
                <th>Temp. (C)</th>
                <th>Temp. (F)</th>
                <th>Summary</th>
                <th></th>
            </tr>
        </thead>
        <tbody>
            @foreach (var forecast in forecasts)
            {
                <tr @key="forecast.Id">
                    <td>@forecast.Date.ToShortDateString()</td>
                    <td>@forecast.TemperatureC</td>
                    <td>@forecast.TemperatureF</td>
                    <td>@forecast.Summary</td>
                    <td>
                        <button class="btn btn-primary" @onclick="() => EditWeatherForecast(forecast)">
                            <i class="oi oi-pencil"></i> Edit
                        </button>
                    </td>
                </tr>
            }
        </tbody>
    </table>
}

@code {
    private WeatherForecast[] forecasts;

    protected override async Task OnInitializedAsync()
    {
        forecasts = await Http.GetJsonAsync<WeatherForecast[]>("sample-data/weather.json");
        WeatherForecastStateProvider.OnChange += UpdateWeatherForecast;
    }

    public void Dispose() => WeatherForecastStateProvider.OnChange -= UpdateWeatherForecast;

    public void EditWeatherForecast(WeatherForecast weatherForecast)
    {
        WeatherForecastStateProvider.WeatherForecast = weatherForecast;
        NavigationManager.NavigateTo("/forecast/edit");
    }

    public void UpdateWeatherForecast()
    {
        var forecast = forecasts.Single(f => f.Id == WeatherForecastStateProvider.WeatherForecast.Id);
        forecast = WeatherForecastStateProvider.WeatherForecast;
        StateHasChanged();
    }
}

EditWeatherForecast.razor component

@page "/forecast/edit"
@using BlazorApp2.Models
@inject NavigationManager NavigationManager
@inject WeatherForecastStateProvider WeatherForecastStateProvider

<EditForm Model="WeatherForecast" OnSubmit="Submit">
    <div class="form-group">
        <label for="wf_date">Date:</label>
        <InputDate id="wf_date" class="form-control" @bind-Value="WeatherForecast.Date" />
    </div>

    <div class="form-group">
        <label for="wf_celsius">Temperature Celsius:</label>
        <InputNumber id="wf_celsius" class="form-control" @bind-Value="WeatherForecast.TemperatureC" />
    </div>

    <div class="form-group">
        <label for="wf_summary">Summary:</label>
        <InputTextArea id="city" class="form-control" @bind-Value="WeatherForecast.Summary" />
    </div>

    <button type="submit" class="btn btn-primary">Save</button>
</EditForm>

@code {
    public WeatherForecast WeatherForecast { get; set; }

    protected override async Task OnInitializedAsync() => WeatherForecast = await Task.FromResult(WeatherForecastStateProvider.WeatherForecast);

    public void Submit()
    {
        WeatherForecastStateProvider.UpdateWeatherForecast();
        NavigationManager.NavigateTo("/fetchdata");
    }
}

See, the whole point is:

SteveSandersonMS commented 4 years ago

Sorry, I don't really understand.

In the code you've posted, the only place you're subscribing to the event is inside FetchData.razor, but the user is not even on that page when you call WeatherForecastStateProvider.UpdateWeatherForecast(). So how can there be anything listening to this event?

Maybe this isn't a bug and it's working as intended, but then how do I unsubscribe? Most importantly, how to avoid being unsubscribed upon navigation?

Why would you want a component to remain subscribed after it has been removed from the UI?

It sounds like you just want the UI to be up-to-date when the user navigates back there later. Doesn't that already happen by default in your implementation? After the user clicks "save" you call NavigationManager.NavigateTo("/fetchdata"), which should:

  1. Create a new FetchData component instance
  2. ... which queries the server for the latest forecasts and renders them

So if you actually updated the state on the server when the user clicks "save", you would see the updated state in step 2.

If your goal is just to edit the state in memory and not get an update from the server, then FetchData should render data provided by WeatherForecastStateProvider instead of data provided by the backend server.

yopez83 commented 4 years ago

You're right @SteveSandersonMS. Last paragraph outlined my intent. I may have confused things a bit.

yopez83 commented 4 years ago

@SteveSandersonMS one last question. Say I from the EditWeatherForecast.razor update the weather.json file. Then I nagivate back to FetchData.razor. Now, would the diffing re-render the whole table or just the forecast item it was updated?

SteveSandersonMS commented 4 years ago

It would be inserting an entirely new table as the previous one was removed when you navigated away.

yopez83 commented 4 years ago

fair enough. Thanks