Ever wondered about 'Loading chunk X failed' exceptions in your React app? It is one of those exceptions that go unnoticed during development unless we throttle down our network speed (and nobody likes that). The first time I saw this was when I was skimming through the exceptions reported on production when we launched our product in a new market. Considering our alert service kept on pestering us continuously, we wanted to fix this as early as possible. From the terminology of the reported exception, we do know that this has something to do with code splitting.
Since we were using React.lazy and dynamic imports, we comprehended that we needed to have a mechanism in place that grabs exceptions reported while importing the chunks. Dynamic imports return a promise. So we needed a layer over these imports to catch the reported exceptions. Lazy accepts a function that returns a promise. The only condition is that this promise should resolve to a module with a default export (the target component).
const OurComponent = React.lazy(() => import('./OurComponent'));
Visualizing the flow of events in a dynamic import, the above line of code would translate to something like the following image.
One thing we notice here is that in either case (success or failure), the rejections are not handled gracefully. On failure, lazy does not implicitly handle the reported exception.
- One way that we can fix this error is by using ErrorBoundaries ( Fiber ❤️). But that does not solve our problem. It only handles the exception for us, which we can further report to our logging service.
- Another strategy we can apply here is that we can use our knowledge of promises and handle the exception when thrown by dynamic import. We can introduce an extra layer of abstraction and handle the exceptions. Something like this.
We will replace the '❓' above, with a block of code. The fundamental requirement of this block would be that it-
- Needs to return a promise resolving to a module having default export (React.lazy requires it).
- Should not leave the promise object returned from dynamic import un handled.
Let us give our '❓' block a name. We'll call it 'componentLoader'. Considering that this has to return a promise, let us create a new Promise and add a return statement. Next, we will accept the promise from dynamic import as a parameter. Let's call it lazyComponent.
function componentLoader(lazyComponent) {
return new Promise((resolve, reject) => {
// we will add code here. Soon.
});
}
If we observe, lazyComponent is not a component but a function that returns a promise object. Inside of the promise that we return from componentLoader, we trigger the function (lazyComponent) and add handlers for promise resolve (.then) and reject(.catch). Since the successful resolution of promise is not a problem in our use case, we let React.lazy handle the resolved contents.
We also add a .catch to handle exceptions. Within .catch we will be handling our exceptions.
function componentLoader(lazyComponent) {
return new Promise((resolve, reject) => {
lazyComponent()
.then(resolve)
.catch((error)=>{
// let us retry after 1500 ms
setTimeout(() => {}, 1500)
})
});
}
Since an exception was recorded when the chunk failed to load, we will try loading the piece that failed. For that, let us add a set time out. This would again attempt to load our component after waiting for a minimum number of seconds (1.5s in our example).
function componentLoader(lazyComponent) {
return new Promise((resolve, reject) => {
lazyComponent()
.then(resolve)
.catch((error)=>{
// let us retry after 1500 ms
setTimeout(() => {}, 1500)
})
});
}
Considering it is not wise to repeatedly load a chunk if it fails to load, we need to exit after some attempts and report an exception. Let's add a second parameter to our function signature and call it attemptsLeft. The attemptsLeft parameter will act as a base case to our recursive calls and terminate our them. This will raise an exception when all retry attempts have failed.
We achieve this as shown in the following snippet.
function componentLoader(lazyComponent, attemptsLeft) {
return new Promise((resolve, reject) => {
lazyComponent()
.then(resolve)
.catch((error)=>{
// let us retry after 1500 ms
setTimeout(() => {
if (attemptsLeft === 1) {
reject(error);
return;
}
// call componentLoader again!
}, 1500)
})
});
}
But, what about the recursive call? 🤔
We need to add one line to make it all work.
componentLoader(lazyComponent, attemptsLeft - 1).then(resolve, reject);
Here, we forward lazyComponent but reduce the number of attempts left (attemptsLeft -1).
The .then() attached to our function call gets super important if you want to catch the exception that was thrown after all attempts were exhausted.
And here is the complete snippet.
Loading chunk failed? No more! 🎉