Skip to content

Commit 7bb2071

Browse files
authored
fix(useQuery): prevent hydration mismatch when ssr: false and skip: true are combined (#13128)
2 parents 32f92e5 + ca88f33 commit 7bb2071

4 files changed

Lines changed: 127 additions & 1 deletion

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@apollo/client": patch
3+
---
4+
5+
Fix `useQuery` hydration mismatch when `ssr: false` and `skip: true` are used together
6+
7+
When both options were combined, the server would return `loading: false` (because `useSSRQuery` checks `skip` first), but the client's `getServerSnapshot` was returning `ssrDisabledResult` with `loading: true`, causing a hydration mismatch.

config/jest.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ const react17TestFileIgnoreList = [
6161
"src/react/hooks/__tests__/useQueryRefHandlers.test.tsx",
6262
"src/react/query-preloader/__tests__/createQueryPreloader.test.tsx",
6363
"src/react/ssr/__tests__/prerenderStatic.test.tsx",
64+
"src/react/ssr/__tests__/useQueryEndToEnd.test.tsx",
6465
];
6566

6667
const tsStandardConfig = {

src/react/hooks/useQuery.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,7 @@ function useResult<TData, TVariables extends OperationVariables>(
544544
ssr: boolean | undefined
545545
) {
546546
"use no memo";
547+
const fetchPolicy = observable.options.fetchPolicy;
547548
return useSyncExternalStore(
548549
React.useCallback(
549550
(handleStoreChange) => {
@@ -591,7 +592,13 @@ function useResult<TData, TVariables extends OperationVariables>(
591592
[observable, resultData]
592593
),
593594
() => resultData.current,
594-
() => (ssr === false ? useQuery.ssrDisabledResult : resultData.current)
595+
() =>
596+
(
597+
(fetchPolicy !== "standby" && ssr === false) ||
598+
fetchPolicy === "no-cache"
599+
) ?
600+
useQuery.ssrDisabledResult
601+
: resultData.current
595602
);
596603
}
597604

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { screen, waitFor } from "@testing-library/react";
2+
import React from "react";
3+
import { hydrateRoot } from "react-dom/client";
4+
import { renderToString } from "react-dom/server";
5+
6+
import { ApolloClient, gql, InMemoryCache } from "@apollo/client";
7+
import { ApolloProvider, useQuery } from "@apollo/client/react";
8+
import { prerenderStatic } from "@apollo/client/react/ssr";
9+
import { MockLink } from "@apollo/client/testing";
10+
11+
it("should not cause a hydration mismatch when both ssr: false and skip: true are set", async () => {
12+
const query = gql`
13+
{
14+
hello
15+
}
16+
`;
17+
const mocks = [
18+
{
19+
request: { query },
20+
result: { data: { hello: "world" } },
21+
},
22+
];
23+
24+
const rendered: Array<{
25+
loading: boolean;
26+
data: unknown;
27+
networkStatus: number;
28+
hasMounted: boolean;
29+
}> = [];
30+
31+
const Component = () => {
32+
const {
33+
loading,
34+
data = "<undefined>",
35+
networkStatus,
36+
} = useQuery(query, {
37+
ssr: false,
38+
skip: true,
39+
});
40+
const [hasMounted, setHasMounted] = React.useState(false);
41+
React.useEffect(() => {
42+
setHasMounted(true);
43+
}, []);
44+
rendered.push({ loading, data, networkStatus, hasMounted });
45+
return (
46+
<div id="target">
47+
{JSON.stringify({ loading, data, networkStatus, hasMounted })}
48+
</div>
49+
);
50+
};
51+
52+
const serverClient = new ApolloClient({
53+
cache: new InMemoryCache(),
54+
link: new MockLink(mocks),
55+
});
56+
57+
const { result } = await prerenderStatic({
58+
tree: <Component />,
59+
renderFunction: renderToString,
60+
context: { client: serverClient },
61+
});
62+
expect(result).toMatchInlineSnapshot(
63+
`"<div id=\\"target\\">{&quot;loading&quot;:false,&quot;data&quot;:&quot;&lt;undefined&gt;&quot;,&quot;networkStatus&quot;:7,&quot;hasMounted&quot;:false}</div>"`
64+
);
65+
66+
expect(serverClient.extract()).toEqual({});
67+
68+
const container = document.createElement("div");
69+
container.innerHTML = result;
70+
document.body.appendChild(container);
71+
72+
const clientClient = new ApolloClient({
73+
cache: new InMemoryCache(),
74+
link: new MockLink(mocks),
75+
});
76+
77+
const hydrationErrors: unknown[] = [];
78+
const root = hydrateRoot(
79+
container,
80+
<ApolloProvider client={clientClient}>
81+
<Component />
82+
</ApolloProvider>,
83+
{
84+
onRecoverableError: (err) => hydrationErrors.push(err),
85+
}
86+
);
87+
88+
await waitFor(() => {
89+
expect(
90+
screen.getByText(
91+
JSON.stringify({
92+
loading: false,
93+
data: "<undefined>",
94+
networkStatus: 7,
95+
hasMounted: true,
96+
})
97+
)
98+
).toBeInTheDocument();
99+
});
100+
101+
expect(hydrationErrors).toHaveLength(0);
102+
103+
expect(
104+
rendered.every(({ data, loading, networkStatus }) => {
105+
return data === "<undefined>" && loading === false && networkStatus === 7;
106+
})
107+
).toBe(true);
108+
109+
root.unmount();
110+
document.body.removeChild(container);
111+
});

0 commit comments

Comments
 (0)