facebook / react

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

Suspense API forces coupling of page-load sequence with runtime updates #26091

Open ghbakhtiari opened 1 year ago

ghbakhtiari commented 1 year ago

(the prelude to this issue may be a bit abstract, but please bear with me!)

Problem statement

As it has usually been the case in React and already stated in its docs:

When we [React] design APIs, we try to hide implementation details from developers. As a React developer, you focus on what you want the user experience to look like, and React handles how to deliver that experience...

But I think the new Suspense API -with all of its great features- contradicts this mindset to some degree. Because looking at the library from the “usage” perspective, Suspense API enforces coupling of independent features together. Features that although are related from a technical standpoint, in reality can be fundamentally different.

But the main issue I’m referring to is about the other Suspense features that basically seem to enable a “federated-mode” (for the lack of a better word) that makes each suspended component act independently in the tree. The problem is, they are coupling “SSR and page-load sequence” with “client-side runtime” and forcing product decisions on developers. Because:

Working example

I think this issue is a great practical use-case of the problem of coupling behaviors on "SSR/page-load sequence" and "client-side changes" even with the use of the useTransition api, which also includes a working codeSandbox example.

Suggested solution

I’m not sure what the "best" solution would be (e.g. to have a separate component other than Suspense, or change the useTransition behavior as suggested in the previously mentioned issue or…), but a solution that can also work is:

Adding two new props to the Suspense component like:

(Depending on the real life usage, they could instead be functions passed to the Suspended component as well, so it can change the values internally during runtime) Also a component that has both hoistSuspenseHandling and handleChildrenSuspense as true, could add its children’s suspense conditions to its own and pass them all up to the parent.

With this, a developer can differentiate between SSR/page-load sequence and client side updates/changes (or even more different scenarios) and handle components’ fallback/loading states in different places on the tree.

gaearon commented 1 year ago

On SSR, a developer may want to use partial streaming and selective/async hydration to provide performance boost for the initial page-load sequence. For example, if an end user initially opens a page that includes the details of an article scattered among multiple nested components, it makes sense to stream/render/hydrate different components of the article’s details (photos, title/description, ratings, comments, etc.) separately and as soon as possible (with some optional priorities).

We're planning to add a lower-level feature that would let you "chunk up" hydration without associating a loading state with it. The main reason it can't be automatic everywhere is that this would break refs — it's not expected for them to randomly be null. So the points do need to be explicit.

For example, code-splitting is basically a separate subject from the other features. Because a developer may need code-splitting (for bundle size optimizations), but it doesn’t necessarily mean they want to render the lazy-loaded component in a delayed manner in the initial html coming from the SSR stream.

If the component does not suspend on the server, I believe it won't be delayed in the stream. So I think you should be able to make it lazy conditionally depending on whether you're bundling for the server or for the client. I'm not sure what the idiomatic way to write this is, though.

They also may not want to delay that component’s hydration after other (non-suspense) parts are hydrated either.

Why not, if its code is likely to still be loading? Also, we'd prioritize hydrating it regardless if there are any events inside that tree, so what is the downside?

So for example when the user clicks to show a similar article and the page has to update on the client side to show the data of this different article, the developer should have the choice to make the page’s content (photos, title/description, ratings, comments, etc.) update together in a single graceful step - or batch some of the nested components’ loadings together (regardless of whether nested suspense boundaries were used). As opposed to each component getting updated independently, showing their own fallbacks independently, and rendering the new content independently while another component is still showing the old content or a fallback. Basically, how/where to show fallback/loading states is a product/UX decision and the API shouldn’t force an approach.

I agree with you here. Suspense boundaries should be placed only where you actually want the user to experience the loading states. Where is the contradiction?

it should still be possible to opt into interruptible/concurrent rendering on client side during runtime changes (after page-load) which allows pausing a rendering sequence and prioritizing user interactions, to benefit from the improvements to the user experience and the perceived performance.

It is possible — either by marking updates as interruptible with useTransition, or by making a part of UI as deferred with useDeferredValue. What other use cases are you thinking about?

ghbakhtiari commented 1 year ago

Thanks a lot for the quick response :)

We're planning to add a lower-level feature that would let you "chunk up" hydration without associating a loading state with it. The main reason it can't be automatic everywhere is that this would break refs — it's not expected for them to randomly be null. So the points do need to be explicit.

&

I agree with you here. Suspense boundaries should be placed only where you actually want the user to experience the loading states. Where is the contradiction?

To clarify, the scenario I am referring to (from a user experience perspective), which I believe is the same as the one in the linked issue, is this:

Now to enable the first point (page-load), the current Suspense API provides a very convenient solution. I need to wrap each of the components that are allowed to be streamed and hydrated separately in a separate Suspense boundary and react also even takes care of the prioritization of them in case the user interacts with a component. This is what I also meant originally by this part:

  • On SSR, a developer may want to use partial streaming and selective/async hydration to provide performance boost for the initial page-load sequence. For example, if an end user initially opens a page that includes the details of an article scattered among multiple nested components, it makes sense to stream/render/hydrate different components of the article’s details (photos, title/description, ratings, comments, etc.) separately and as soon as possible (with some optional priorities).

But to enable the second point (runtime), currently we don't have a working way either with only Suspense or in combination with useTransition - as is elaborated in the linked issue. In other words, because we had the use-case for the independent components during the page-load sequence, we are now stuck with the same behaviour during runtime. This was what I also meant originally by this part:

  • So for example when the user clicks to show a similar article and the page has to update on the client side to show the data of this different article, the developer should have the choice to make the page’s content (photos, title/description, ratings, comments, etc.) update together in a single graceful step - or batch some of the nested components’ loadings together (regardless of whether nested suspense boundaries were used). As opposed to each component getting updated independently, showing their own fallbacks independently, and rendering the new content independently while another component is still showing the old content or a fallback. Basically, how/where to show fallback/loading states is a product/UX decision and the API shouldn’t force an approach.

So I believe a solution like introducing those new props (hoistSuspenseHandling and handleChildrenSuspense) would give developers the ultimate freedom to manage how to reflect the waiting situation of different (nested) Suspense boundaries onto UI. Meaning they can still decide where in the component tree they want to show this fallback/loading during runtime changes, regardless of what they needed during page-load sequence. (On SSR and initial hydration I could set hoistSuspenseHandling to false on the child Suspense boundary, and on another runtime update I could change it to true so its parents handle it)


Regarding code-splitting

If the component does not suspend on the server, I believe it won't be delayed in the stream. So I think you should be able to make it lazy conditionally depending on whether you're bundling for the server or for the client. I'm not sure what the idiomatic way to write this is, though.

&

Why not, if its code is likely to still be loading? Also, we'd prioritize hydrating it regardless if there are any events inside that tree, so what is the downside?

As you explain elaborately in the New Suspense SSR Architecture post, the parts of a page that are not wrapped in suspense, will be streamed and later hydrated in a single pass, and from my understanding, these parts may also have backend data dependencies, which in that case on SSR we wait until the data is ready and then start responding for example by streaming. But imagine I have a component that is an important part of a page and needs to be treated like the above content: SSRed, streamed and then hydrated in a single pass. But because the code for this module is very big, and this page is not one of the main pages of my application, I want to use code-splitting to only include the JS code of this component in this specific page - and not in the bundles of other pages. So now:


Regarding the possibility to opt into interruptible/concurrent rendering

It is possible — either by marking updates as interruptible with useTransition, or by making a part of UI as deferred with useDeferredValue. What other use cases are you thinking about?

I see. Then I think my only question is why couldn’t it be enabled as the default behavior for regular setStates and other updates, and instead we need to wrap all of those in useTransitions? Because it seems to me that the updates that absolutely need to be sync and uninterruptible are very rare, and it would be easier to enable the concurrent mode on a top level place and just have an API for exceptions.

gaearon commented 1 year ago

If the update worked just like the page load (the entire container gets replaced with a spinner, then nested ones begin to reveal as they're ready), would that be satisfactory? If not, can you help me understand why it's a bad experience for updates but an okay experience for first load? Presumably if you're worried about layout jumps, they'd also be a problem for the first load.

ghbakhtiari commented 1 year ago

I think ultimately the choice should be there for the developer (or UX designer) to decide what should happen in each case. And when react forces the page-load and runtime changes to behave in the same way, there should be a reason for that, not the other way around.

Because one could argue that when user goes from a state of blank or nothing to a page, it makes sense to see the content streamed, rendered and hydrated little by little. But when a full-page change is supposed to happen (excluding header navbar for example), then the better user experience would be to let the user know for sure when the change starts and ends, to confidently know that all these bits and pieces of the page belong to the new state, and none of them are old/irrelevant/incorrect.

(It's not about the layout jumps, as I agree that would be an issue on page-load as well - unless you handle the fallbacks' sizes correctly which would then fix both page-load and runtime)

gaearon commented 1 year ago

Sorry, I do want to refocus the discussion on concrete UX cases rather than on philosophy. E.g. in a parallel discussion, https://github.com/facebook/react/issues/25082#issuecomment-1413994002 was very helpful because it shows a concrete case. I understand you have philosophical opinions about this too, but I'm asking to set them aside for a bit, and entertain the principle that maybe we can figure out how to do everything right automatically. The context behind this is that we've had more granular control before, and we've made a decision to remove it / not expose it. So far I believe our approach is right, but I'd need to see very concrete UX arguments for why it's bad rather than philosophical arguments about control.

gaearon commented 1 year ago

So, to clarify, it helps me to see (a) concrete cases where UX "feels bad" — the closer to the real example, the better — and (b) concrete examples of how you want it to work instead.

ghbakhtiari commented 1 year ago

So, to clarify, it helps me to see (a) concrete cases where UX "feels bad" — the closer to the real example, the better — and (b) concrete examples of how you want it to work instead.

I think the comment you referenced https://github.com/facebook/react/issues/25082#issuecomment-1413994002 is a good example for the problem here as well. It especially includes a good visualisation for the (b) concrete examples of how you want it to work.

I'll try to come up with a visualisation for (a) concrete cases where UX "feels bad", which is when a client-side change happens and it's not clear when the change starts and ends and whether pieces of the page belong to the new state or the old one. But I think it would be somewhat similar to the initial example in the same issue: https://codesandbox.io/s/romantic-haslett-psc4f4?file=/src/App.js

I understand you have philosophical opinions about this too, but I'm asking to set them aside for a bit, and entertain the principle that maybe we can figure out how to do everything right automatically. The context behind this is that we've had more granular control before, and we've made a decision to remove it / not expose it. So far I believe our approach is right, but I'd need to see very concrete UX arguments for why it's bad rather than philosophical arguments about control.

Well I hope the philosophical type of issues are also accepted here since I'm also very interested in that part :) Because -as also mentioned in React docs- different frameworks are expected to build features on top of React and its concurrent features. And in these frameworks I think it makes sense (even is expected) to be somewhat opinionated and to offer limited approaches and APIs, and maybe even lean towards some user experiences.

But the same type of limitations would tilt React to be biased towards a specific set of user experiences and even frameworks, and impairs its neutrality.

Even if a specific user experience sounds "good" or "bad" here to us, it doesn't necessarily mean it'll be the same case in other products.

ghbakhtiari commented 1 year ago

I tried a bit more for the visualisation for (a) concrete cases where UX "feels bad" but in the end the same example in the linked issue is quite the same. So I refer to that example here as well.

ghbakhtiari commented 1 year ago

For what it's worth, regarding this part of your comment

So far I believe our approach is right, but I'd need to see very concrete UX arguments for why it's bad rather than philosophical arguments about control.

My point is that the mentioned examples are simply different UX approaches that exist. And that should be enough of an argument here. That React should allow them.

shubhankarval commented 1 year ago

(this is my response to the original post.)

The Problem

React's philosophy has always been to abstract implementation details away from developers and allow them to focus on delivering user experiences. However, the introduction of the Suspense API raises concerns about the coupling of independent features, which can be problematic for developers.

The issue at hand is the merging of server-side rendering (SSR) and client-side runtime behaviors, and how this forces certain product decisions on developers.

Example

Consider a scenario where we want to optimize the initial page load by streaming and hydrating components separately during SSR. This can significantly improve performance for the user. However, when dealing with client-side updates, developers might prefer a different approach, such as updating components together in a single step.

The Proposed Solution

A potential solution to this problem is to introduce two new props to the Suspense component:

  1. hoistSuspenseHandling: When set to true, this prop skips handling Suspense status within the component's own suspense boundary. Instead, it passes the responsibility to its parent for merging with the parent's Suspense handling.

  2. handleChildrenSuspense: This prop ensures that the parent can handle its children's suspense. If set to true, it allows the merging of suspense conditions from children with its own suspense handling.

Alternatively, these could be functions passed to Suspended components, allowing for runtime changes. A component with both hoistSuspenseHandling and handleChildrenSuspense as true would aggregate its children's suspense conditions and pass them up to the parent.

Implications and Benefits

Implementing this solution would provide developers with more control over how they handle Suspense during SSR and client-side updates. It allows for a clearer separation of concerns and avoids forcing specific product decisions. Developers can tailor their Suspense handling based on different scenarios, improving flexibility and maintainability.

By addressing the coupling issue between SSR and client-side behavior, React can continue to evolve while maintaining its commitment to providing a great developer experience.