Allowing users to trigger hot reload when they're ready

The hot reload feature is great but causes some of us to lose work when it happens unexpectedly. During testing we’d be in the middle of creating/editing something, and the browser would reload. This post aims to do two things:

  1. See if the approach I’ve done is sound
  2. (if above is true) Share the approach for others to use as well in their apps.

The idea is to notify users who are in the app that an update is ready. I’ve seen mentions of how to do this so I put things together in a simple way to let the user decide when they’re game to reload.

Here’s the relevant bit of code:

Template.body.onRendered(function(){
    Reload._onMigrate(function(retry) {
        Session.set('showUpdateNotice', true);
        return [false];
    });
    // ... other code

By returning false, it prevents the reload. The Session is used just to add a class to a notification popup. when the Session is true, the user sees this little message:

Clicking “refresh now” just does location.reload();

So far in testing it’s working great. We can finish what we’re doing and then do the reload. But because there seemed to be a lot of different/somewhat-successful approaches on the web and yet this one is both be easy and robust, I’m wondering if anyone can see any gotchas I’m missing? We don’t have any Cordova builds yet, so all testing has been in browsers.

Any feedback would be great!

4 Likes

This is a great idea! I no longer use Blaze (in favour or react) but I would want to see this sort of thing available to my users.

2 Likes

The good thing is that this is front-end framework agnostic. In React, you can just trigger a state change on the callbcack of Reload._onMigrate()

2 Likes

Indeed the front-end doesn’t matter because all you need is the callback for ._onMigrate(). I generally prefer Vue these days but the Meteor app uses Blaze and a refactor isn’t easy nor necessary (as far as we can tell).

In our case, I’m using it in body.js so that it’s global. Originally I thought to use it for authenticated users, but the reality is we don’t want a sudden reload if someone is in the middle of registering or, say, resetting a lost password.

From looking at the source, ._onMigrate() defaults to returning an array of true: [true]; By returning false, the automatic reload won’t occur even with subsequent code deploys.

1 Like

The way I read the code, returning [false] should not prevent automatic reload with subsequent code deploys.


The var allReady will be reinitialized with true on subsequent invocations. Am I overlooking something?

Reload._onMigrate() is used by some packages to restore state after a hot code push. To preserve this behavior, you can use the callback given by Reload._onMigrate instead of calling location.reload:

let reloadWanted = false;
let updateAvailable = false;
let requestReload = null;

// When the ready function is called, Reload re-runs
// all _onMigrate callbacks to check they are now ready
// for the page to reload.
Reload._onMigrate((ready) => {
  requestReload = ready;
  updateAvailable = true;

  return [reloadWanted];
});

// When the user clicks the button to update, run this code:
reloadWanted = true;
requestReload();
4 Likes

Here’s where I was looking: https://github.com/meteor/meteor/blob/f937983721eadcddb8518b0d5ac470c5010ddb20/packages/reload/reload.js#L110

As long as it returns [false], our own tests show the browser will not reload until the users takes action. In dev mode, saving one or more non-CSS files will only fire our notification (screenshot above) once. Any subsequent saves don’t have any action on reload nor multiple notifications firing. Same with our tests in Production.

Of course, it could be that I’m missing something, but so far our tests and UX have been good!

1 Like

That’s a nice approach as well. Do you see any advantages over that vs location.reload()? For us, we don’t bother returning [true], since reloading the browser does what we need. But curious if you feel our approach may not be as ideal as yours?

Reload._onMigrate is normally used to preserve data during a hot code push and then restore it when the page is updated to reduce how much the reload disrupts users. It is used by some core packages and some community packages used with blaze. For example, autoform is able to preserve the values in a form. The data is only saved once all _onMigrate callbacks say they are ready by returning true in the array.

The majority of apps that do not use blaze will probably see no difference right now compared to location.reload, but it hopefully will become easier in the future for apps to preserve state. There was a recent post about doing this with react: useMeteorState - useState with data persistence

I see. In theory that would be nice to preserve data but the scope of data is opaque to me. Pubs don’t stay cached between reloads nor do Session vars. I tried using the approach above but the end result appears to be the same as simply reloading the page.

By the way - what is the updateAvailable var for in your example?

FYI, Here’s my Vue approach:

//  in my main $root vue instance:

App = new Vue({
    data: {
        ...
        preventHCP: Meteor.isProduction,
        installHCPFn: false,  // to store the retry function for later
    }, 
    created() {
        Reload._onMigrate(retry => {
            if (!this.preventHCP) {
                // remember the current route so we can return the user to the same page...
                localStorage.setItem('HCPStorePath', window.location.pathname);
                return [true, {}];
            }
            this.$emit('showPopop', {
                title: "Software Update",
                text: "A software update is ready to install. Reload to complete the installation...",
                closeLabel: "Later...",
                buttons: [{
                    label: "Reload",
                    icon: '$refresh',
                    onClick: () => {
                        this.preventHCP = false;
                        retry();
                    },
                }]
            });
            this.installHCPFn = retry; // save this to use within a template
            return [false];
        });
        const HCPStorePath = localStorage.getItem('HCPStorePath');
        if (HCPStorePath) {
            localStorage.removeItem('HCPStorePath');
            this.$nextTick(() => this.$router.push(HCPStorePath).catch(e => e));
        }
    }

The only reason I store the retry function within data: { } is because I also use it to display a ‘refresh’ button in the title bar:

// within the title bar template (pug)"

v-btn.fade-loop(v-if="installHCPFn" icon @click="preventHCP=false;installHCPFn()" title="Reload to install update.")
    v-icon(color="#76FF03") $refresh
2 Likes

Right on! Cool stuff and that’ll come in handy if we refactor Blaze --> Vue. :slight_smile:

Sidenote: also cool to see your redirect so the current route is preserved. Just recently added a feature to our Blaze/Flowrouter app so that if an unauthenticated user tries to visit a protected page, they get redirected there after login. It’s not earth-shatteringly cool functionality, but it was long overdue and getting it to work was so simple and a testament to how Meteor makes magic happen so easily.

1 Like

Thanks @mikeTT.

On web the redirect is actually handled automatically by the Reload module, but it doesn’t work with Desktop (Electron) or Cordova which always reloads to your home route once the app reloads, hence I do it manually for my app/desktop users.

Just reading the thread above a bit more though, I realise that I don’t have to bother with my own localStorage to remember the route, the reload module can do that for me:

const migrateName = 'hcpPreviousLocation';
Reload._onMigrate(migrateName, retry => {
    if (!this.preventHCP) {
        return [true, window.location.pathname];
    }
    ...
});
const HCPStorePath = Reload._migrationData(migrateName);
if (HCPStorePath) {
    this.$nextTick(() => this.$router.push(HCPStorePath).catch(e => e));
}
1 Like

Yes, this was annoying for us as well. We made some changes to the Autoupdate package to achieve this: https://github.com/meteor/meteor/pull/10249

The following is the counterpart in our application to get notified and to trigger the update (just to get the idea):

/* eslint no-console: [2, { allow: ["debug", "error"] }] */
import { ReactiveVar } from 'meteor/reactive-var';
import { Reload } from 'meteor/reload';
import { useTracker } from 'meteor/react-meteor-data';
import { Meteor } from 'meteor/meteor';

const updateAvailable = new ReactiveVar();
const incompatible = new ReactiveVar();

export const useUpdateAvailable = () => useTracker(() => updateAvailable.get(), []);

export const useIsIncompatible = () => useTracker(() => incompatible.get(), []);

document.addEventListener('deviceready', () => {
  window.WebAppLocalServer.onError((err) => {
    if (
      err.message ===
      // eslint-disable-next-line max-len
      'Skipping downloading new version because the Cordova platform version or plugin versions have changed and are potentially incompatible'
    ) {
      // eslint-disable-next-line no-console
      console.log('Realoder: Remote side incompatible!');
      incompatible.set(true);
    }
  });
});

const update = () => {
  if (incompatible.get()) return;
  if (!updateAvailable.get()) return;
  if (Meteor.isCordova) navigator.splashscreen.show();

  // We'd like to make the browser reload the page using location.replace()
  // instead of location.reload(), because this avoids validating assets
  // with the server if we still have a valid cached copy. This doesn't work
  // when the location contains a hash however, because that wouldn't reload
  // the page and just scroll to the hash location instead.
  if (window.location.hash || window.location.href.endsWith('#')) {
    window.location.reload();
  } else {
    window.location.replace(window.location.href);
  }
};

Reload._onMigrate('foo', () => {
  console.debug('Reload migration blocked');
  if (Meteor.isCordova) {
    document.addEventListener('deviceready', () => {
      window.WebAppLocalServer.switchToPendingVersion(() => {
        // eslint-disable-next-line no-console
        console.log('called switchPendingVersion');
        updateAvailable.set(true);
      }, console.error);
    });
  } else {
    updateAvailable.set(true);
  }
  return false;
});

export { update, updateAvailable, incompatible };

and to get the fined grained status:

// @flow
import { useTracker } from 'meteor/react-meteor-data';
import { Autoupdate } from 'meteor/autoupdate';

export type AutoUpdateStatus = 'connecting' | 'loading' | 'outdated' | 'uptodate' | 'waiting' | void;

export const useAutoUpdateStatus = () => useTracker<AutoUpdateStatus>(() => Autoupdate.status().status, []);

This solution is not perfect, especially because the WebAppLocalServer doesn’t fire Cordova errors properly

1 Like

That’s awesome as well and thanks for sharing. Though I suspected it already, I’m glad to know others have been frustrated by this and have put fixes in place.

Does anybody know how to release the callbacks when the component has been unloaded? There is a possibility that this functionality is causing memory leaks in our app when the component is being mounted/unmounted multiple times

With Blaze we’ve used onDestroyed:

Template.someTemplate.onDestroyed(function(){
    // remove event listeners, intervals, etc 
});

But I suspect you already know that. Any code examples of the callbacks you can share?

I am trying to figure out what specifically we need to “destroy” for the Reload package to ensure that we are not accumulating callbacks everytime the component is mounted

Actually in thinking more about it, I don’t think the onDestroyed even plays a role. In my use-case in starting this thread, the location.reload() refreshes everything, so memory is all wiped and a new session starts. I may be missing something with your use-case, but in ours the forced reload just starts everything anew with respect to memory.

99% of the time, reload is not being called but the component can be mounted/unmounted. Every time the component is mounted, it will save the callback. Therefore, it can have multiple copies of the same callback saved.