facebook / react

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

[React 19] When multiple components call `use` inside a `Suspense`, it crosses the boundary of `Suspense` and stops rendering of other `Suspense` as well #29905

Open yatsuna827 opened 3 weeks ago

yatsuna827 commented 3 weeks ago

Summary

import { Suspense, use, useState } from "react";

const f = async (wait) => {
  await new Promise((resolve) => setTimeout(resolve, wait));
  return new Date();
};

const App = () => {
  const [open, setOpen] = useState(false);

  return (
    <>
      <Expected />
      <Unexpected />
    </>
  );
};

const Expected = () => {
  const [open, setOpen] = useState(false);

  return (
    <>
      <div>πŸ‘</div>
      <button onClick={() => setOpen(!open)}>{open ? "close" : "open"}</button>
      {open && <List wait={[1000, 2000, 3000, 4000, 5000]} />}
    </>
  );
};

const Unexpected = () => {
  const [open, setOpen] = useState(false);

  return (
    <>
      <div>πŸ€”</div>
      <button onClick={() => setOpen(!open)}>{open ? "close" : "open"}</button>
      {open && <List wait={[1000, 2000, 3000, 3000, 5000]} />}
    </>
  );
};

const List = ({ wait }) => {
  const [t1] = useState(f(wait[0]));
  const [t2] = useState(f(wait[1]));
  const [t3] = useState(f(wait[2]));
  const [t4] = useState(f(wait[3]));
  const [t5] = useState(f(wait[4]));

  return (
    <>
      <Suspense fallback={<div>Loading No.1 ...</div>}>
        <Item time={t1} />
      </Suspense>
      <Suspense fallback={<div>Loading No.2 ...</div>}>
        <Item time={t2} />
      </Suspense>
      <Suspense fallback={<div>Loading No.3 ...</div>}>
        <Item time={t3} />
      </Suspense>
      <Suspense fallback={<div>Loading No.4 & No.5 ...</div>}>
        <Item time={t4} />
        <Item time={t5} />
      </Suspense>
    </>
  );
};

const Item = ({ time }) => {
  const t = use(time);

  console.log("render item");

  return <div>{t.toISOString()}</div>;
};

export default App;

https://codesandbox.io/p/sandbox/use-and-suspense-hlcjk6

The two components <Expected>πŸ‘ and <Unexpected>πŸ€” are expected to behave in the same way. That is, No.1 to No.3 should be displayed one second apart, and No.4 and No.5 should be displayed two seconds after No.3 is displayed.

There is a difference in the waiting time for No.4 between <Expected> and <Unexpected>, being 4 seconds or 3 seconds, but since they are wrapped together in <Suspense> with No.5, they should be throttled and displayed after 5 seconds.

However, in <Unexpected>, not only that, but No.3, which should belong to a different <Suspense> scope, is also displayed after 5 seconds. This seems to break the behavior of <Suspense>.


Please note that this is a different issue from the one being discussed in https://github.com/facebook/react/pull/26380. I am issuing a Promise outside of Suspense and passing it as props.

yatsuna827 commented 3 weeks ago

Furthermore, I have investigated a few more examples. When using a class like Recoil's Loadable instead of the use API, this issue does not occur.

And by wrapping Loadable and use in the same Suspense, the strange behavior of use becomes clear. When multiple Promises are thrown within a single Suspense, it seems that all Suspense boundaries are halted until the Promise thrown by use is resolved. This does not seem like the intended behavior to me.

https://codesandbox.io/p/sandbox/use-and-suspense-forked-skjk48

eps1lon commented 2 weeks ago

2 Suspense boundaries is sufficient to reproduce this behavior: https://codesandbox.io/p/sandbox/crazy-browser-88t26y?file=%2Fsrc%2Findex.js%3A7%2C1

Flagged this internally to check if it's intended or not.