May 03, 2024

Is your app fresh?

Do your users run the latest version of your webapp?
Do they see the latest and greatest data?
Do they have the fix for that nasty bug?

What is the problem?

fresh

It might sound superfluous, or even illogical, why should we think about updating a webapp.
The app runs in the browser, online. The server sends the latest version each time the browser asks.
The thing is, if your app is a Single Page Application (SPA), it can run in the browser for quite a long time.
Without ever being download from the server.

Which means running potentially old javascript, html, css.

Additionally, even if the app itself didn't change, maybe some configuration changed.

App version check

There are a couple of solutions.

  1. Start a timer and reload after 24 hours, always.
  2. Do a version check.

Option 1. will work, and it is in fact a good idea to implement as a fallback.
However 24 hours are sometimes too long, but a shorter timeframe might be annoying.
We also have to figure out what reload will mean to our users.
We certainly don't want to reload while the user is entering data.

Generate app version and serve

For option 2. in order to do a version check we first need to write a version somewhere.
To make this state-less, so that we don't need to remember the last version, we shall use a date.
And we will write the date and time into a version.html file during build.

Here is an example package.json file for a NodeJS server.

{
  "name": "server",
  "version": "1.0.0",
  "description": "",
  "main": "dist/index.js",
  "scripts": {
    "build": "(echo $(date -u +\"%Y-%m-%dT%H:%M:%SZ\") > ./build/version.html) && npx tsc",
    "start": "node dist/index.js",
  },
}

The magic happens in the (echo $(date -u +\"%Y-%m-%dT%H:%M:%SZ\") > ./build/version.html) command. Which will write the current date and time in ISO format UTC, into a version file.
The /build directory is where the client-side SPA lives.

Alternatively, we could stat (get the modification date of) the index.html file in node.js and generate the version.html file on the fly.
However, depeding on how the app is hosted, the filesystem date might change in surprising ways.

Finally, we could generate the version.html file in a git commit hook.

There are no wrong answers here, just use what makes the most sense in your circumstances.

Once we have the version (date) on the server, we can serve it to the webapp running in the user's browser.
Since the version.html file is put into the spa build dir, it is served as static by NodeJS.

// our server app lives in 'dist', thus we need to __dirname, then go one level up '..' and go down one level to 'build'.
const staticPath = path.join(__dirname, "../build");
app.use(express.static(staticPath));

Read and compare app version

In the browser we will fetch the version file (or api) and compare to script load time.
The script load time, is just a global variable initialized to current time new Date().
To accurately reflect the load time, we want to put it into index.tsx or App.tsx, or whather your app's entry point is.

const pageLoadedDate = new Date();

const checkNewAppVersion = async () => {
  try {
    const resp = await fetch("/version.html");
    if (resp) {
      const text = await resp.text();
      const date = new Date(text.trim());
      if (date && !isNaN(date.getTime()) && pageLoadedDate < date && date < new Date()) {
        return true;
      }
    }
  }
  catch (e) {
    console.log(e);
  }
  return false;
}

How often you want to do this is really up to you.

Since the version can be easily cached on the server and is quite small, even checking on every client-side page load should be fine.
Here, by client-side page load we mean the user navigating to a different page within SPA.

The nice thing about doing the check in the page load, is that we can do a reload and the user might not even notice (apart from being a little slower).

Dynamic data versioning

The above works well for the app (static) file versioning, but maybe you also need a dynamic data versioning check.
For example some configuration changed like the user’s permissions or even the database structure (if your app can do that while running;).

One approach is to have a dynamic-version api call which would return the configuration-modification-date.
And we need to make sure we set the date in expressJS (or your server of choice) each time the configuration changes (user permissions are changed, etc.)

Additionally we can use Server Sent Events to deliver a notification to browsers, that the underlying configuration has changed. This way we could even notify or interrupt the user, without waiting for a page load.

Static and dynamic check.

In our apps, we use a AuthLayout React router component.
This component checks whether the user is authenticated and will load and cache configuration intormation.
The it renders a child page component via the <Outlet/>.

const AuthLayout = () => {

	const [meta, setMeta] = useState(null);
	
	useEffect(()=>{

		const newMeta = await loadConfiguration();
		if (checkNewAppVersion(newMeta)) {
        	window.location.reload();
          	return;
		}

	}, [meta?.needsRefresh]);

	return (!meta ? <Loading />) : (
      <MetaContext.Provider value={meta}>
        <Header />
        <Outlet />
        <ModalSite />
      </MetaContext.Provider>
  ));
}

Each time the meta - configuration is loaded we will do a version check and simply reload the page from the server.
Because this happens at the very start of page rendering, we are sure the user is not in the middle of something.

When the app receives a configuration change event in SSE (Server sent events) push message, we set the needsRefresh flag.
If the user doesn't navigate somewhere else in time (and thus reloading the configuration and performing a version check) we show an info banner.
The user can save work and reload the page from server by clicking on the banner.

You might also want to have a second timer, the hard limit, that will reload the page, ignoring any changes made by the user.

Bonus: Tab hibernation

Another issue, is tab sleep. Browsers will “hibernate” a tab, if it’s unused and in the background.
But, they will not refresh the tab when resumed🙈.
Instead the cached html file is loaded. Which might in turn ask for JS or css files that no longer exists.

Why would they not exist? JS files are usually generated (by builders like webpack) with a the hash of their content in the file name.
If we used a static name like logic.js, browser or proxies could cache an old version. The hash in the name busts the cache. This is also called salting. It comes from cryptograhic salt, which guarantees uniqueness of encrypted data.

Thus when you deploy a new version of your app, the js files will have a new name. Of course you also deployed a new index.html file, that references the correct new js file names.

But the browser will not use your new index.html when resuming the tab! If you even seen a white screen instead of your app after resume.
Or you have seen a Uncaught SyntaxError: Unexpected token '<' is invalid in your logs, the above is likely the cause.

Unexpected token '<' error is generated because the browser asks for a non-existent file and the server replies with 404 html file. (For SPA the server is usually configured to just return the index.html for all non /api/ requests.)
Since this html file usually starts with < which is illegal for a javascript file, the browser raises an exception.

  1. We must make sure caching of index.html is disabled.
  2. Handle the ‘<‘ error and force reload the page.

Let's look at how we can handle the error and reload the page.
First we have to put this error handler into our index.html file directly.

Then we shall look for the error message Uncaught SyntaxError: Unexpected token '<'. Finally we, shall remember when we forced a reload, to prevent a reload loop, if the issue is caused by something else.

<html>
<head>
<script lang="javascript">
var unhandledErrorDefaultHandler = function (event) {
	console.log("UNHANDLED ERR:" + event.message);
	if (event.message === "Uncaught SyntaxError: Unexpected token '<'") {
		var lastreload = localStorage.getItem("error_reload");
		if(lastreload && (+lastreload + 10000) > new Date().valueOf() ) {
			console.log("Error loop detected");
		} else {
			localStorage.setItem("error_reload", new Date().valueOf());
			//alert("Application was updated, and will be reloaded.");
			window.location.reload();
		}
	}
	return false;
};
window.addEventListener("error", unhandledErrorDefaultHandler);
</script>
<head>

Keep it fresh

Building an online app might give the users and developers a false sense of fresh data.
In fact the page is out-dated the moment it is displayed.

Therefore we need a system of versioning of the app itself, the configuration and the data.
Plus a way to notify the browser a new version is available.
Plus a reliable way to let the user know (or doing it a way that will not affect the user).

After all that, we can enjoy the fresh fruits of our work.

Happy hacking!