Code splitting causes chunks to fail to load after new deployment for SPA
Asked Answered
B

5

54

I have a single page app that I code split on each route. When I deploy a new version of my app the users will usually get an error if a user still has the page open and visits a route they haven't visited before.

Another scenario where this can also happen is if the app has service workers enabled. When the user visits a page after a new deployment, the service worker will serve from the cache. Then if the user tries to visit a page not in their cache, they'll get the chunk loading failure.

Currently I disabled code splitting in my app to avoid this but I've been very curious what's the best way to handle this issue. I've thought about pre-loading all the other routes after the user finishes loading the initial page and I believe this might fix the issue for code splitting on routes. But let's say I want to code split on components then that would mean I have to try to figure out when and how to pre-load all of those components.

So I'm wondering how do people handle this issue for single page apps? Thanks! (I'm currently using create-react-app)

Burack answered 17/6, 2017 at 5:28 Comment(4)
Did you find a way to reproduce this locally?Dace
I haven't tried to reproduce it locally but I think one option to try to reproduce (NOTE: I did not test this so it might not work) would be to create 2 different builds that has different chunks. Serve the first one load it on the browser. While staying on that page, stop serving the first build and serve the second one. Now try to navigate to another page.Burack
This is what I did in the end! I build 2 different docker images (docker might not even be needed) that contained different versions of the code. While browsing the application switch the applications and on your next 'Chunk load' (navigating to a different URL for me) this error occurred.Dace
I solved by restarting my IIS server every time when I publish production, I know it is not good solution but I haven't found other way yet.Phrasal
E
44

I prefer to let the user refresh rather than refreshing automatically (this prevents the potential for an infinite refresh loop bug).
The following strategy works well for a React app, code split on routes:

Strategy

  1. Set your index.html to never cache. This ensures that the primary file that requests your initial assets is always fresh (and generally it isn't large so not caching it shouldn't be an issue). See MDN Cache Control.

  2. Use consistent chunk hashing for your chunks. This ensures that only the chunks that change will have a different hash. (See webpack.config.js snippet below)

  3. Don't invalidate the cache of your CDN on deploy so the old version won't lose it's chunks when a new version is deployed.

  4. Check the app version when navigating between routes in order to notify the user if they are running on an old version and request that they refresh.

  5. Finally, just in case a ChunkLoadError does occur: add an Error Boundary. (See Error Boundary below)

Snippet from webpack.config.js (Webpack v4)

From Uday Hiwarale:

optimization: {
  moduleIds: 'hashed',
  splitChunks: {
      cacheGroups: {
          default: false,
          vendors: false,
          // vendor chunk
          vendor: {
              name: 'vendor',
              // async + async chunks
              chunks: 'all',
              // import file path containing node_modules
              test: /node_modules/,
              priority: 20
          },
      }
  }

Error Boundary

React Docs for Error Boundary

import React, { Component } from 'react'

export default class ErrorBoundary extends Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    // Update state so the next render will show the fallback UI.    
    return { hasError: true, error };
  }
  componentDidCatch(error, errorInfo) {
    // You can also log the error to an error reporting service    
    console.error('Error Boundary Caught:', error, errorInfo);
  }
render() {
    const {error, hasError} = this.state
    if (hasError) {
      // You can render any custom fallback UI      
      return <div>
      <div>
        {error.name === 'ChunkLoadError' ?
          <div>
            This application has been updated, please refresh your browser to see the latest content.
          </div>
          :
          <div>
            An error has occurred, please refresh and try again.
          </div>}
      </div>
      </div>
    }
    return this.props.children;
  }
}

Note: Make sure to clear the error on an internal navigation event (for example if you're using react-router) or else the error boundary will persist past internal navigation and will only go away on a real navigation or page refresh.

Eniwetok answered 9/6, 2020 at 13:55 Comment(5)
Keep in mind, the ErrorBoundary will persist the error through navigation actions unless a 'real' page change or refresh occurs.Eniwetok
Great solution Jordan. Going to look into something similar to this.Trifling
@Eniwetok Thanks for the thorough answer! One question I have for (4) is how do you check version between routes? (We currently store the current version the user is on. How do you verify if what the latest is?)Burack
@KennethTruong I suspect that there are better ways to do this than what I came up with; but what I did was to make an endpoint in the backend that would return what the expected frontend version was (and then compare to see if it's out of date) and I would make a request against this endpoint when the user initiated navigation. This step is really only necessary for long-lived tabs anyway, so maybe only make that request on tabs that have been active for longer than a specified duration to prevent every user from making an extra request on every navigation.Eniwetok
@Eniwetok To your strategy point 3 - "Don't invalidate the cache of your CDN on deploy so the old version won't lose its chunks when a new version is deployed.", there can be a case that a few older chunks were never fetched and therefore never cached in CDN. How to solve this? One possible solution can be to make cURL requests to each chunk post-deployment to force the caching.Anny
D
8

The issue in our create-react-app was that the chunks that the script tags were referencing did not exist so it was throwing the error in our index.html. This is the error we were getting.

Uncaught SyntaxError: Unexpected token <    9.70df465.chunk.js:1 

Update

The way we have solved this is by making our app a progressive web app so we could take advantage of service workers.

Turning a create react app into a PWA is easy. CRA Docs on PWA

Then to make sure the user was always on the latest version of the service worker we made sure that anytime there was an updated worker waiting we would tell it to SKIP_WAITING which means the next time the browser is refreshed they will get the most up to date code.

import { Component } from 'react';
import * as serviceWorker from './serviceWorker';

class ServiceWorkerProvider extends Component {
  componentDidMount() {
    serviceWorker.register({ onUpdate: this.onUpdate });
  }

  onUpdate = (registration) => {
    if (registration.waiting) {
      registration.waiting.postMessage({ type: 'SKIP_WAITING' });
    }
  }

  render() {
    return null;
  }
}

export default ServiceWorkerProvider;

Bellow is the first thing we tried and did run into some infinite looping

The way I got it to work is by adding a window.onerror function above all of our script tags in index.html.

<script>
  window.onerror = function (message, source, lineno, colno, error) {
    if (error && error.name === 'SyntaxError') {
      window.location.reload(true);
    }
  };
</script>

I wish there was a better way but this is the best way I could come up with and felt like it's a pretty safe solution since create-react-app will not compile or build with any syntax errors, this should be the only situation that we get a syntax error.

Decrypt answered 12/6, 2019 at 18:58 Comment(1)
is below solution now working? I'm currently has PWA but still got the errorHarbaugh
M
3

We solved this in a slightly ugly, albeit really simple solution. Probably temporary for now, but might help someone.

We have an AsyncComponent that we created to load chunks (i.e. route components). When this component loads a chunk and receives and error, we just do a simple page reload to update index.html and it's reference to the main chunk. The reason it's ugly is because depending on what your page looks like or how it loads, the user could see a brief flash of empty page before the refresh. It can be kind of jarring, but maybe that's also because we don't expect an SPA to refresh spontaneously.

App.js

// import the component for the route just like you would when
// doing async components
const ChunkedRoute = asyncComponent(() => import('components/ChunkedRoute'))

// use it in the route just like you normally would
<Route path="/async/loaded/route" component={ChunkedRoute} />

asyncComponent.js

import React, { Component } from 'react'

const asyncComponent = importComponent => {
  return class extends Component {
    state = {
      component: null,
    }

    componentDidMount() {
      importComponent()
        .then(cmp => {
          this.setState({ component: cmp.default })
        })
        .catch(() => {
          // if there was an error, just refresh the page
          window.location.reload(true)
        })
    }

    render() {
      const C = this.state.component
      return C ? <C {...this.props} /> : null
    }
  }
}

export default asyncComponent
Mage answered 4/12, 2018 at 18:44 Comment(2)
I've not tried this, but couldn't you recursively run the componentDidMount function until the component imports correctly rather than refreshing the page?Macule
@BrandonKeithBiggs, the issue was that the chunk ID specified in the index.html was no longer valid so componentDidMount would just run endlessly because the component would never exist again (it existed in the past). Refreshing the page caused index.html to update, with the new chunk ID and things would work as normal.Mage
C
1

I am using AsyncComponent HOC to lazy load chunks, and was facing same issue. the work around I did is, identifying the error and make a hard reload once.

.catch(error => {
        if (error.toString().indexOf('ChunkLoadError') > -1) {
          console.log('[ChunkLoadError] Reloading due to error');
          window.location.reload(true);
        }
      });

the full HOC file looks like this,

export default class Async extends React.Component {
  componentWillMount = () => {
    this.cancelUpdate = false;
    this.props.load
      .then(c => {
        this.C = c;
        if (!this.cancelUpdate) {
          this.forceUpdate();
        }
      })
      .catch(error => {
        if (error.toString().indexOf('ChunkLoadError') > -1) {
          console.log('[ChunkLoadError] Reloading due to error');
          window.location.reload(true);
        }
      });
  };

  componentWillUnmount = () => {
    this.cancelUpdate = true;
  };

  render = () => {
    const props = this.props;
    return this.C ? (
      this.C.default ? (
        <this.C.default {...props} />
      ) : (
        <this.C {...props} />
      )
    ) : null;
  };
}
Chu answered 13/3, 2020 at 14:52 Comment(0)
H
0

I have a solution to the problem!

I was facing an identical situation. In my case, I use Vite in my React projects and every time the rollup generates the bundle chunks, it generates different hashes that are included in the filenames (Example: LoginPage.esm.16232.js). I also use a lot of code splitting in my routes. So every time I deployed to production, the chunk names would change, which would generate blank pages for clients every time they clicked on a link (which pointed to the old chunk) and which could only be resolved when the user refreshed the page (forcing the page to use the new chunks).

My solution was to create an ErrorBoundary wrapper for my React application that would "intercept" the error and display a nice error page explaining the problem and giving the option for the user to reload the page (or reload automatically)

Holoenzyme answered 24/12, 2022 at 19:55 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.