facebook / react

The library for web and native user interfaces.
https://react.dev
MIT License
225.29k stars 45.94k forks source link

Bug: Nested lazy components cause rerendering #29235

Open raymondwang opened 1 month ago

raymondwang commented 1 month ago

This is a duplicate of https://github.com/facebook/react/issues/27573, which was closed without response. We're noticing this issue at scale, and it's fairly pronounced, with hundreds of rapid re-renders triggered by a single lazy component. I've forked the replication from original issue to demonstrate that this is still an issue in React 19:

https://codesandbox.io/p/sandbox/react-lazy-rerender-bug-forked-q4q6ym

The current behavior

The lazy parent component renders multiple times:

Screenshot 2024-05-23 at 10 54 06 AM

(In our environment, we've seen the number of re-renders proliferate to the hundreds. This matches the below reproduction.)

The expected behavior

The lazy parent component should only render one time.


The original issue had a reply that included a reproduction that matched the issue we're experiencing more closely: https://codesandbox.io/p/sandbox/react-lazy-suspense-5g75x3

Similar to what we're seeing, this repro has hundreds of re-renders, and the number is nondeterministic.

Screenshot 2024-05-23 at 11 06 11 AM Screenshot 2024-05-23 at 11 06 21 AM

Our workaround for now is a lightweight replacement for Suspense using the Transition API to defer its lazy children, for usage in nested lazy contexts:

export const DeferredSuspense: FunctionComponent<SuspenseProps> = (props) => {
  const [isDeferred, setIsDeferred] = useState(true);

  useEffect(() => {
    startTransition(() => setIsDeferred(false));
  }, []);

  if (isDeferred) {
    return props.fallback;
  }

  return <Suspense {...props} />;
};
Aryant01 commented 1 month ago

hey @raymondwang . Can you please elaborate the issue and the expected result a bit more for the better understanding. That'll be really helpfull.

raymondwang commented 1 month ago

@Aryant01 The issue is that a Suspense rendered within another Suspense causes errant rerendering of the component within the first Suspense. The examples in the code sandboxes are more illustrative, but here's a brief example:

function Grandchild() {
  return <p>Hello world!</p>
}

function Child() {
  const LazyGrandchild = React.lazy(() => import('./Grandchild'));

  return (
    <Suspense>
      <LazyGrandChild />
    </Suspense>
  );
}

function App() {
  const LazyChild = React.lazy(() => import('./Child'));

  return (
    <Suspense>
      <LazyChild />
    </Suspense>
  );
}

Here's what I'd expect to happen:

  1. App renders
  2. App lazily imports Child
  3. Child renders
  4. Child lazily imports Grandchild
  5. Grandchild renders

Instead, here's what's actually happening:

  1. App renders
  2. App lazily imports Child
  3. Child renders
  4. Child lazily imports Grandchild
  5. Child rerenders
  6. Grandchild renders

I think that what's happening under the hood in step 5 matches what's described here: https://react.dev/reference/react/Suspense#preventing-already-revealed-content-from-hiding — because Child sees a suspended component, its suspense is hoisted up to the closest parent suspense in App.

This issue can balloon far out of control very easily, as seen in this reproduction: https://codesandbox.io/p/sandbox/react-lazy-suspense-5g75x3

Instead of rerendering just once (which might be acceptable), this interaction can cause hundreds of errant rerenders.

alvaro-cuesta commented 1 month ago

I'm still working on a minimum reproduction but I think I'm seeing this. In my case this is also causing error 421 when hydrating after SSR. Removing either the inner lazy component, or just its parent boundary (but leaving the grandparent boundary in) still exhibits error 421.

Anyone else seeing the same or am I chasing a red herring?