Behavioral changes to Suspense in React 18 · reactwg/react-18 · Discussion #7 (original) (raw)
Overview
We added basic support for Suspense in React 16.x. But it wasn’t full support for Suspense — it doesn’t do all the things we’ve shown off in our demos, like delayed transitions (i.e. waiting for the data to resolve before proceeding with a state transitions), or placeholder throttling (reducing UI thrash by throttling the appearance of nested, successive placeholders), or SuspenseList (coordinating the appearance of a list or grid of components, like by streaming them in order). We've been referring to the version of Suspense that exists in 16 and 17 as Legacy Suspense.
Our full suite of Suspense functionality depends on Concurrent React, which we are adding in React 18. That means Suspense works slightly differently in React 18 than in previous versions. Technically, this is a breaking change, but as with automatic update batching, we expect the impact on existing code to be relatively minimal, and that it won’t impose a significant migration burden on authors migrating their apps. (If this ends up not being the case during pre-release testing, we have a backup strategy that we can discuss in a separate thread.)
This post discusses the behavioral differences — the parts that affect the compatibility of user component code.
Note on terminology
The feature itself is still called just "Suspense".
The distinction between "Legacy Suspense" and "Concurrent Suspense" only matters in the context of migration. Since we expect most people to not have any significant hurdles upgrading, you won't see these terms outside of the migration discussion.
Siblings of a suspended component may be interrupted
Simplified explanation
In both Legacy Suspense and Concurrent Suspense, the basic user experience is the same. In the following example, until the data in ComponentThatSuspends resolves, React will display the Loading component in its place:
<Suspense fallback={}>
The difference is how a suspended components affects the rendering behavior of its siblings:
- In Legacy Suspense, the Sibling component is immediately mounted to the DOM and its effects/lifecycles are fired. Then we hide it.
- 🟡 React 17 demo sandbox showing all the effects firing too early. (Notice the effect logs appear before logs like
===== fetched posts =====.)
- 🟡 React 17 demo sandbox showing all the effects firing too early. (Notice the effect logs appear before logs like
- In Concurrent Suspense, the Sibling component is not mounted to the DOM. Its effects/lifecycles are also NOT fired until ComponentThatSuspends resolves, too. This fixes some long-standing issues affecting component libraries.
- ✅ React 18 with createRoot demo sandbox showing effects delayed until the content is ready. (Notice the effect logs appear after logs like
===== fetched posts =====.)
- ✅ React 18 with createRoot demo sandbox showing effects delayed until the content is ready. (Notice the effect logs appear after logs like
Detailed explanation
In previous versions of React, there was an implied guarantee that a component that starts rendering will always finish rendering. For example, when rendering a class component, there's 1:1 correspondence between when the render method is called and when componentDidMount/Update is called. Most people don't really think about this guarantee, or intentionally rely on it, but it's possible to accidentally rely on it without realizing.
You can see how this is important in the context of a feature like Suspense, whose purpose to delay the rendering of a subtree until all the data in the tree has resolved. If one component in the tree isn't ready to commit yet, what do we do about its siblings, some of which may have already started rendering? (For example, if the third component in a list of items suspends, the render method of the first two items as already been called.)
When we first introduced Legacy Suspense, we found a way to maintain the 1:1 render-commit correspondence with a clever trick: we would skip over the suspended child, proceed rendering the siblings, and commit as much of the DOM tree as we can. This means the DOM is an inconsistent state, but we can get away with this because we're going to replace it with a fallback UI, anyway. Before the browser is allowed to paint, we show the fallback UI and hide everything inside the Suspense boundary with display: hidden.
With this trick, the sibling's rendering behavior is unaffected, but from the user's perspective they don't see any inconsistency: they just see a placeholder.
Legacy Suspense is a bit weird, but it was a good compromise solution for introducing the basic Suspense functionality in a backwards compatible way.
In Concurrent Suspense, what we do instead is interrupt the siblings and prevent them from committing. we wait to commit everything inside the Suspense boundary — the suspended component and all its siblings — until the suspended data has resolved. Then we commit the whole tree simultaneously in a single, consistent batch. This fits much better with the rest of our rendering a model, both in terms of implementation complexity and in terms of the the features we can build on top of this behavior. And it’s arguably a more predictable behavior from the developer’s perspective, once you adopt the constraint that side effects can’t be in render (which was already discouraged).
But it does require that your code is resilient to being interrupted. However, this is the same requirement that time slicing via startTransition introduces. Usually, the solution involves moving side effects and mutations from the render phase into the commit phase. You can use Strict Mode to surface these types of bugs during development.