Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bug: nested Suspense with lazy children permanently shows fallback #32313

Open
jtbandes opened this issue Feb 6, 2025 · 4 comments
Open

Bug: nested Suspense with lazy children permanently shows fallback #32313

jtbandes opened this issue Feb 6, 2025 · 4 comments

Comments

@jtbandes
Copy link

jtbandes commented Feb 6, 2025

React version: both 18.3.1 and 19.0.0

When creating a lazy component in useMemo, and rendering inside nested Suspense, the suspense continues to render its fallback forever and never mounts the component – even after the component has had time to load.

This is reproducible with both 18.3.1 and 19.0.0.

May be related to #30582?

The current behavior

When rendering Suspense in a parent and the lazy component in a child, the lazy component never loads (line 2). However, when the child component additionally wraps its own Suspense, the loading succeeds (lines 1 & 3).

Image

The expected behavior

All lazy components should render

Image

Steps To Reproduce

Link to code example: https://stackblitz.com/edit/vitejs-vite-yafxnrot?file=src%2FApp.tsx

function Child({ n }: { n: number }) {
  return <div>Child {n}</div>;
}

function Layout2({ withSuspense = false }: { withSuspense?: boolean }) {
  const LazyChild = useMemo(() => lazy(async () => ({ default: Child })), []);
  if (withSuspense) {
    return (
      <Suspense fallback={<div>Suspense 2</div>}>
        <LazyChild n={2} />
      </Suspense>
    );
  } else {
    return <LazyChild n={2} />;
  }
}

function Layout() {
  const LazyChild = useMemo(() => lazy(async () => ({ default: Child })), []);
  return (
    <div>
      <Suspense fallback={<div>Suspense 1</div>}>
        <LazyChild n={1} />
      </Suspense>
      <Suspense fallback={<div>Suspense 2</div>}>
        <Layout2 />
      </Suspense>
      <Suspense fallback={<div>Suspense 2</div>}>
        <Layout2 withSuspense />
      </Suspense>
    </div>
  );
}

function App() {
  return <Layout />;
}

export default App;
createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>
);
@jtbandes jtbandes added the Status: Unconfirmed A potential issue that we haven't yet confirmed as a bug label Feb 6, 2025
@eps1lon
Copy link
Collaborator

eps1lon commented Feb 6, 2025

Does this work if LazyChild is created outside of render? The initializer function to lazy must be sync as well: lazy(() => ({ default: Child }))

@eps1lon eps1lon added Resolution: Needs More Information and removed Status: Unconfirmed A potential issue that we haven't yet confirmed as a bug labels Feb 6, 2025
@jtbandes
Copy link
Author

jtbandes commented Feb 6, 2025

@eps1lon thanks for the quick response. Moving the LazyChild creation out of render no longer triggers this issue. However, I am not aware of it being "against the rules" to use useMemo(() => lazy(...), []) in this way. The docs do mention that using lazy inside of render may cause state to be re-set, since the component's identity is changing:

  // 🔴 Bad: This will cause all state to be reset on re-renders
  const MarkdownPreview = lazy(() => import('./MarkdownPreview.js'));

However, one would think that wrapping with useMemo should avoid this problem? When I originally discovered this in our app, it involved a dynamic use of lazy() inside useMemo. (Actually, this worked in most cases — we had this lazy usage for years before noticing any issues. The problem was intermittent and resolved itself after some other background activity in the app stopped, so I am not sure if this is the entire root cause, but seems like probably part of it.)

Anyway, it still seems like a bug that this puts the Suspense into a broken state. From the app's perspective, the async function has finished executing and there is no state in the app code itself. If this is truly not allowed, I would expect an error message rather than a partially-working/partially-broken app.

As for your other statement,

The initializer function to lazy must be sync as well: lazy(() => ({ default: Child }))

lazy is usually used with a dynamic import() (as it was in our app) which always returns a Promise. That's why I passed an async function, to simulate using it with a Promise. In fact, the type definitions from @types/react specify that the function must return a promise:

    function lazy<T extends ComponentType<any>>(
        load: () => Promise<{ default: T }>,
    ): LazyExoticComponent<T>;

@eps1lon
Copy link
Collaborator

eps1lon commented Feb 7, 2025

However, one would think that wrapping with useMemo should avoid this problem?

useMemo is for performance optimizations not semantic guarantees. But use does require a cached Promise so useMemo is not suitable for that.

lazy is usually used with a dynamic import() (as it was in our app) which always returns a Promise. That's why I passed an async function, to simulate using it with a Promise. In fact, the type definitions from @types/react specify that the function must return a promise:

You don't need to specify an async function for that. () => import() already returns a Promise. You can specify an async function but it's redundant and might cause the chunk to be loaded later than it needs to due to additional microtasks.

@jtbandes
Copy link
Author

jtbandes commented Feb 7, 2025

@eps1lon Thanks, so to confirm, are you saying the usage of lazy() anywhere inside render is not allowed? If that's true, I think this needs to become an error rather than triggering "undefined behavior" and causing react to hang. The docs would also need to be updated to reflect this restriction.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

2 participants