Creating an Automatic “New Version Available” Alert with create-react-app and Firestore

Single Page Applications (SPAs) have a significant advantage over traditional multi-page applications in that they load assets when a client initially accesses the app. This means that routing can be very fast; the user doesn’t have to load a new HTML file every time they navigate within the application. But this advantage also creates a unique problem: after deployment, users will only have the latest version of your app once they refresh the site.

Create React App uses Webpack to generate new builds, and Webpack will automatically hash filenames to make sure the client doesn’t hold on to old assets. Assuming your cache is properly configured, all you need from the user is a refresh to ensure they get the changes from your last deployment.

Alerting the User

So how do we let the user know they should refresh the app? Well, you may have seen an increasingly popular pattern for handling this: the new version available alert.

New version notification in Inbox (Screenshot)

Google Inbox (RIP) letting you know their team has deployed a new version

This pattern is used in many of Google’s products, including the Firebase Dev Console and Android Messages for web. It’s also used by my favorite budgeting app, YNAB. There are several ways to detect a version change on the frontend, including using service workers, regular calls to a server, and timers that run a check on index.html. All of these work, but they’re also fairly involved.

Because we use Firestore as our database on Datapage, we’re in a uniquely advantageous position when it comes to notifying our users about something. Each user already has an active connection to Firestore, and they’re already subscribing to changes in the database to display updates in real-time. If we can track the version of our app inside a Firestore document, the client can listen for a change in that document’s fields, and display a notification when the fields change in value.

Version numbers in Cloud Firestore (screenshot)

Now we’ll need a component that listens for the change, and displays an alert. We use the Material-UI for building frontend components and react-redux-firebase for connecting to Firestore, but native Firestore web functions will work just as well here.

First, let’s create a new component that listens for a change in the version document of our config collection:

import React, { useEffect, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import { useFirestoreConnect } from 'react-redux-firebase';

export const VersionChangeRefresher = () => {
  const [versionChanged, setVersionChanged] = useState(false);

  useFirestoreConnect({
    collection: 'config',
    doc: 'version',
    storeAs: 'appVersion'
  });

  const version = useSelector(
    (state) => state.firestore.data.appVersion?.number
  );

  const prevVersionRef = useRef();
  const prevVersion = prevVersionRef.current;

  useEffect(() => {
    prevVersionRef.current = version;
  });

  useEffect(() => {
    if (version && prevVersion && version !== prevVersion) {
      setVersionChanged(true);
    }
  });

  return null 
}

We initialize a state variable called versionChanged. We then connect to Firestore and subscribe to our version document and return the number field. When version changes, two side effects run. First, we set a ref with the value of the previous version so we can compare it to the new one. Then we compare the two versions and if they’re different, we update versionChanged to true.

Now that we know when the version changes, all that’s left is the UI.

<Snackbar open={versionChanged}>
  <Alert
    variant="filled"
    severity="warning"
    classes={{ message: middleText, icon: middleIcon }}
    action={
      <Button
        variant="outlined"
        onClick={() => forceRefresh()}
        style={{ minWidth: 100, color: 'white' }}
      >
        Refresh Now
      </Button>
    }
  >
    <Box display="flex" alignItems="center">
      A new version of the app is available. Your browser will
      automatically refresh in 30 seconds.
    </Box>
  </Alert>
</Snackbar>  

New version indicator in SPAs (screenshot)

Our Material-UI snackbar will open when versionChanged is set to true. Our refresh button calls on a function that is dead simple:

 const forceRefresh = () => window.location.reload();

Feel free to stop here. But let’s say we wanted to get more aggressive than Google does in making sure every user is on the updated version of the app? We could display a countdown timer and force a refresh after a given interval.

Counting Down to Refresh

First, let’s add another state variable and two more side effects.

const [secondsSinceChange, setSecondsSinceChange] = useState(0);

useEffect(() => {
if (versionChanged && secondsSinceChange < 30) {
const timer = setTimeout(() => {
setSecondsSinceChange(prevState => prevState + 1);
}, 1000);

return () => clearTimeout(timer);
}
});

useEffect(() => {
if (secondsSinceChange === 29) {
forceRefresh();
}
});

Our first effect will be triggered as soon as versionChanged is true and as long as secondsSinceChange is less than 30. We initialize secondsSinceChange as 0, and begin counting upwards, adding 1 to the value every second after our first side effect fires. As soon as secondsSinceChange reaches 29, we force a refresh of the app.

This is a big improvement, but we should probably let the user know what’s going on. Let’s update our alert to display a progress spinner which will fill up as the timer counts to 30, with the number of seconds remaining in the middle.

<Snackbar open={versionChanged}>
  <Alert
    variant="filled"
    severity="warning"
    action={
      <Button
        variant="outlined"
        onClick={() => forceRefresh()}
      >
        Refresh Now
      </Button>
    }
    icon={
      <Box position="relative" display="inline-flex" style={{ height: 30 }}>
        <CircularProgress
          variant="determinate"
          value={secondsSinceChange * 3.33}
          size={30}
        />
        <Box
          top={0}
          left={0}
          bottom={0}
          right={0}
          position="absolute"
          display="flex"
          alignItems="center"
          justifyContent="center"
        >
          <Typography
            variant="caption"
            component="div"
          >
            {30 - secondsSinceChange}
          </Typography>
        </Box>
      </Box>
    }
  >
    <Box display="flex" alignItems="center">
      A new version of Datapage is available. Your browser will
      automatically refresh in 30 seconds.
    </Box>
  </Alert>
</Snackbar>

New version warning in SPA (screenshot)

We create a new Circular Progress MUI component and set its value to secondsSinceChange * 3.33, converting our seconds counted to a value n/100, filling the circle up according to the Circular Progress component API. Inside the Circular Progress, we have an absolutely positioned numeric representation of how many seconds the user has before the app will refresh for them.

Automating Version Changes

This will work just fine, as long as you don’t forget to update your project’s version number by hand in Firestore each time you deploy a new version of the app. Let’s make it easier to not forget by automating the process.

Deployment processes vary widely, so I’ll just cover the process we use. The first step is to create an onRequest Cloud Function which will update our version number.

import * as functions from "firebase-functions";
import { db } from "../../admin";

export const updateVersion = functions.https.onRequest(async (req, res) => {
  try {
    const version = req.body.version;

    if (!version) {
      throw new Error("must specify version");
    }

    db.collection("config")
      .doc("version")
      .set({ number: version })
      .then((_) => {
        res.json({ result: "Version updated, deployment complete" });
      })
      .catch((err) => {
        throw new Error(err);
      });
  } catch (err) {
    console.log(err);
    throw new Error("Update version failed");
  }
});

Now, all we need to do is to make a request with a parameter of the new version number as the body. In Datapage, we handle all of our deployments through NPM scripts from our dev machine. Here’s what our deployment script looks like:

npm run build:staging && npm run backup:staging && npm run transfer:staging

This single command calls each step of our deployment process, from building the project, to backing up the old deployment, to copying the build files to our server. Let’s add one more script which will be executed last, only after the other steps have been completed:

npm run "./updateVersion.sh $npm_package_version firebase-project-id"

We’re calling a shell script and passing it two arguments. The first argument is what you could call a “hidden” feature of NPM. Every entry in your package.json is accessible when running a script with npm run, prefaced with npm_package. Because we always update version in our package.json when we deploy, we don’t have to change our deployment process to make sure we update Firestore with our new version number. The second argument is the Firebase project id that the Cloud Function should be run from.

Let’s take a look at that shell script:

echo "Updating version document in config of collection on $2 to ${1}..." &&
gcloud config set project $2 && 
gcloud functions call updateVersion --data '{ "version": "'${1}'"}' &&
echo "Version updated, deployment complete."

We’re making use of an overlooked feature of the Google Cloud CLI – it can call Cloud Functions directly. First, we set gcloud’s active project to the id we sent as our second argument. Then, we call the function and send back an object with a single field, version, with the version number of our package.json.

Let’s recap how this all works:

  1. We commit our new version of the app, updating the version in package.json.
  2. We run npm run deploy
  3. React builds the app for production, and transfers it to the server
  4. Node calls our script, which calls our Cloud Function, which updates Firestore with the new app version number
  5. Our component detects the change and fires a series of effects that display the alert: starting a countdown to an automatic browser refresh

The end result is that we won’t have to change anything about when and how we deploy, but we’ll be able to make some pretty safe assumptions about our users being up to date.

Featured Posts

Follow Along

Stay up to date with the latest news & examples from SeedCode

Leave a Reply

Your email address will not be published. Required fields are marked *

Check out some of our other posts ...

Suggesting Appointment Slots

Show Available Slots that Match Multiple Criteria Schedulers often look for gaps in their schedules to find the open resources for each opportunity. But sometimes,

Introducing Draft Settings Mode

Following up on this idea that people stretch themselves when they feel a little safer, we’ve been very focused on the customization experience for DayBack

New Longer Timescales for DayBack

Resource Scheduling Swimlanes You can now extend the pivoted scheduling view in DayBack to show items by week instead of solely by day. This lets

FileMaker Summer Camp – Recap

Unconference Sessions If you missed Pause in October, here’s a look at the sessions that attendees hosted. All the sessions are listed in this post

COMPANY

FOLLOW ALONG

Stay up to date with the latest news & examples from SeedCode

© 2024 SeedCode, Inc.