Replies: 4 comments 17 replies
-
Timing of refs passed down from outside the Suspense boundaryThere's another difference that's really a consequence of the same render-commit correspondence issue: the timing of refs passed in from a parent. const refPassedFromParent = useRef(null)
<Suspense fallback={<Loading />}>
<ComponentThatSuspends />
<button ref={refPassedFromParent} {...buttonProps} />
</Suspense> In Legacy Suspense, In Concurrent Suspense, That means that any code in a parent that accesses that same ref will observe a change in the timing of when the ref resolves. We think it's pretty unlikely that this will cause a difference in behavior, and in fact the new behavior is more consistent with the rest of React's rendering model. But it's worth calling out as something that could potentially affect existing code. |
Beta Was this translation helpful? Give feedback.
-
This is a great post, @acdlite! I appreciate how you jumped right to giving these things names: I'm going to play my "no stupid questions" card. I find myself a little lost with Reading your post, I realized that I just don't think about the discrete render and commit phases (post-Hooks announcement). Is there a doc that clarifies the finer points of render and commit phases for Hooks? |
Beta Was this translation helpful? Give feedback.
-
Awesome post @acdlite 🎉 .
can we add CSB example for the two cases comparing legacy and concurrent suspense? |
Beta Was this translation helpful? Give feedback.
-
This is a great post and I really appreciate the code examples since they do help clarify the differences.
Does this mean that Legacy Suspense is completely removed in React 18 or is it the case where both exist and Concurrent Suspense only applies to subtrees that are opted into concurrent rendering?
Have you encountered any scenarios where this change in behavior did cause an issue? We have quite a few usages of |
Beta Was this translation helpful? Give feedback.
-
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:
The difference is how a suspended components affects the rendering behavior of its siblings:
===== fetched posts =====
.)createRoot
demo sandbox showing effects delayed until the content is ready. (Notice the effect logs appear after logs like===== fetched posts =====
.)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 whencomponentDidMount/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.Beta Was this translation helpful? Give feedback.
All reactions