Skip to content

Commit 100952e

Browse files
0.10.0 (3) switch rehydration mismatch protection to useSyncExternalStore (#207)
* add test for race condition * switch to `useSyncExternalStore` * add comments * fix up tests * move React functions out of test * disable semgrep * Update packages/client-react-streaming/src/DataTransportAbstraction/WrappedApolloClient.test.tsx Co-authored-by: Jerel Miller <[email protected]> * remove some `await test` calls --------- Co-authored-by: Jerel Miller <[email protected]>
1 parent d98b87b commit 100952e

File tree

4 files changed

+173
-15
lines changed

4 files changed

+173
-15
lines changed

packages/client-react-streaming/src/AccumulateMultipartResponsesLink.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import type { SubscriptionObserver } from "zen-observable-ts";
1414
const { DebounceMultipartResponsesLink: AccumulateMultipartResponsesLink } =
1515
await import("#bundled");
1616

17-
await test("normal queries can resolve synchronously", () => {
17+
test("normal queries can resolve synchronously", () => {
1818
const query = gql`
1919
query {
2020
fastField
@@ -46,7 +46,7 @@ await test("normal queries can resolve synchronously", () => {
4646
});
4747
});
4848

49-
await test("deferred query will complete synchonously if maxDelay is 0", () => {
49+
test("deferred query will complete synchonously if maxDelay is 0", () => {
5050
const query = gql`
5151
query {
5252
fastField
@@ -81,7 +81,7 @@ await test("deferred query will complete synchonously if maxDelay is 0", () => {
8181
});
8282
});
8383

84-
await test("`next` call will be debounced and results will be merged together", () => {
84+
test("`next` call will be debounced and results will be merged together", () => {
8585
mock.timers.enable();
8686

8787
const query = gql`

packages/client-react-streaming/src/DataTransportAbstraction/WrappedApolloClient.test.tsx

Lines changed: 127 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { Suspense, useMemo } from "rehackt";
1+
import React, { Suspense, use, useMemo } from "rehackt";
22
import { outsideOf } from "../util/runInConditions.js";
33
import assert from "node:assert";
44
import test, { afterEach, describe } from "node:test";
@@ -24,17 +24,21 @@ const {
2424
InMemoryCache,
2525
WrapApolloProvider,
2626
DataTransportContext,
27+
resetApolloSingletons,
2728
} = await import("#bundled");
2829

29-
await describe(
30+
describe(
3031
"tests with DOM access",
3132
{ skip: outsideOf("node", "browser") },
3233
async () => {
3334
// @ts-expect-error seems to have a wrong type?
3435
await import("global-jsdom/register");
35-
const { render, cleanup } = await import("@testing-library/react");
36+
const { render, cleanup, getQueriesForElement } = await import(
37+
"@testing-library/react"
38+
);
3639

3740
afterEach(cleanup);
41+
afterEach(resetApolloSingletons);
3842

3943
const QUERY_ME: TypedDocumentNode<{ me: string }> = gql`
4044
query {
@@ -215,9 +219,9 @@ await describe(
215219
await findByText("User");
216220

217221
assert.ok(attemptedRenderCount > 0);
218-
// one render to rehydrate the server value
222+
// will try with server value and immediately restart with client value
219223
// one rerender with the actual client value (which is hopefull equal)
220-
assert.equal(finishedRenderCount, 2);
224+
assert.equal(finishedRenderCount, 1);
221225

222226
assert.deepStrictEqual(JSON.parse(JSON.stringify(client.extract())), {
223227
ROOT_QUERY: {
@@ -227,10 +231,127 @@ await describe(
227231
});
228232
}
229233
);
234+
235+
test(
236+
"race condition: client ahead of server renders without hydration mismatch",
237+
{ skip: outsideOf("browser") },
238+
async () => {
239+
const { $RC, $RS, setBody, hydrateBody, appendToBody } = await import(
240+
"../util/hydrationTest.js"
241+
);
242+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-constraint
243+
let useStaticValueRefStub = <T extends unknown>(): { current: T } => {
244+
throw new Error("Should not be called yet!");
245+
};
246+
247+
const client = new ApolloClient({
248+
connectToDevTools: false,
249+
cache: new InMemoryCache(),
250+
});
251+
const simulateRequestStart = client.onQueryStarted!;
252+
const simulateRequestData = client.onQueryProgress!;
253+
254+
const Provider = WrapApolloProvider(({ children }) => {
255+
return (
256+
<DataTransportContext.Provider
257+
value={useMemo(
258+
() => ({
259+
useStaticValueRef() {
260+
return useStaticValueRefStub();
261+
},
262+
}),
263+
[]
264+
)}
265+
>
266+
{children}
267+
</DataTransportContext.Provider>
268+
);
269+
});
270+
271+
const finishedRenders: any[] = [];
272+
273+
function Child() {
274+
const { data } = useSuspenseQuery(QUERY_ME);
275+
finishedRenders.push(data);
276+
return <div id="user">{data.me}</div>;
277+
}
278+
279+
const promise = Promise.resolve();
280+
// suspends on the server, immediately resolved in browser
281+
function ParallelSuspending() {
282+
use(promise);
283+
return <div id="parallel">suspending in parallel</div>;
284+
}
285+
286+
const { findByText } = getQueriesForElement(document.body);
287+
288+
// server starts streaming
289+
setBody`<!--$?--><template id="B:0"></template>Fallback<!--/$-->`;
290+
// request started on the server
291+
simulateRequestStart(EVENT_STARTED);
292+
293+
hydrateBody(
294+
<Provider makeClient={() => client}>
295+
<Suspense fallback={"Fallback"}>
296+
<Child />
297+
<ParallelSuspending />
298+
</Suspense>
299+
</Provider>
300+
);
301+
302+
await findByText("Fallback");
303+
// this is the div for the suspense boundary
304+
appendToBody`<div hidden id="S:0"><template id="P:1"></template><template id="P:2"></template></div>`;
305+
// request has finished on the server
306+
simulateRequestData(EVENT_DATA);
307+
simulateRequestData(EVENT_COMPLETE);
308+
// `Child` component wants to transport data from SSR render to the browser
309+
useStaticValueRefStub = () => ({ current: FIRST_HOOK_RESULT as any });
310+
// `Child` finishes rendering on the server
311+
appendToBody`<div hidden id="S:1"><div id="user">User</div></div>`;
312+
$RS("S:1", "P:1");
313+
314+
// meanwhile, in the browser, the cache is modified
315+
client.cache.writeQuery({
316+
query: QUERY_ME,
317+
data: {
318+
me: "Future me.",
319+
},
320+
});
321+
322+
// `ParallelSuspending` finishes rendering
323+
appendToBody`<div hidden id="S:2"><div id="parallel">suspending in parallel</div></div>`;
324+
$RS("S:2", "P:2");
325+
326+
// everything in the suspense boundary finished rendering, so assemble HTML and take up React rendering again
327+
$RC("B:0", "S:0");
328+
329+
// we expect the *new* value to appear after hydration finished, not the old value from the server
330+
await findByText("Future me.");
331+
332+
// one render to rehydrate the server value
333+
// one rerender with the actual client value (which is hopefull equal)
334+
assert.deepStrictEqual(finishedRenders, [
335+
{ me: "User" },
336+
{ me: "Future me." },
337+
]);
338+
339+
assert.deepStrictEqual(JSON.parse(JSON.stringify(client.extract())), {
340+
ROOT_QUERY: {
341+
__typename: "Query",
342+
me: "Future me.",
343+
},
344+
});
345+
assert.equal(
346+
document.body.innerHTML,
347+
`<!--$--><div id="user">Future me.</div><div id="parallel">suspending in parallel</div><!--/$-->`
348+
);
349+
}
350+
);
230351
}
231352
);
232353

233-
await describe("document transforms are applied correctly", async () => {
354+
describe("document transforms are applied correctly", async () => {
234355
const untransformedQuery = gql`
235356
query Test {
236357
user {
Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"use client";
2-
import { useContext, useEffect, useState } from "react";
2+
import { useContext, useSyncExternalStore } from "react";
33
import { DataTransportContext } from "./DataTransportAbstraction.js";
44

55
/**
@@ -12,20 +12,24 @@ import { DataTransportContext } from "./DataTransportAbstraction.js";
1212
* the component can change to client-side values instead.
1313
*/
1414
export function useTransportValue<T>(value: T): T {
15-
const [isClient, setIsClient] = useState(false);
16-
useEffect(() => setIsClient(true), []);
17-
1815
const dataTransport = useContext(DataTransportContext);
1916
if (!dataTransport)
2017
throw new Error(
2118
"useTransportValue must be used within a streaming-specific ApolloProvider"
2219
);
2320
const valueRef = dataTransport.useStaticValueRef(value);
24-
if (isClient) {
21+
22+
const retVal = useSyncExternalStore(
23+
() => () => {},
24+
() => value,
25+
() => valueRef.current
26+
);
27+
28+
if (retVal === value) {
2529
// @ts-expect-error this value will never be used again
2630
// so we can safely delete it
2731
valueRef.current = undefined;
2832
}
2933

30-
return isClient ? value : valueRef.current;
34+
return retVal;
3135
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { hydrateRoot } from "react-dom/client";
2+
3+
/* eslint-disable */
4+
// prettier-ignore
5+
/** React completeSegment function */
6+
// @ts-expect-error This is React code.
7+
export function $RS(a, b) { a = document.getElementById(a); b = document.getElementById(b); for (a.parentNode.removeChild(a); a.firstChild;)b.parentNode.insertBefore(a.firstChild, b); b.parentNode.removeChild(b) }
8+
// prettier-ignore
9+
/** React completeBoundary function */
10+
// @ts-expect-error This is React code.
11+
export function $RC(b, c, e = undefined) { c = document.getElementById(c); c.parentNode.removeChild(c); var a = document.getElementById(b); if (a) { b = a.previousSibling; if (e) b.data = "$!", a.setAttribute("data-dgst", e); else { e = b.parentNode; a = b.nextSibling; var f = 0; do { if (a && 8 === a.nodeType) { var d = a.data; if ("/$" === d) if (0 === f) break; else f--; else "$" !== d && "$?" !== d && "$!" !== d || f++ } d = a.nextSibling; e.removeChild(a); a = d } while (a); for (; c.firstChild;)e.insertBefore(c.firstChild, a); b.data = "$" } b._reactRetry && b._reactRetry() } }
12+
/* eslint-enable */
13+
14+
export function hydrateBody(
15+
initialChildren: Parameters<typeof hydrateRoot>[1],
16+
options?: Parameters<typeof hydrateRoot>[2]
17+
) {
18+
return hydrateRoot(document.body, initialChildren, options);
19+
}
20+
21+
export function setBody(html: TemplateStringsArray) {
22+
if (html.length !== 1)
23+
throw new Error("Expected exactly one template string");
24+
// nosemgrep
25+
document.body.innerHTML = html[0];
26+
}
27+
28+
export function appendToBody(html: TemplateStringsArray) {
29+
if (html.length !== 1)
30+
throw new Error("Expected exactly one template string");
31+
// nosemgrep
32+
document.body.insertAdjacentHTML("beforeend", html[0]);
33+
}

0 commit comments

Comments
 (0)