I have a Blazor component that has to display data from a long running operation. For this reason I display a spinner, but since it takes long time I want to be able to cancel this loading when for example the user navigates away (e.g. the user clicks login while data is loading).
I implemented the Dispose pattern with the CancellationTokenSource object in my component, I made my function async with a Token as parameter but it seems that the token's "IsCanceled" is never set to true inside my load data function, neither it throws OperationCanceledException. If I make a test with a dummy function that simply awaits with Task.Delay for 20 sec and I pass the token it is correctly canceled. What am I doing wrong?
The end result is that while data is loading and the spinner is showing, if the user clicks on a button in order to navigate away it waits for the data loading completion.
The view where I display my data; "LoadingBox" shows a spinner while the list is not created.
<Card>
<CardHeader><h3>Ultime offerte</h3></CardHeader>
<CardBody>
<div class="overflow-auto" style="max-height: 550px;">
<div class="@(offersAreLoading ?"text-danger":"text-info")">LOADING: @offersAreLoading</div>
<LoadingBox IsLoading="lastOffers == null">
@if (lastOffers != null)
{
@if (lastOffers.Count == 0)
{
<em>Non sono presenti offerte.</em>
}
<div class="list-group list-group-flush">
@foreach (var off in lastOffers)
{
<div class="list-group-item list-group-item-action flex-column align-items-start">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">
<a href="@(NavigationManager.BaseUri)offerta/@off.Oarti">
@off.CodiceOfferta4Humans-@off.Versione
</a>
</h5>
<small>@((int) ((DateTime.Now - off.Created).TotalDays)) giorni fa</small>
</div>
<p class="mb-1"><em>@(off.OggettoOfferta.Length > 50 ? off.OggettoOfferta.Substring(0, 50) + "..." : off.OggettoOfferta)</em></p>
<small>@off?.Redattore?.Username - @off.Created</small>
</div>
}
</div>
}
</LoadingBox>
</div>
</CardBody>
</Card>
The component code-behind. Here I call the long-running function (GetRecentAsync) that I want to cancel when the user navigates away or if the user does some other operation:
public partial class Test : IDisposable
{
private CancellationTokenSource cts = new();
private IList<CommercialOffer> lastOffers;
private bool offersAreLoading;
[Inject] public CommercialOfferService CommercialOfferService { get; set; }
async Task LoadLastOffers()
{
offersAreLoading = true;
await InvokeAsync(StateHasChanged);
var lo = await CommercialOfferService.GetRecentAsync(cancellationToken: cts.Token);
lastOffers = lo;
offersAreLoading = false;
await InvokeAsync(StateHasChanged);
}
async Task fakeLoad()
{
offersAreLoading = true;
await InvokeAsync(StateHasChanged);
await Task.Delay(TimeSpan.FromSeconds(20), cts.Token);
offersAreLoading = false;
await InvokeAsync(StateHasChanged);
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await LoadLastOffers();
}
await base.OnAfterRenderAsync(firstRender);
}
public void Dispose()
{
cts.Cancel();
cts.Dispose();
}
}
public async Task<List<CommercialOffer>> GetRecentAsync(CancellationToken cancellationToken)
{
try
{
cancellationToken.ThrowIfCancellationRequested();
var result = await _cache.GetOrCreateAsync<List<CommercialOffer>>("recentOffers", async entry =>
{
entry.AbsoluteExpiration = DateTimeOffset.Now.Add(new TimeSpan(0, 0, 0, 30));
var list = await _unitOfWork.CommercialOfferRepository.GetAllWithOptionsAsync();
foreach (var commercialOffer in list)
{
// sta operazione è pesante, per questo ho dovuto cachare
// BOTH ISCANCELLATIONREQUESTED AND THROWIFCANCELLATINREQUESTED DOES NOT WORK, ISCANCELLATIONREQUESTED IS ALWAYS FALSE.
cancellationToken.ThrowIfCancellationRequested();
if (cancellationToken.IsCancellationRequested) return new List<CommercialOffer>();
await _populateOfferUsersAsync(commercialOffer);
}
return list.Take(15).OrderByDescending(o => o.Oarti).ToList();
});
return result;
}
catch (OperationCanceledException)
{
// HERE I SET A BREAKPOINT IN ORDER TO SEE IF IT RUNS, BUT IT DOESN'T WORK
}
}
Thanks!
EDIT 20/07/2021
Thanks @Henk Holterman. GetRecentAsync gets all recent commercial offers, compiled by a simple form and having some data as a usual use case. Each of these commercial offers refer to 4 users (who manages the offer, the superior, the approver, etc.) and I populate with a foreach loop each of these users, for each commercial offer I want to display.
I know I should create from the start the entire entity (commercial offer) from the SQL query, but I need this for a matter of order and separation of concerns.
So, _populateOfferUsersAsync(commercialOffer) queries for 4 users of an offer, creates these 4 entities and assign them to the offer:
private async Task _populateOfferUsersAsync(CommercialOffer commercialOffer)
{
commercialOffer.Responsabile = await _unitOfWork.UserRepository.GetByIdAsync(commercialOffer.IdResponsabile);
commercialOffer.Redattore = await _unitOfWork.UserRepository.GetByIdAsync(commercialOffer.IdRedattore);
commercialOffer.Approvatore = await _unitOfWork.UserRepository.GetByIdAsync(commercialOffer.IdApprovatore);
commercialOffer.Revisore = await _unitOfWork.UserRepository.GetByIdAsync(commercialOffer.IdRevisore);
}
Under the hood I use Dapper for DB queries:
public async Task<User> GetByIdAsync(long id)
{
var queryBuilder = _dbTransaction.Connection.QueryBuilder($@"SELECT * FROM GEUTENTI /**where**/");
queryBuilder.Where($"CUSER = {id}");
queryBuilder.Where($"BSTOR = 'A'");
queryBuilder.Where($"BDELE = 'S'");
var users = await queryBuilder.QueryAsync<User>(_dbTransaction);
return users.FirstOrDefault();
}
From what I've seen there's no simple and effective way to pass a CancellationToken to stop Dapper queries, it might be me or Dapper being poor for that stuff