react icon indicating copy to clipboard operation
react copied to clipboard

Bug: hydration is blocking for components inside Suspense boundary

Open OliverJAsh opened this issue 2 years ago • 10 comments

React version: 18.2.0

Steps To Reproduce

Link to code example: https://github.com/OliverJAsh/react-suspense-non-blocking-hydration

The app renders two Expensive components outside of Suspense and another two inside of Suspense.

Each Expensive component takes 500ms to render.

shared/Expensive.tsx:

import * as React from 'react';

export const Expensive = () => {
  const end = Date.now() + 500;
  while (Date.now() < end) {
    // do nothing
  }

  return <div>Expensive</div>;
};

shared/Nested.tsx:

import * as React from 'react';
import { Expensive } from './Expensive';

const Nested: React.FC = () => (
  <>
    <div>Suspense</div>
    <Expensive />
    <Expensive />
  </>
);

export default Nested;

shared/App.tsx:

import * as React from 'react';

import { Expensive } from './Expensive';

const Nested = React.lazy(() => import('./Nested'));

const NestedWithSuspenseAndMemo = React.memo(() => (
  <React.Suspense>
    <Nested />
  </React.Suspense>
));

export const App = ({ history }: any) => {
  React.useEffect(() => {
    history.replace({});
  }, []);

  return (
    <div>
      <div>App</div>
      <Expensive />
      <Expensive />

      <NestedWithSuspenseAndMemo />
    </div>
  );
};

client/index.tsx:

import { hydrateRoot } from 'react-dom/client';

import * as React from 'react';
import { App } from '../shared/App';

const container = document.getElementById('root')!;

class History {
  callback?: (location: {}) => void;

  listen(callback: (location: {}) => void) {
    this.callback = callback;
  }

  replace(location: {}) {
    this.callback!(location);
  }
}
const history = new History();

const Context = React.createContext<{} | null>(null);

const Router = ({ children }: { children: React.ReactNode }) => {
  const [location, setLocation] = React.useState({});

  React.useLayoutEffect(() => {
    history.listen(setLocation);
  }, []);

  return <Context.Provider value={location}>{children}</Context.Provider>;
};

hydrateRoot(
  container,
  <Router>
    <App history={history} />
  </Router>,
);

The current behavior

The hydration for the two Expensive components inside the Suspense boundary is in the form of a single task lasting 1 second:

blocking

The expected behavior

The hydration for the two Expensive components inside the Suspense boundary should be in the form of two tasks lasting 500ms each:

non-blocking

Notes

I can fix this issue by wrapping the state update in startTransition, but I don't understand why this is necessary, given that the Suspense boundary will not be receiving this update because it is memoized and has no props.

diff --git a/shared/App.tsx b/shared/App.tsx
index 2ff5b5e..9323df2 100644
--- a/shared/App.tsx
+++ b/shared/App.tsx
@@ -12,7 +12,9 @@ const NestedWithSuspenseAndMemo = React.memo(() => (
 
 export const App = ({ history }: any) => {
   React.useEffect(() => {
-    history.replace({});
+    React.startTransition(() => {
+      history.replace({});
+    });
   }, []);
 
   return (

OliverJAsh avatar Jun 05 '23 14:06 OliverJAsh

This issue has been automatically marked as stale. If this issue is still affecting you, please leave any comment (for example, "bump"), and we'll keep it open. We are sorry that we haven't been able to prioritize it yet. If you have any new additional information, please include it with your comment!

github-actions[bot] avatar Apr 09 '24 15:04 github-actions[bot]

Bump

OliverJAsh avatar Apr 09 '24 17:04 OliverJAsh

render and hydrateRoot are sync by default. Originally the plan was to do concurrent by default but to ease migration to React 18 it was changed to sync by default. See https://github.com/reactwg/react-18/discussions/64 for more details about this decision.

To opt-into concurrent rendering, you can wrap your updates in startTransition which splits the updates into different tasks as you already noted.

eps1lon avatar Apr 12 '24 08:04 eps1lon

@eps1lon Thanks for the reply.

render and hydrateRoot are sync by default.

I believe hydration is async for components wrapped in Suspense, so unfortunately I don't think that explains the issue I'm seeing here. The two Expensive components inside Suspense are hydrating synchronously but I believe they should be hydrating asynchronously.

you can wrap your updates in startTransition which splits the updates into different tasks as you already noted.

I am aware of this, however as noted above, I don't understand why this is necessary in this particular scenario:

I can fix this issue by wrapping the state update in startTransition, but I don't understand why this is necessary, given that the Suspense boundary will not be receiving this update because it is memoized and has no props.

OliverJAsh avatar Apr 19 '24 10:04 OliverJAsh

I am aware of this, however as noted above, I don't understand why this is necessary in this particular scenario:

Because hydration, just like render, is sync by default. Where does the believe come from that hydration is "async"?

eps1lon avatar Apr 19 '24 11:04 eps1lon

Where does the believe come from that hydration is "async"?

To clarify, my understanding is that hydration is only async for components wrapped in Suspense (as is the case in my test above). References:

  • https://twitter.com/dan_abramov/status/1529694045479526400
  • https://3perf.com/talks/react-concurrency/#suspense
  • https://github.com/reactwg/react-18/discussions/37#:~:text=are%20being%20hydrated%3A-,In%20React%2018%2C%20hydrating%20content%20inside%20Suspense%20boundaries%20happens%20with%20tiny%20gaps%20in%20which%20the%20browser%20can%20handle%20events.,-Thanks%20to%20this

OliverJAsh avatar Apr 19 '24 11:04 OliverJAsh

To add to that e.g. nextjs wraps hydration in startTransition, which makes hydration interruptible too - so it can certainly be "asynchronous" (async in terms of avoiding one big synchronous task; not real asynchronous execution flow) without suspense boundaries.

kurtextrem avatar Apr 19 '24 12:04 kurtextrem

The Suspense boundaries are hydrated in different tasks, yes. But each hydration task itself is sync. That's what the linked Tweet says. Async is unrelated to what startTransition does. startTransition enables concurrent rendering.

eps1lon avatar Apr 19 '24 12:04 eps1lon

each hydration task itself is sync. That's what the linked Tweet says.

Maybe I'm being slow but I don't think that's the case. From the tweet:

Remaining hydration is non-blocking and happens in chunks.

Also see this slide: https://3perf.com/talks/react-concurrency/#slide-27-10.

(Side note: it's a shame that this behaviour isn't mentioned in the React docs.)

This is also the behaviour I've observed outside of the issue described above.

OliverJAsh avatar Apr 19 '24 12:04 OliverJAsh

Async is unrelated to what startTransition does. startTransition enables concurrent rendering.

Agreed, that's why I set it in quotes. I've updated it to clarify that I meant it avoids one big synchronous hydration task.

kurtextrem avatar Apr 19 '24 12:04 kurtextrem

This issue has been automatically marked as stale. If this issue is still affecting you, please leave any comment (for example, "bump"), and we'll keep it open. We are sorry that we haven't been able to prioritize it yet. If you have any new additional information, please include it with your comment!

github-actions[bot] avatar Jul 18 '24 13:07 github-actions[bot]

This is still affecting us.

OliverJAsh avatar Jul 18 '24 13:07 OliverJAsh