React: React.lazy does not allow retrying a rejected promise

Created on 16 Nov 2018  Â·  16Comments  Â·  Source: facebook/react

Do you want to request a feature or report a bug?

It can be seen as a feature or a bug, depending on angle. Let's say it's an enhancement to how lazy works.

What is the current behavior?

When using React.lazy, if the given promise rejects while trying to asynchronously load a component, it's no longer possible to retry loading the component chunk because lazy internally caches the promise rejection.

If the current behavior is a bug, please provide the steps to reproduce and if possible a minimal demo of the problem. Your bug will get fixed much faster if we can run your code and it doesn't have dependencies other than React. Paste the link to your JSFiddle (https://jsfiddle.net/Luktwrdm/) or CodeSandbox (https://codesandbox.io/s/new) example below:

This does not seem to work great in CodeSandbox because it's using service workers, which get in the way when simulating offline mode, yet this small app illustrates the issue: https://codesandbox.io/s/v8921j642l

What is the expected behavior?

A promise rejection should not be cached by lazy and another attempt to render the component should call the function again, giving it the chance to return a new promise.

Which versions of React, and which browser / OS are affected by this issue? Did this work in previous versions of React?

AFAIK all version of React that include lazy.

Reconciler Stale Feature Request

Most helpful comment

FWIW, I ran into this same problem, and found this issue while doing research as to who was caching the promise (thanks for filing it!). I found a workaround if you still have to make it work until this is properly solved here.

Codesandbox is here: https://codesandbox.io/s/1rvm45z3j3

Basically I created this function, which creates a new React.lazy component whenever the import promise rejects, and assigns it back to the component variable:

let Other;

function retryableLazy(lazyImport, setComponent) {
  setComponent(
    React.lazy(() =>
      lazyImport().catch(err => {
        retryableLazy(lazyImport, setComponent);
        throw err;
      })
    )
  );
}

retryableLazy(
  () => import("./Other"),
  comp => {
    Other = comp;
  }
);

Another important aspect is that the error boundary of your component should be using something like render props, in order to use the new value that the variable references at any point in time (otherwise it will always use the first value assigned to Other and keep using it forever):

<ErrorBoundary>
  {() => (
    <Suspense fallback={<div>Loading...</div>}>
      <Other />
    </Suspense>
  )}
</ErrorBoundary>

Hope this helps at least make it work while this is solved!

All 16 comments

React.lazy accepts any async function that returns a module, so you could do something like this -

const Something = React.lazy(async () => {
  let mod;
  while(!mod){ 
    try{
      mod = await import('./something')
    }
    catch(err){}   
  }
  // this does infinite retries, 
  // you could modify it to do it only thrice, 
  // or add backoff/network detection logic, etc 
  return mod 
})

(I haven't tested the above, but I think it should work?)

@threepointone that would immediately retry loading a failed module until it eventually fails and gives up, which could address part of the problem.

The part we can't currently address unless we change lazy implementation is the scenario where we want to retry loading a previously failed async module load after the initial attempt(s) gave up.

Imagine an app code-split by route:

  1. The user loads the initial route, navigates around while loading new routes on demand and then eventually goes offline, or has connectivity issues.
  2. Any new route requested during this period will fail to load (which is expected) and we want to be able to gracefully show an error letting the user know we couldn't load it right now.
  3. The user is free to continue using the application and navigate back to the routes that have already been loaded.
  4. At a later time, if the user goes back to the previously failed route, we want to retry loading the module from the network and render it if successful.

Since lazy is caching a failed promise, we can't do the 2nd part of 2 in a timely fashion or do 4 at all right now. Changing lazy to cache only fulfilled promises and forget rejected ones would allow us to support this use case, which seems something we would like React to facilitate.

This is applicable to any lazily loaded component, not only at "route-based" split points.

FWIW, I ran into this same problem, and found this issue while doing research as to who was caching the promise (thanks for filing it!). I found a workaround if you still have to make it work until this is properly solved here.

Codesandbox is here: https://codesandbox.io/s/1rvm45z3j3

Basically I created this function, which creates a new React.lazy component whenever the import promise rejects, and assigns it back to the component variable:

let Other;

function retryableLazy(lazyImport, setComponent) {
  setComponent(
    React.lazy(() =>
      lazyImport().catch(err => {
        retryableLazy(lazyImport, setComponent);
        throw err;
      })
    )
  );
}

retryableLazy(
  () => import("./Other"),
  comp => {
    Other = comp;
  }
);

Another important aspect is that the error boundary of your component should be using something like render props, in order to use the new value that the variable references at any point in time (otherwise it will always use the first value assigned to Other and keep using it forever):

<ErrorBoundary>
  {() => (
    <Suspense fallback={<div>Loading...</div>}>
      <Other />
    </Suspense>
  )}
</ErrorBoundary>

Hope this helps at least make it work while this is solved!

Thanks for sharing @leoasis.

There are workarounds for this in user-land, albeit rather convoluted. Changing lazy can make this common scenario very simple.

Is there any known drawback to this proposed change, like for example affecting performance in a negative way?

We definitely want to support this in a first class manner from react, but I don’t have any specific details to share yet.
The proposed workarounds aren’t ideal, but if they unblock you and you’re ok with the perf after measuring it, might be ok for now. Will report back on this issue when we have more news.

@threepointone is there any updates about this issue?

If there was an update — it would be on this issue. :-)

You can help drive it by submitting a failing test case, with or without a fix. Here's an example of me changing something in React.lazy a few days ago, might help: https://github.com/facebook/react/pull/14626.

Any update on this Issue ???
Rejected and cached the failed result. next time its not triggering the fresh call to retrieve the data

I have made a pull request #15296 and added a test case. Please review and comment.
Thanks.

This could be done at the promise level retrying with a catch handler provided to the promise returned by the call to import, and you can even create an importWithRetries utility where you can pass the number of retries and the path of the file.

Handling lazy loading was so much easier with use of react-loadable (unfortunately it doesn't look like its maintained) it shame that its done so poorly in main library.
Also it looks like everybody tries to do automatic retries and what about showing user some message and for example retrying on user action like clicking retry button?

Edit:
I have found a way how to handle errors with retrying on user interaction if anyone needs that:

import * as React from 'react';

export default function LazyLoaderFactory<T = any>(
  promise: () => Promise<{ default: React.ComponentType<any> }>,
  Loader = () => <>Loading...</>,
  ErrorRetryView = ({ retry }) => <button onClick={retry}>Module loading error. Try again.</button>,
) {
  function LazyLoader(props: T) {
    const [ loading, setLoading ] = React.useState<boolean>(true);
    const retry = React.useCallback(() => setLoading(true), []);
    const Lazy = React.useMemo(() => React.lazy(() => promise().catch(() => {
      setLoading(false);
      return { default: () => <ErrorRetryView retry={retry}/> };
    })), [promise, loading]);
    return <React.Suspense fallback={<Loader/>}><Lazy {...props}/></React.Suspense>;
  }

  (LazyLoader as any).displayName = `LazyLoader`;

  return LazyLoader as React.ComponentType<T>;
}

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contribution.

Closing this issue after a prolonged period of inactivity. If this issue is still present in the latest release, please create a new issue with up-to-date information. Thank you!

This issue (feature request) is still valid. I think that retrying should have first-class support.

Yes, this issue isn't fixed. What's wrong with different caching behaviour based on whether the Promise fulfilled or rejected?

For anyone else coming across this issue; I've gone for a slightly different workaround:

<ErrorBoundary>
  {() => (
    <Suspense fallback={<div>Loading component...</div>}>
      {React.createElement(
        React.lazy(() => import("./my/Component")),
        {  aProp: "aValue"  }
      )}
    </Suspense>
  )}
</ErrorBoundary>;

This is re-rendered by a retry button in the ErrorBoundary that changes the ErrorBoundary state.

Was this page helpful?
0 / 5 - 0 ratings