react
react copied to clipboard
Bug: hydration is blocking for components inside Suspense boundary
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:
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:
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 (
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!
Bump
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 Thanks for the reply.
renderandhydrateRootare 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
startTransitionwhich 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 theSuspenseboundary will not be receiving this update because it is memoized and has no props.
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"?
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
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.
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.
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.
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.
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!
This is still affecting us.