Search code examples
javascriptcachingservice-workerworkbox

Service worker - update cache on new version using skipWaiting()


I have implemented Workbox to generate my service worker using webpack. This works - I can confirm revision is updated in the generated service worker when running "generate-sw": "workbox inject:manifest".

The problem is - I have noticed my clients are not updating the cache after a new release. Even days after updating the service worker my clients are still caching the old code and new code will only load after several refreshes and/or unregistering the service worker. For each release I have confirmed that the revision is updated.

I understand that I need to implement skipWaiting to ensure the clients gets updated - especially PWA. I have read, and tried to follow the 3rd approach here: https://redfin.engineering/how-to-fix-the-refresh-button-when-using-service-workers-a8e27af6df68.

My app mounts in app.js

I have added this code to serviceWorker-base.js

addEventListener('message', function(messageEvent){
  if (messageEvent.data === 'skipWaiting') return skipWaiting();
});

I have this code in app.js

const runServiceWorker = true
const serviceWorkerAvailable = ('serviceWorker' in navigator) ? true : false

// reload once when the new Service Worker starts activating
let refreshing
navigator.serviceWorker.addEventListener('controllerchange', function() {
    if (refreshing) return
    refreshing = true
    window.location.reload()
  }
)

function promptUserToRefresh(reg) {
  // this is just an example - don't use window.confirm in real life; it's terrible
  if (window.confirm("New version available! OK to refresh?")) {
    reg.waiting.postMessage('skipWaiting')
  }
}

function listenForWaitingServiceWorker(reg, callback) {
  console.log('listenForWaitingServiceWorker')
  function awaitStateChange() {
    reg.installing.addEventListener('statechange', function() {
      if (this.state === 'installed') callback(reg)
    })
  }
  if (!reg) return
  if (reg.waiting) return callback(reg)
  if (reg.installing) awaitStateChange()
  reg.addEventListener('updatefound', awaitStateChange)
}

// Register service worker
if (runServiceWorker && serviceWorkerAvailable) {
  navigator.serviceWorker.register('/serviceWorker.js')
  .then( (registration) => {
    console.log('Service worker registered', registration)
    listenForWaitingServiceWorker(registration, promptUserToRefresh) // <-- Added to existing code
  })
}else{
  console.log('Service worker disabled - process.env.NODE_ENV', process.env.NODE_ENV)
}

The problem with this code is that promptUserToRefresh() only gets called on initial service worker install, not when a new service worker is waiting!

Also, I get the below error when accepting the first install.

TypeError: registration.waiting is null
promptUserToRefresh app.js:154
awaitStateChange app.js:162

The error gets triggered in promptUserToRefresh(registration) by registration.waiting.postMessage('skipWaiting')

I have also tested this approach with the same result: https://github.com/GoogleChrome/workbox/issues/1120


Solution

  • The code now works after simply re-arranging it!

    Updated app.js

    // *** PWA Functionality START ***
    
    // skipWaiting() functions
    function promptUserToRefresh(registration) {
      // this is just an example - don't use window.confirm in real life; it's terrible
      if (window.confirm("New version available! Refresh?")) {
        registration.waiting.postMessage('skipWaiting')
      }
    }
    function listenForWaitingServiceWorker(registration) {
      console.log('listenForWaitingServiceWorker', registration)
      function awaitStateChange() {
        registration.installing.addEventListener('statechange', function() {
          if (this.state === 'installed') promptUserToRefresh(registration)
        })
      }
      if (!registration) return
      if (registration.waiting) return promptUserToRefresh(registration)
      if (registration.installing) awaitStateChange()
      registration.addEventListener('updatefound', awaitStateChange)
    }
    //**
    
    const enableServiceWorker = true
    const serviceWorkerAvailable = ('serviceWorker' in navigator) ? true : false
    // Register service worker
    if (enableServiceWorker && serviceWorkerAvailable) {
      navigator.serviceWorker.register('/serviceWorker.js')
      .then( (registration) => {
        console.log('Service worker registered', registration)
        listenForWaitingServiceWorker(registration) // ** skipWaiting() code
      })
    }else{
      console.log('Service worker disabled - process.env.NODE_ENV', process.env.NODE_ENV)
    }
    
    // Install prompt event handler
    export let deferredPrompt
    window.addEventListener('beforeinstallprompt', (event) => {
      // Prevent Chrome 76 and later from showing the mini-infobar
      event.preventDefault()
      deferredPrompt = event // Stash the event so it can be triggered later.
      try{
        showInstallPromotion()
      }catch(e){
        console.error('showInstallPromotion()', e)
      }
    })
    window.addEventListener('appinstalled', (event) => {
      console.log('a2hs installed')
    })
    // *** PWA Functionality END *
    

    Maybe the below (removed) lines caused all the trouble?

    // reload once when the new Service Worker starts activating
    let refreshing
    navigator.serviceWorker.addEventListener('controllerchange', function() {
        if (refreshing) return
        refreshing = true
        window.location.reload()
      }
    )
    

    All that remains now is figuring out how not to show the prompt on first visit to the app / install! (^__^)/