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

useTransition should optionally also consider nested suspense boundaries #25082

Open
reneeichhorn opened this issue Aug 11, 2022 · 10 comments
Open

Comments

@reneeichhorn
Copy link

React version: 18.2.0

Background

We are working on a web framework that can be used to build highly personalized and data-driven applications. A page that is rendered by the framework consists of a tree of composable, declarative and self-sufficient widgets. A widget can be seen as a mini-application that specifies its own data dependencies and behavior. Since the widgets are self-sufficient, it gives us lots of flexibility to choose which widgets should be shown on page and how they are arranged. It allows us to have personalized pages or layouts per user depending on certain criteria, e.g. their browsing history. The way widgets are chosen to be displayed on the page can be seen as a dynamic, nested routing mechanism.

Here is an example of an outfit view that can be used in an ecommerce application:
showing the structure of an example page with above described concept

We are currently migrating the framework to React 18 with the new Suspense SSR and streaming architecture. The main idea is that we wrap each widget in its own suspense boundary to handle the loading of their code and data as soon as the framework decides to display it on the page. For initial requests it works well, but we face a problem with client-side updates that lead to new sub-tree of widgets.

Steps to Reproduce

Here is a link to a code sandbox that contains a minimal code example to reproduce the problem with “new” suspense boundaries and useTransition: https://codesandbox.io/s/romantic-haslett-psc4f4?file=/src/App.js

Current Behavior

useTransition waits for the first Suspense boundary to be ready and then re-renders the DOM although nested Suspense boundaries are still in their fallback state. It is similar to the problem that has been reported in this issue.

In our code example, you can see a big loading overlay after clicking the button to fetch a new collection of widgets. Fetching a new collection of widgets means the following: determine which widgets should be displayed on the page and start loading their code and data. In a real application, this would be data-driven. In the code example, it has been simplified to randomly select a Square or Circle widget and lazy-load them via React.lazy().

Expected Behavior

You have suggested two approaches to overcome the issue which do not work well for us.

Why is it a problem that moving to a page shows the spinners? Is it because there are many of them?

Yes, in general there are many widgets and it might change to completely new page layouts. But the type of all widgets could also be the same as before the state update. Then it would be odd if the user first sees a big loading overlay for the whole page and when the new tree of widgets has been determined (which is the same as before, just the data changed), they will see individual loading spinners for each “new” widget.

In that case, move the Suspense boundaries from individual dashboard items widgets to wrap all of them.

This seems to be impossible for us. A page consists of a tree of widgets. A widget can contain other widgets and each widget can trigger a state update. If a widget triggers a state update, it normally requests some data from backend services and that data determines which child widgets should then be displayed. These child widgets could have their own data dependencies and could again render other widgets, and so on. The whole code is very generic.

Our proposal is that useTransition should have an option to wait for the entire subtree to be ready, before re-rendering the component. In our use case, the widget of which the state should be updated, would use startTransition with some new flag that will additionally wait for the whole widget’s subtree to complete as well.

@reneeichhorn reneeichhorn added the Status: Unconfirmed A potential issue that we haven't yet confirmed as a bug label Aug 11, 2022
@facebook facebook deleted a comment Aug 24, 2022
@ntucker

This comment was marked as off-topic.

@gaearon
Copy link
Collaborator

gaearon commented Feb 1, 2023

@reneeichhorn Thanks for a well-written issue, and apologies for no response.

I've read through it and I don't understand what behavior you want. Can you please describe what you want to happen when the button is pressed?

@gaearon gaearon added Type: Discussion Component: Suspense and removed Status: Unconfirmed A potential issue that we haven't yet confirmed as a bug labels Feb 1, 2023
@reneeichhorn
Copy link
Author

reneeichhorn commented Feb 2, 2023

I've read through it and I don't understand what behavior you want. Can you please describe what you want to happen when the button is pressed?

My expectations from a user perspective, when clicking the "Fetch new collection" button, is that when the loading "spinner" disappears the UI is fully rendered and no new UI lazily pops in.

So as a developer, since I'm using startTransition I'd expect that isPending stays true until the whole subtree resolved an no child (or child-child) components are suspended.

Perhaps the example is a bit too constructed but think of a ecommerce catalog page where a list of products are shown. When applying some filters (for example a brand filter) I don't want to present each product in the list individually but instead have one loading spinner that disappears once the subtree is done.

From my current understanding this does not work right now because as seen in the example, new Suspense boundaries are added during the transition and React won't consider these.

@gaearon
Copy link
Collaborator

gaearon commented Feb 2, 2023

Perhaps the example is a bit too constructed but think of a ecommerce catalog page where a list of products are shown. When applying some filters (for example a brand filter) I don't want to present each product in the list individually but instead have one loading spinner that disappears once the subtree is done.

The canonical solution to this is to not have individual Suspense boundaries around each product. Why do you need these, in this case? Why is it okay for things to "pop in" individually during the initial load, but not during updates?

@reneeichhorn
Copy link
Author

reneeichhorn commented Feb 2, 2023

The reason we want to have Suspense boundaries around each product is because in our architecture the Product component only retrieves its UUID as a prop and then fetches its required data from the API. Now with Suspense the idea was to fetch this data inside the component and suspend it as long as it is fetching. The nice benefit we get from this is that when do this with SSR. The browser gets streamed the HTML of product cards as soon as they are ready. So instead of letting the browser idle until we fetched all required data we stream it ASAP and let the browser do parsing, drawing etc. (Ideally to avoid CLS in a top-bottom order)
Ultimately causing the page to be streamed like this: (Heavily slowed down for demo sake of course)

zalando-loading-ssr


While this works great already on the server this is not necessarily the behavior we want to have on the Client but at the same time we are "forced" to have the same Suspense boundaries set as the server to avoid hydration mismatches.

From an UX perspective, this is how I'd imagine the behavior on the client when doing a setState causing content reload.

zalando-loading-client

@gaearon
Copy link
Collaborator

gaearon commented Feb 2, 2023

What is the Suspense fallback for a product item? Is it an empty hole? A glimmer square?

@reneeichhorn
Copy link
Author

For us it is really just an empty hole although I'd also think a glimmering placeholder would look even better but that's not what we want to have for the UI as of now.

@kurtextrem
Copy link

@reneeichhorn just out of curiosity, how did you solve this / workaround this?

@reneeichhorn
Copy link
Author

reneeichhorn commented Sep 23, 2024

@reneeichhorn just out of curiosity, how did you solve this / workaround this?

@kurtextrem
Ultimately we've changed our approach entirely to get around this limitation because the "Fetch-as-you-render" methodology caused other issues as well.

Instead we are doing "render-as-you-fetch", whereas the fetching part is controlled outside of React, and with that we built a custom logic that while all components within a subtree are being updated, collects state changes and puts them into a queue and only once the whole subtree is done, the "state change" queue is being flushed and that flush is what we wrap in React.startTransition

You can read a bit more about it in our blog post we did some time ago:
https://engineering.zalando.com/posts/2023/07/rendering-engine-tales-road-to-concurrent-react.html
See the "Design challenges with Concurrent Rendering" section.

@kurtextrem
Copy link

@reneeichhorn Appreciate it, thank you!

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

4 participants