If we’re looking at an upgrade of old PWA, such as PWA1 or 2, to a modern version, you’re likely to encounter problems with service worker invalidation, main reason being that the service worker URL changed from sw.js to service-worker.js

The end result of such problems is users who had an existing service worker visiting the website after an upgrade, and encountering either partially or completely broken page, that would not work outside of manual cache clear, or incognito mode…which we can’t expect average visitor to perform.

Even if the service worker URL did not change, and it is expected to update correctly, you may still end up in a situation where the new service worker doesn’t become active immediately; or if it becomes active, but the initial render of the page shows broken website and requires a manual page refresh.

Reproducing the issue

The first step is to reproduce the issue, ideally to confirm if this is going to be a problem before launch.

There are multiple ways to do it:

If you get a completely broken website after visiting the new page with the old service worker, there may be a few things that need to be done.

Invalidating the old service worker

You can check information on what service worker is active in chrome://serviceworker-internals/?devtools, or in Console -> Application -> Service Workers

Note the Registration ID of the old service worker, before opening the new page.

First, let’s cover the case if the service worker ID doesn’t change at all; this means that your service worker is not being invalidated.

This is why changing the URL of the service worker is a bad idea. In order to change the service worker URL in browser, we now need to update service worker in order to update service worker.

<aside> ➡️ In practice, this means we need to serve new content on the old service worker URL(sw.js), instead of returning 404.

</aside>

That can be achieved by adding the following modification to ScandiPWA\\ServiceWorker\\Controller\\Router :

public function match(RequestInterface $request)
    {
        if ((trim($request->getPathInfo(), '/') == 'service-worker.js'
            || trim($request->getPathInfo(), '/') == 'sw.js')) {
            $request
                ->setModuleName('serviceworker')
                ->setControllerName('index')
                ->setActionName('index');

            return $this->actionFactory->create(Forward::class, ['request' => $request]);
        }
    }

This core PWA router is responsible for returning content when accessing it via a URL such as website.com/service-worker.js

That kind of override would mean that accessing a URL such as website.com/sw.js, would return the same content as the new service-worker.js, instead of 404.

And that will trigger the visitor’s browser to update the service worker code.

Initially the new service worker will be loaded as sw.js again, but it will contain the new code. In the future, when the page is re-opened with the new service worker and getting new contents for the root template, service-worker.js will be registered instead.

Activating new service worker immediately

Potential problems do not end with installing the new service worker.

If, when opening the new website and checking service workers in console, you get multiple lines, such as:

#24136 activated and is running stop
#24141 waiting to activate skipWaiting

It means that the new service worker was installed, but it will not activate, until the old service worker is stopped.

This is the native service worker behavior; the old service worker is still controlling the website namespace. Without any changes, the new service worker will activate only once all tabs on the namespace will be closed in the browser, or after a browser restart.

In order to immediately tell our new service worker to activate, we need to execute skipWaiting operation.

This can be done by modifying PWA’s src/service-worker.js , adding code such as this:

// temporary fix for switch from sw.js to service-worker.js
self.addEventListener('install', (event) => {
    if (event.target &&
        event.target.serviceWorker &&
        event.target.serviceWorker.scriptURL &&
        event.target.serviceWorker.scriptURL.includes("sw.js")
    ) {
        self.skipWaiting();
    }
});

Triggering a page reload

Unfortunately, activating the new service worker is not guaranteed to fix the broken page that we already loaded. Resolving this may be easy or difficult, depending on which PWA version we’re upgrading from.

If the old root template already had conditional triggers to refresh the page or at least display the new version popup, it may work immediately.

Otherwise, we would need to somehow trigger a page reload ourselves, which isn’t easy.

We cannot add code such as window.location.reload to the service-worker.js code; it doesn’t have write access to the window object and cannot initialize the refresh.

What we can do, is override one of the old JS URLs, or root template, to add this JS code.

The trick is to add the Cache-Control header with value no-store, no-cache, must-revalidate, max-age=0 to the response that has the reload code.

That will trigger the old cache worker to refresh that JS content, and a page reload would be executed.

Example of JS code that would trigger a page refresh, if executed with old service worker still active:

// reload page on first open with old service worker
if ('serviceWorker' in navigator) {
    window.addEventListener('load', () => {
        navigator.serviceWorker.getRegistrations().then((registrations) => {
            const oldReg = registrations.find(
                (sw) =>
                    sw &&
                    sw.active &&
                    sw.active.scriptURL &&
                    sw.active.scriptURL.includes("sw.js")
            );

            if (oldReg) {
                oldReg.unregister();
                window.location.reload();
            }
        });
    });
}

Here’s an example of how this was done on one of the projects, via override on old JS content:

As such, we could modify the response to currently-404’ing old JS paths, to have the page reload code instead.

Then can be done with a di.xml plugin, such as:

<type name="Magento\\Framework\\App\\StaticResource">
        <plugin name="override_old_js_content"
                type="ProjectName\\ModuleName\\Plugin\\OverrideOldJsContent" sortOrder="10"/>

And the PHP plugin containing code such as:

    /**
     * @param ResponseInterface $response
     * @param Http $request
     */
    public function __construct(
        \\Magento\\Framework\\App\\ResponseInterface $response,
        \\Magento\\Framework\\App\\Request\\Http $request
    ) {
        $this->response = $response;
        $this->request = $request;
    }

    /**
     * Fix for old service worker rendering a white page
     * It is rendered because old JS bundles 404
     *
     * @param StaticResource $subject
     * @return ResponseInterface|void|null
     */
    public function beforeLaunch(\\Magento\\Framework\\App\\StaticResource $subject)
    {
        // serves window.location.reload JS for old JS bundle requests, if old SW is present
        if (preg_match('/Magento_Theme\\/[0-9a-zA-Z]{0,6}\\.bundle\\.js/uim', $this->request->getPathInfo())) {
            $content = ... (JS code here)
            $this->response->setHeader('Content-Type', 'text/javascript');
            $this->response->setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0');
            $this->response->setBody($content);
            $this->response->setStatusHeader(200);
            $this->response->sendResponse();
            exit;
        }

        return null;
    }