Forum Discussion

Gavin-Williams's avatar
Gavin-Williams
Brass Contributor
Sep 07, 2023

Blazor WASM PWA – Applications updates, cache busting with notification or force refresh

My team is currently working on a Blazor application and after several bouts of confusion between developers and testers we stuck a version number on the home screen and discovered the updates weren’t going out to everyone as we were expecting. We ended up with several people on different versions of the application and fortunately we spotted this issue early in development. I’ve put together the options and info I’ve found while trying to find a solution to this that works for our particular situation in hopes that it will help someone else dealing with this and hopefully open a discussion on how people are handling this issue and find any alternative solutions.

 

To look at what was happening behind the scenes, I set up a simple WASM PWA on .NET7 and have it hosted on an Azure App Service. I stuck a version number on the home page so I can easily see when the update has taken place.

 

 

The behaviour:

Blazor WASM PWA applications load the application into the browser based on the cache pulled in by the service workers (Javascript assets that act as a proxy between browsers and web servers for offline support and performance). In dev tools you can see what’s in the cache storage for the app (go to dev tools > application tab > cache storage). The picture below shows that the cache contains “blazor-resources” and “offline-cache”. The “offline-cache” is the important one that determines what the site is displaying and on pushing an update the “blazor-resources” is unaffected so for this purpose can be ignored.

 

Now the standard behaviour on pushing an update to the app is that the service worker will install the new app cache in the background and enter the “waiting” state and will not activate the newly cached app until the current application service worker is released, by either closing the browser or hard refreshing the app (Ctrl + F5). This behaviour is by design to prevent having two tabs open with different versions of the application running at once. However users may never close the application or browser or whatever and that would mean they could be running long retired versions of your application for an indeterminant length of time. Below you can see the cache storage after an update has been pushed stacking up the old and new version of the offline-cache.

 

 

 Once the service worker has been released then the old cache is removed the latest will be activated. This will then display the new version of the app.

 

The solution:

Now, what if we want to change this behaviour to either prompt and update or force one?

Either way we will want to remove the need to close browsers or hard refresh as that can be a bit of a tricky one to get end users to get on board with. The service workers actually have a method that can skip the “waiting” stage of the service worker lifecycle clear the old cache and activate the latest. This is the SkipWaiting method.

 

In the Blazor solution there is the service-worker.published.js file found in the wwwroot folder. This file contains the methods for the service worker. In this file there is a OnInstall method and can be modified to the following adding in the SkipWaiting method:

 

async function onInstall(event) {
    console.info('Service worker: Install');

    self.skipWaiting();

    // Fetch and cache all matching items from the assets manifest

    const assetsRequests = self.assetsManifest.assets
        .filter(asset => offlineAssetsInclude.some(pattern => pattern.test(asset.url)))
        .filter(asset => !offlineAssetsExclude.some(pattern => pattern.test(asset.url)))
        .map(asset => new Request(asset.url, { integrity: asset.hash, cache: 'no-cache' }));
    await caches.open(cacheName).then(cache => cache.addAll(assetsRequests));
}

 

This additional line will now allow a standard refresh to update the application rather than requiring a hard refresh or closing the browser completely. When looking at the cache storage when an update is pushed you will see the second cache appear then the old cache disappear immediately.

Note: Adding this behaviour will overwrite the intended behaviour and allow the potential of different version of the application running in different tabs. However we can look at avoiding that by forcing update later.

 

Next, regardless of whether we want to prompt the user for an update or force it on them, we need a way of letting the site know it needs an update. My javascript is less than brilliant so I’ve used a solution by Wouter Huysentruit, full description here: https://whuysentruit.medium.com/blazor-wasm-pwa-adding-a-new-update-available-notification-d9f65c4ad13

 

This involves adding a js file called sw-registrator.js to the wwwroot folder that will allow an event to be triggered that can link to updating the site:

 

window.updateAvailable = new Promise((resolve, reject) => {
    if (!('serviceWorker' in navigator)) {
        const errorMessage = `This browser doesn't support service workers`;
        console.error(errorMessage);
        reject(errorMessage);
        return;
    }

    navigator.serviceWorker.register('/service-worker.js')
        .then(registration => {
            console.info(`Service worker registration successful (scope: ${registration.scope})`);
            registration.onupdatefound = () => {
                const installingServiceWorker = registration.installing;
                installingServiceWorker.onstatechange = () => {
                    if (installingServiceWorker.state === 'installed') {
                        resolve(!!navigator.serviceWorker.controller);
                    }
                }
            };
        })
        .catch(error => {
            console.error('Service worker registration failed with error:', error);
            reject(error);
        });
});

window.registerForUpdateAvailableNotification = (caller, methodName) => {

    window.updateAvailable.then(isUpdateAvailable => {

        if (isUpdateAvailable) {
            caller.invokeMethodAsync(methodName).then();
        }
    });
};

 

Within index.html in the wwwroot folder we will need to register this new file instead of the service-worker.js. So we need to replace the reference to service-worker.js with the following:

 

<script src="sw-registrator.js"></script>

 

This will now raise an event once the cache refresh happens that can be used to trigger a prompt on the application or a refresh of the application.

 

Now we need a razor component that will listen for the event and either throw up a refresh prompt or just force a refresh. Firstly I’ll do the prompt. For this make a new razor component and add the following code:

 

@inject IJSRuntime _jsRuntime
@inject NavigationManager uriHelper

@if (_newVersionAvailable)
{
    <button type="button" class="btn btn-danger shadow floating-update-button" onclick="window.location.reload()">
        A new version of the application is available. Click here to upgrade.
    </button>
}

@code {
    private bool _newVersionAvailable = false;

    protected override async Task OnInitializedAsync()
    {
        await RegisterForUpdateAvailableNotification();
    }

    private async Task RegisterForUpdateAvailableNotification()
    {
        await _jsRuntime.InvokeAsync<object>(
            identifier: "registerForUpdateAvailableNotification",
            DotNetObjectReference.Create(this),
            nameof(OnUpdateAvailable));
    }

    [JSInvokable(nameof(OnUpdateAvailable))]
    public Task OnUpdateAvailable()
    {
        _newVersionAvailable = true;

        StateHasChanged();

        return Task.CompletedTask;
    }
}

 

I personally like a red bar across the top as it’s very attention grabbing so you can add the following css file called WhateverYouCalledTheComponent.razor.css:

 

.floating-update-button {
    position: relative;
    top: 0;
    padding: 1rem 1.5rem;
    width: 100%;
}

 

This component can then be placed at the top of MainLayout.razor above the body to ensure it appears anywhere on the app the user happens to be.

 

This will then produce the following result once an update is pushed out:

 

 

Once the banner is clicked, it will reload the site and the new update will be running. This is the setup that I will be implementing on my teams project as I wouldn’t want the site to force updates in case a user was in the middle of data entry. However if a forced refresh is desired, it’s a quick change to the razor component to allow that.

 

Change the components code to the following:

 

@inject IJSRuntime _jsRuntime
@inject NavigationManager uriHelper

@code {
    protected override async Task OnInitializedAsync()
    {
        await RegisterForUpdateAvailableNotification();
    }

    private async Task RegisterForUpdateAvailableNotification()
    {
        await _jsRuntime.InvokeAsync<object>(
            identifier: "registerForUpdateAvailableNotification",
            DotNetObjectReference.Create(this),
            nameof(OnUpdateAvailable));
    }

    [JSInvokable(nameof(OnUpdateAvailable))]
    public Task OnUpdateAvailable()
    {
        uriHelper.NavigateTo(uriHelper.Uri, forceLoad: true);

        StateHasChanged();

        return Task.CompletedTask;
    }
}

 

Now on pushing an update you will see that the update is installed in the cache and once the event is triggered the old cache is removed and the site reloads wherever it happens to be.

 

I’d be really interested to hear other peoples takes on this or if they’ve found other methods of achieving this, I’ve scoured the web and this was the combination of the best methods I came across but certainly open to improvement.

  • Mikhail_M's avatar
    Mikhail_M
    Copper Contributor
    Hi Gavin!
    Thanks for compiling everything in one article, it's pretty helpful.

    As for my personal expirience. It works fine locally, but when deployed I see the same error as kaniosm. Full clean-up helps, but just temporarily.

    Strange that approaches to the problem of having a latest app on client are not discussed more, it is a a pretty important aspect in my view.
  • kaniosm's avatar
    kaniosm
    Copper Contributor

    Gavin-Williams 

     

    Unfortunately this won't work with .NET 7 since Edge and Chrome will block the new resources.

    Hopefully this will be fixed in .NET 8, where .NET resource extensions will be replaced from .dll to .wasm.

    Error:

    Failed to find a valid digest in the 'integrity' attribute for resource 'https://localhost:7072/_framework/BlazorApp2.Client.dll' with computed SHA-256 integrity 'uM5xqnXO604cKGyjVgtnSDU5aaVKXTk+467/LipkPAU='. The resource has been blocked.

    • Gavin-Williams's avatar
      Gavin-Williams
      Brass Contributor
      Strange, I did all of this on .NET7 but didn't encounter that error. Just out of interest, could you try a clean and rebuild on that? Wondering if there's some reference to previous dlls that may be able to be cleared.
      • kaniosm's avatar
        kaniosm
        Copper Contributor
        Yes, Clean and rebuild resolved the issue the first time, but the error appears randomly with new releases. From what I understand, this is a known issue with .dll files, that's why the team decided to rename the binaries to .wasm instead.
        Today I was testing with .NET 8 and it seems indeed the problem is solved in .NET 8.

Resources