App version notifier

Shipping code with CI

Continuous Integration allows Tatari developers ship code multiple times a day. For those unaccustomed to CI, it can be a freeing experience but as with most things, it’s not without its caveats. One unexpected challenge is keeping the web applications up-to-date in our customers’ browsers.

The Media Buying Platform team develops rich web apps for our buyers to locate and secure the best TV ad slots for Tatari’s clients. So, from the dev team’s perspective, the media buyers are our customers and our mission is to support them with improvements and fixes at a moment’s notice. CI makes this possible but… therein lies the rub:

Stale browser cache

When deploying a single page application (SPA), the typical approach is to ship each release bundle with a unique version hash in its name, forcing the browser cache to “bust” as soon as the user reopens the app (or hard-refreshes the browser).

However, if you happen to serve hard-working customers such as our media buyers, who often keep an instance of the SPA open while burning the midnight oil, they may miss out on bug fixes, cool new features, or worse, encounter bugs due to their app falling behind the deployed version. (While a rare event, we do occasionally break some eggs.)

Hence, we wanted some mechanism to alert our users directly within the app, whenever a new version becomes available.

Approach

After some internal brainstorming, we settled on the following approach:

At build time (Webpack):

  1. Associate a unique version identifier with each CI build
  2. Statically bake such version ID as a variable into the JS bundle
  3. Generate an assets/build.json file containing the same version ID to be deployed along with other assets

In the app (JS):

  1. Set up an interval to poll the server for the build.json file
  2. Whenever the fetched version ID does not match the JS variable, notify the user

In essence, we are comparing the client bundle version against the latest version currently deployed on the production server.

Webpack config

Sounds easy enough? Sure does until one begins futzing with Webpack configurations. Thankfully, there are existing plugins and packages we can use.

For the unique version identifier, it makes sense to adopt the git version of the commit being built. To simplify this example, however, let’s use the uuid package.

To bake the version into our JS code as a global variable, Webpack’s own DefinePlugin is a shoo-in.

Finally, the third-party generate-json-webpack-plugin NPM module will generate our JSON file.

yarn add -D uuid generate-json-webpack-plugin

With these dependencies in our package.json, we can extend our webpack.config.js:

// Imports...
const GenerateJsonPlugin = require('generate-json-webpack-plugin');
const { v4: uuid } = require('uuid');

// Your webpack shenanigans, whatever they may be...

// Create the JSON object with a unique version ID
const buildJson = { version: uuid() };

// Add your plugins...
module.exports.plugins = module.exports.plugins.concat([
    // Bake in the ID as a global __VERSION__ var
    new webpack.DefinePlugin({
      __VERSION__: JSON.stringify(buildJson.version),
    }),

    // Save the JSON file wherever your deployable public assets folder lives
    new GenerateJsonPlugin('../assets/build.json', buildJson),
  ]);

NOTE: If using Typescript, you will also need to declare the global __VERSION__ in one of your d.ts files.

With the config stuff out of the way, we are now free to write some fun Javascript to act on any version changes.

The Notifier component

We need a little abstraction to do the polling and notifying for us. Luckily, we are still in the world of React, so a component will do. And while our Tatari APIs are GraphQL-powered, here we can settle for good old fetch, e.g. the whatwg-fetch polyfill. Finally, we can leverage our existing app-wide notifications (covered in our earlier post):

import 'whatwg-fetch';
import * as React from 'react';

import useInterval from 'hooks/use_interval';
import useNotifications, {NOTIFICATION} from 'hooks/use_notifications';

const FETCH_INTERVAL = 1000 * 60 * 30; // 30 mins (or as appropriate)

type TVersionFile = {
  builtAt: number;
  version: string;
};

export default function VersionNotifier() {
  const {notifications, setNotification} = useNotifications();

  // Polling handler
  const handleVersionNotify = React.useCallback(async () => {
      try {
        // Fetch build.json from server
        const verFile: TVersionFile = await fetch('/assets/build.json').then(resp => resp.json());
        // On version ID mismatch, trigger notification
        if (verFile.version !== __VERSION__) {
          setNotification({
            message: `New app version available. Please refresh your browser!`,
            variant: NOTIFICATION.ALERT,
            timeout: null, // do not auto-expire the message
          });
        }
      } catch (e) {
        console.error('Unable to fetch build.json');
      }
  }, [notifications, setNotification]);

  // Set up polling, call above handler at each interval
  useInterval(handleVersionNotify, FETCH_INTERVAL);

  // Render an empty span (for some debugging fun)
  return <span data-build-version={__VERSION__} />;
}

NOTE: To learn more about the useInterval and useNotification hooks above, explore the App-wide Notifications examples on CodeSandbox.

That’s pretty much it. One neat UX enhancement worth suggesting might be an actual link within the notification message to reload the app. Since the message prop accepts JSX, easily done:

setNotification({
  message: (
    <>
      New app version available. Please{' '}
      <button onClick={() => window.location.reload()}>refresh</button>
     {' '}your browser!
    </>
  ),
  variant: NOTIFICATION.ALERT,
  timeout: null, // do not expire
});

Now, slot the <VersionNotifier /> near the root of your render tree (below the <NotificationsProvider />) and your customers will never miss an update!

Bells & Whistles

As additional improvements, our version is applied conditionally for production builds-only and implements an extra debounce-like timeout to shield our buyers from “notification burnout”. So, on busy days, perhaps they get notified on every second or third deployment only.

Conclusion

Did I mention we ship a LOT? Come join us at Tatari and you will too! :fire:

Peter Morawiec

Peter is a Sr. Frontend Developer at Tatari