Suspense hydration error on server navigation
Closed this issue ยท 10 comments
What version of Remix are you using?
1.14.0
Are all your remix dependencies & dev-dependencies using the same version?
- Yes
Steps to Reproduce
Run https://github.com/jacobparis/remix-defer-streaming-progress. A gitpod link is provided so you don't have to clone and set it up yourself.
You can submit the form and see the expected behavour working.
Then change the <Form>
on routes/index.tsx to use reloadDocument
and try again to see the bug.
<Form method="post" reloadDocument>
<button type="submit"> Start long-running process </button>
</Form>
Expected Behavior
If I navigate to a page that uses a deferred loader with a Remix <Link>
component or a <Form>
that does not reload the document, it works. Any client side navigation to the target page works as expected. The page loads to show the suspense fallback and React will happily re-render the page as many times as needed up until the deferred promise resolves, at which point it hydrates the suspense boundary and continues on with its day.
Screen.Recording.2023-03-09.at.4.36.04.PM.mov
Actual Behavior
When a page is suspended, waiting for a deferred promise to resolve, and any state update causes a re-render, React throws an error and switches to client rendering.
react-dom.development.js:20702 Uncaught Error: This Suspense boundary received an update before it finished hydrating. This caused the boundary to switch to client rendering. The usual way to fix this is to wrap the original update in startTransition.
This only happens if I navigate to the page with a full browser navigation, either by refreshing the page, entering it directly in the browser address bar, following an <a>
link, using a regular HTML <form>
, or in this case, using a Remix <Form>
component with reloadDocument
set.
Screen.Recording.2023-03-09.at.4.36.34.PM.mov
EDIT: This is my bad, this diff does work as expected. My organization manages our browsers and there is an extension I could not disable which I believe is the culprit. On my personal machine, it works as expected.
I've been having the same issue. I would expect a simple diff from a new project like this to work. But it does not, the log gets printed but the awaited content never gets rendered
diff --git a/app/routes/index.tsx b/app/routes/index.tsx
index cbca612..8d07779 100644
--- a/app/routes/index.tsx
+++ b/app/routes/index.tsx
@@ -1,4 +1,17 @@
+import { Await, useLoaderData } from "@remix-run/react";
+import { defer } from "@remix-run/router";
+import { Suspense } from "react";
+
+export const loader = () => {
+ console.log('In loader body')
+ const myPromise = new Promise((resolve) => {setTimeout(() => resolve('Resolved'), 2000)})
+ return defer({
+ myPromise,
+ })
+}
+
export default function Index() {
+ const data = useLoaderData();
return (
<div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.4" }}>
<h1>Welcome to Remix</h1>
@@ -27,6 +40,13 @@ export default function Index() {
</a>
</li>
</ul>
+ <Suspense fallback="Loading...">
+ <Await resolve={data.myPromise}>
+ {
+ (myPromise) => { console.log('in func body'); return <div>Rendered div !</div>}
+ }
+ </Await>
+ </Suspense>
</div>
We are struggling with the same issue while designing a dynamically populated form field where we are deferring the response of the form-field values, and while waiting for the response, showing a loading spinner. The form-field is inside a collapsible section and that is where the problem starts - while the content of the collapsible section suspends and shows a loading spinner (waiting for the form-field values), if we expand the section (thereby causing a state change), we get the error -
Uncaught Error: This Suspense boundary received an update before it finished hydrating. This caused the boundary to switch to client rendering.
We created a trimmed down example just to reproduce and share the issue here. Below we have a collapsible item that displays the deferred response wrapped with <Await />
and <Suspense />
. We toggle the display of the awaited wrapped element using a button and as you can see - while the component suspends - as soon as the button is clicked we get the error.
Below is a screen recording of the behaviour -
remix-defer-suspense-hydration-error.mov
And here is the full code for the above example (unfortunately a codesandbox is difficult to create for this) -
export async function loader() {
return defer({
deferredResponse: new Promise((resolve) => setTimeout(resolve, 2000)).then(
() => "hey from slow thing"
),
});
}
export default function App() {
const { deferredResponse } = useLoaderData<typeof loader>();
return (
<div>
<pre>"hey from instant thing"</pre>
<AwaitedItem deferredResponse={deferredResponse} />
</div>
);
}
function AwaitedItem({
deferredResponse,
}: {
deferredResponse: Promise<string>;
}) {
const [showAwaitedItem, setShowAwaitedItem] = useState(false);
return (
<div className="AwaitedItem">
<button
onClick={() =>
setShowAwaitedItem((prevShowAwaitedItem) => !prevShowAwaitedItem)
}
>
{showAwaitedItem ? "hide" : "show"}
</button>
<div style={{ visibility: showAwaitedItem ? "visible" : "hidden" }}>
<Suspense fallback="loading slow thing...">
<Await resolve={deferredResponse}>
{(data) => <pre>{JSON.stringify(data)}</pre>}
</Await>
</Suspense>
</div>
</div>
);
}
We of course tried to wrap the state change with React's startTransition()
to get rid of the error, but that makes things even worse i.e. after wrapping in startTransition()
we cannot expand the suspended item until it finishes loading. That essentially means we cannot see the loading spinner at all because the state change only takes place after the deferred value has finished loading. Below is the screen recording of the above example with startTransition()
-
remix-defer-suspense-hydration-usetransition.mov
As you can see the use case is pretty common and in our case it's crucial for our UX requirement. We would really appreciate if the Remix team can give some attention to this issue.
Additional notes:
- This same issue was also raised during Kent C. Dodds' Advanced Remix course on FrontendMasters where he too was unsure of the cause (here is a link to the particular video).
- This behaviour is reproducible in production builds too, not just on dev server.
I believe this is resolved with 1.17.0
since internal router state updates are now wrapped in startTransition
. The gitpod provided by @jacobparis seems to work. @rishitells do you want to test out your scenario with 1.17.0?
Hey @brophdawg11 i think the particular issue is not resolved with 1.17.0
we could still reproduce it. To make it simpler for you to check i created a repo that is basically just the default template with a simple route reproducing the issue.
https://github.com/flexzuu/hydration-bug
Screen.Recording.2023-06-15.at.13.31.29.mov
Be sure to avoid safari if you reproduce this as safaris streaming implementation will buffer if the size is too small and will not "defer" at all in this example!
@flexzuu I'm not quite sure if this is a Remix problem or a case of React suspense + transitions not being used/understood quite right. Indeed, the error being presented tells you the issue and the fix:
Uncaught Error: This Suspense boundary received an update before it finished hydrating. This caused the boundary to switch to client rendering. The usual way to fix this is to wrap the original update in startTransition.
The update in this case is the button click to show the awaited data, and wrapping that in startTransition
correctly resolves the error, but has the side effect of delaying showing of the expanded content until the boundary resolves.
<button
onClick={() =>
startTransition(() =>
setShowAwaitedItem((prevShowAwaitedItem) => !prevShowAwaitedItem)
)
}
>
I'm going to loop back with @jacob-ebey on this since he's our in-house expert on the React internals and expectations in this area ๐
Thanks for the detailed response @brophdawg11. To compare the same behaviour we also tried to replicate it in NextJS 13 (app directory), and it worked without the hydration error. Here is the git repo of the next13 appDir example. Attaching the gif of the behaviour at the end.
The use case is very central to our application i.e. we have a huge content form which consists of many complex form fields. All the fields are collapsible. Some of the fields need to fetch large amount of data from backend to render, so we want to defer such fields' rendering and prioritise the fields which don't fetch much data. This way we can progressively render our form instead of waiting for all the data to be available. Now, while any of the "slow" fields are rendering, we want to collapse the field and show a loading indicator inside the collapsed item, so if user tries to open the collapsed item and the data is still loading, users can visually see that the data is still loading.
With the issue we are facing, it seems to be not possible to achieve this, so we would really appreciate if Remix team can look a bit deeper into this.
I have noticed that version 1.18.1 still experiences the same issue. This issue does not cause any side effects or crashes, but it is an uncaught error message that is still reported using our error collection tools.
It would be highly appreciated if the Remix team could investigate this matter further. Thank you!
This is not really a Remix issue but a React one. Remix just happened to be the first to hydrate the entire document instead of just a div in the body.
Next.js 13 app for uses React canary version which solves the issue of hydrating the entire document.
So you can either update to latest canary version or use a workaround like described here.
As Kiliman and others have stated, this is a React issue and not something we can address at the Remix level. I'm very hopeful the next React release addresses the underlying issue.
Duplicate of #4822 which is being used as the canonical issue for this type of hydration issue