Skip to content

Instantly share code, notes, and snippets.

@kawazoe
Last active February 1, 2024 06:50
Show Gist options
  • Save kawazoe/fa3b5a3c998d16871ffb9e2fd721cb4b to your computer and use it in GitHub Desktop.
Save kawazoe/fa3b5a3c998d16871ffb9e2fd721cb4b to your computer and use it in GitHub Desktop.
Auto refresh page on manifest.json change. iOS offline PWA hack to handle auto updates.
// ------------------------------------------- IMPORTANT -------------------------------------------
// This is a development file to be minified using https://javascript-minifier.com/ and inlined in
// the index.html file. This file is not compiled or processed by webpack so it should be treated as
// low-level precompiled es5-compatible javascript. The code here is not meant to be clean, it's
// meant to be as light and fast as possible since it runs in the head tag.
// HACK: This file a hack to ensure that home-screen apps on mobile devices gets refreshed when they
// start. It works by forcing a load of the service-worker.js file and use the precache-manifest
// file name as an application version, just like a desktop browser like chrome would do. When
// when it detects a change in the application version, it reloads the page and bypass the browser's
// cache. This should force mobile devices to reload the new version of the app even if they cached
// an older version of the site.
(function () {
var r = new XMLHttpRequest();
r.onload = function () {
var t = r.responseText;
var versionStart = t.indexOf('"/precache-manifest.') + 20;
var versionEnd = t.indexOf('.js"', versionStart);
if (versionEnd - versionStart === 32) {
var ls = localStorage;
var oldPrecacheManifestVersion = ls.getItem('pmv');
var newPrecacheManifestVersion = t.substring(versionStart, versionEnd);
if (newPrecacheManifestVersion !== oldPrecacheManifestVersion) {
ls.setItem('pmv', newPrecacheManifestVersion);
return window.location.reload(true);
}
}
};
r.open('GET', '/service-worker.js?c=' + new Date().getTime());
r.setRequestHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
r.send();
}());
<script type="application/javascript">!function(){var e=new XMLHttpRequest;e.onload=function(){var n=e.responseText,t=n.indexOf('"/precache-manifest.')+20,o=n.indexOf('.js"',t);if(o-t==32){var r=localStorage,i=r.getItem("pmv"),s=n.substring(t,o);if(s!==i)return r.setItem("pmv",s),window.location.reload(!0)}},e.open("GET","/service-worker.js?c=" + new Date().getTime()),e.setRequestHeader('Cache-Control', 'no-cache, no-store, must-revalidate'),e.send()}();</script>
@kawazoe
Copy link
Author

kawazoe commented Jun 26, 2019

@marlonmleite The solution I provided will only work if you use the auto-generated Service Worker from Google Workbox. If you are using a different SW configuration, you will have to use a different solution.

Your solution looks great! You could probably simplify it by running the entire code in componentDidMount() directly. An other improvement you can do is use a plain text version file instead of json which will speed up the code even more.

@coolhand79
Copy link

Thanks so much for adding this solution. I've been struggling with the issue for awhile now. Any idea if safari will fix it any time soon?

@sdennett55
Copy link

Sorry for my ignorance here, but how would this or @marlonmleite's solution fix the issue? AFAIK, iOS is saving your state in the app and never terminating that session, so you can deploy these changes, but the end user who already has your app installed won't get them unless they uninstall the app, clear safari website data, and reinstall. Do these changes just intend to future proof?

@marlonmleite I tried creating a button with window.location.reload(true) to force refresh, then made a change to a JS component, but the button wouldn't update the JS file. Same result when I implemented your proposed solution 100%, which at the end of the day is also just doing window.location.reload(true).

@kawazoe
Copy link
Author

kawazoe commented Jul 30, 2019

@sdnnett55 Good question!

The difference between a button and my solution is that mine is run automatically on startup, includes a version check, and should be transparent to the user. You are right in saying that it's basically just a call to window.location.reload(true). This works because Safari always restart your PWA from scratch when you start it from the home screen. Reloading the page is not what loads the new version of the site, it only causes Safari's stored cache to update. The reload still loads the site from a separate memory cache. Loading the new version happens next time you start the app, when Safari loads it from the stored cache again.

In other words:

  • Safari has two layer of caching in place: storage (SSD) and memory (RAM).
  • window.location.reload(false) causes the site to reload from the memory cache.
  • window.location.reload(true) causes the site to reload from the memory cache (this is the bug that we're trying to bypass) but still updates the stored cache.
  • Tapping on the app icon on the home screen always starts the app from the stored cache.
  • After a call to window.location.reload(true), when the user starts the app, it will always be up to date.

I hope this answers your question!

@coolhand79
Copy link

I'm using @kawazoe 's solution, but running it on window.focus.

Also, since iOS is the only offender here, I'm using @marlonmleite 's userAgent sniffing.

Does the trick! Thanks folks!

@matthewdfleming
Copy link

matthewdfleming commented Aug 8, 2019

I started to test these solutions because I was running into the same problem. I'm not all the way through my testing but after updating my index.html to include these kinds of scripts, I noticed that the page itself was being cached (and of course meant that my updates to it weren't really being loaded). So I checked all of the usual suspects of headers, etc and all of that looked totally fine. It was then that I noticed that workbox was doing the caching itself.

In figuring out why, the answer was essentially that I guess I sort of told it to. Mind you this was only a problem in iOS but I was noticing some inconsistent behavior in MacOS Safari as well (like sometimes it would get stuck). Never a problem on chrome.

I use webpack to build my SPA, specifically the WorkboxPlugin (v4.3.1).

In my configuration I had this..

new WorkboxPlugin.GenerateSW({
            clientsClaim: true,
            skipWaiting: true,
            navigateFallback: '/index.html',

It was the navigateFallback, that I thought would help me in offline mode. Maybe it does, but obviously I was not fully clear on the ramifications. Once I removed that fallback, everything started behaving properly again.

Just putting that out there in case someone else needs to try something to get past this frustrating issue.

Edit: just to clarify. I did NOT need any of the tricks above. The caching of the index page seemed to cause a bad cycle of caching the wrong resources (in iOS and sometimes Safari, never in chrome). Once it was not cached, things work well.

@matthewdfleming
Copy link

Ok still at it... definitely not consistent (getting old versions of my index page even with reload(true). I'm trying this script out to see how it does (in addition to my above post). It seems to be working with one extra reload to set the initial version. I also changed the 'find the version string' which wasn't quite right for my version of webpack as the url didn't start with a slash /.

window.addEventListener('focus', () => {
    const isApple = !!window.navigator.vendor && window.navigator.vendor.match(/apple/i);
    if (isApple) {
        const r = new XMLHttpRequest();
        r.onload = function () {
            const t = r.responseText;
            const versionStart = t.indexOf('"precache-manifest.') + 19;
            const versionEnd = t.indexOf('.js"', versionStart);
            if (versionEnd - versionStart === 32) {
                const ls = localStorage;
                const oldPrecacheManifestVersion = ls.getItem('pmv');
                const newPrecacheManifestVersion = t.substring(versionStart, versionEnd);
                if (newPrecacheManifestVersion !== oldPrecacheManifestVersion) {
                    ls.setItem('pmv', newPrecacheManifestVersion);
                    return window.location.reload(true);
                }
            }
        };
        r.open('GET', '/service-worker.js?c=' + new Date().getTime());
        r.setRequestHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
        r.send();
    }
});

@matthewdfleming
Copy link

matthewdfleming commented Aug 14, 2019

So I also figured out that the latest version of Safari seems to also cache the service-worker.js file if you configure the service worker to cache it (even on accident). This seems to be the source of really all the problems I've been having (although I left the focus listener in just in case).

I had this within my web pack configuration:

new WorkboxPlugin.GenerateSW({
    clientsClaim: true,
    skipWaiting: true,
    cleanupOutdatedCaches: true,
    offlineGoogleAnalytics: true,
    runtimeCaching: [
        {
            urlPattern: /.*/,
            handler: 'networkFirst'
        }
    ]
})

What I noticed is that a force refresh in the browser (on MacOS) would load the new service-worker.js but the subsequent refresh (without a force) would actually bring up the older one. So I noticed the browser would swap between the versions seemingly randomly. I changed my runtime caching to be much more specific and things are stable now.

Edit:

Also now got rid of the forced update via the focus listener as the normal updating (via timed update() checks works just fine/consistently when the service-worker.js file isn't cached by itself. Also the problem with this forced reload technique is that the reload itself can happen before the service worker has truly updated (causing multiple refreshes to get the latest app).

In my index.html I have this (which calls the service worker 'update' every 2 minutes after an initial 30s check):

window.isUpdateAvailable = new Promise(function (resolve) {
    // lazy way of disabling service workers while developing, since no https
    if ('serviceWorker' in navigator && location.protocol.startsWith('https')) {
        window.addEventListener('load', () => {
            navigator.serviceWorker.register('service-worker.js')
                .then(reg => {
                    resolve(reg);
                    setTimeout(function update() {
                        reg.update().catch((err) => {
                            console.log('[SW UPDATE ERROR]:' + err);
                        });
                        setTimeout(update, 120000);
                    }, 30000);
                })
                .catch(err => console.error('[SW ERROR]', err));
        });
    }
});

Then in my code (Angular + Material) I do this to 'show a prompt' to the user to reload:

ngOnInit(): void {
    window['isUpdateAvailable']
        .then((reg) => {
            if (reg) {
                this.setupListener(reg);
            }
        });
}

private setupListener(reg) {
    reg.onupdatefound = () => {
        const installingWorker = reg.installing;
        installingWorker.onstatechange = () => {
            if (installingWorker.state === 'installed') {
                this._ngZone.run(() => {
                    this.openSnackBar();
                });
            }
        };
    };
}

private openSnackBar() {
    if (!this.dialogOpen) {
        this.dialogOpen = true;
        this.snackBar.openFromComponent(NewApplicationVersionDataComponent, {
            duration: -1,
            verticalPosition: 'bottom',
            horizontalPosition: 'center'
        }).afterDismissed().subscribe(() => {
            this.dialogOpen = false;
        });
    }
}

@alexeigs
Copy link

Let's assume you already have the app installed without that code implemented: there is no way to force update existing PWAs right away right? I guess you just need to wait until the iPhone refreshes the cache automatically, replace the old index file by the new one including the script and only by then is updating as desired based on that script.

@kawazoe
Copy link
Author

kawazoe commented Oct 26, 2019

@alexeigs That is right. As far as I am aware off, the only way to reliably force an update in this case is to clear safari's cache. I have seen this cache survive OS updates so I wouldn't count on this happening by itself too much.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment