reactjscode-splittingreact-lifecyclereact-error-boundaryreact-lazy-load

React lazy components with poor internet connectivity and error boundaries: Failed import due to poor connectivity seems to be cached even when online


In an "offline-first" app (which optimistically updates local state and gracefully handles errors by rolling back local state changes, for example), some features may still require internet connectivity.

In one case, I require internet connectivity to use a feature or even mount a component. If the user has no internet access (presume I can reliably detect this), I throw an error if the component is mounted.

I catch this error using an error boundary component that wraps react-error-boundary. Yes, in my example, I could simply check if the user is online in the relevant component and render the fallback if so, but there are other factors not described that should not be relevant to the minimal reproducible example.

This is my wrapper for react-error-boundary's ErrorBoundary component:

interface ErrorBoundaryProps extends React.PropsWithChildren {
    message?: string
}

interface ErrorFallbackProps extends ErrorBoundaryProps {
    onRetry: () => void
}

const ErrorFallback = memo((props: ErrorFallbackProps) => {
    const { message = "Something went wrong", onRetry } = props
    const { resetBoundary } = useErrorBoundary()

    const handleRetry = useCallback(() => {
        resetBoundary()
        onRetry()
    }, [onRetry, resetBoundary])

    return (
        <div>
            <p>{message}</p>
            <Button
                aria-label="Try again"
                onClick={handleRetry}
            >
                <RetryIcon />
            </Button>
        </div>
    )
})

ErrorFallback.displayName = "ErrorFallback"

export const CustomErrorBoundary = memo((props: ErrorBoundaryProps) => {
    const { message } = props
    const [attempt, setAttempt] = React.useState(0)

    const handleRetry = useCallback(() => {
        // `attempt` is incremented to remount all children
        setAttempt((prev) => prev + 1)
    }, [])

    return (
        <ErrorBoundary
            // This should ensure that every retry results in a new mount
            key={attempt}
            fallback={<ErrorFallback message={message} onRetry={handleRetry} />}
            onError={console.error}
        >
            {props.children}
        </ErrorBoundary>
    )
})

Let's say I have a component like this:

const BigOnlineFeature = memo(() => {
    if (!userIsOnline) throw new Error("This component requires internet connectivity.")

    const [success, setSuccess] = useState<boolean | null>(null)

    useEffect(() => {
        // NOTE: Exceptions in unawaited promises should not trigger the error boundary
        performSomeNetworkCall().then(() => {
            setSuccess(true)
        }).catch(() => {
            setSuccess(false)
        })
    }, [])

    if (success === undefined) return <p>Pending...</p>
    if (success) return <p>Success!</p>
    return <p>Failed!</p>
})

If I have an app like this:

const App = () => {
    return (
        <CustomErrorBoundary message="This feature requires internet connectivity.">
            <RequiresInternetConnectivity />
        </CustomErrorBoundary>
    )
}

then I do this procedure to test:

  1. In my browser's developer tools, block all requests or simulate throttling to the "offline" level
  2. Render the App component
  3. See the fallback with retry
  4. Disable throttling/offline simulation
  5. Click the retry button

...then the final result is as expected.

Now, there are two target audiences of this app:

  1. The people in the field, who will most likely not have internet access and will probably not use this online feature.
  2. The managers in an office with full internet access, who will likely use this feature.

Due to the large size of the actual RequiresInternetConnectivity component and everything it imports, I have made it a lazy component to improve the initial load of the app and lazy-load the code required for this feature:

const BigOnlineFeature = React.lazy(() => import("./path/to/BigOnlineFeature")

const App = () => {
    return (
        <CustomErrorBoundary
            message="This feature requires internet connectivity. You are currently offline."
        >
            <Suspense fallback={<Spinner />}>
                <BigOnlineFeature />
            </Suspense>
        </CustomErrorBoundary>
    )
}

This is the error that happens as expected when mounting the component while offline:

Uncaught TypeError: error loading dynamically imported module: http://localhost:3000/src/onlineOnly/BigOnlineFeature.tsx

However, when I disable the offline mode simulation and click the retry button, the same error is thrown again. If I remain online the whole time, it's loaded and rendered correctly.

It seems like React.lazy caches the result of the first import. Because the arrow function in React.lazy threw an error the first time, it will throw it every time.

Probably relevant observations:

Is there a way for me to achieve what I'm trying to do: Lazily loading the code for a feature that requires internet connectivity, while still allowing the component that defines the React.lazy component to be used while offline?

Edit: Even if importing the lazy component within a wrapper, the result is the same (note that the BigOnlienFeatureWrapper below should be remounted on each retry because of the attempt key):

const BigOnlineFeatureWrapper = React.memo(() => {
        const BigOnlineFeature = React.lazy(() => import("./path/to/BigOnlineFeature"))

        return <BigOnlineFeature />    
})

Edit 2: There seems to be an ongoing discussion about catching these errors using error boundaries, but the only viable option seems to be to refresh the window, which is not really how this app works. If you reload the window, you reload a bunch of stuff that you don't want to reload. Navigating to the exact same UI state would also add a ton of complexity.



Solution

  • You've discerned correctly that React.lazy caches the result:

    Both the returned Promise and the Promise’s resolved value will be cached, so React will not call load more than once. If the Promise rejects, React will throw the rejection reason for the nearest Error Boundary to handle.

    The only way to get the behavior you want is to write your own version of React.lazy (or to use a library like https://loadable-components.com/ which, if I am reading its code correctly, will attempt to reload your component the next time you mount it if your component throws an error while loading).