Code splitting causes chunks to fail to load after new deployment for SPA
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
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.
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)
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.
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.
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
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.
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.
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 componentsconst 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