Skip to content

Commit 577b392

Browse files
add test to test the behavior of hydrating Suspensable components
1 parent b371f19 commit 577b392

File tree

2 files changed

+202
-3
lines changed

2 files changed

+202
-3
lines changed

eslint.config.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -164,9 +164,7 @@ const config = tsEslint.config([
164164
languageOptions: {
165165
parserOptions: {
166166
projectService: {
167-
allowDefaultProject: ['eslint.config.ts', 'knip.ts', 'node_package/tests/*.test.ts'],
168-
// Needed because `import * as ... from` instead of `import ... from` doesn't work in this file
169-
// for some imports.
167+
allowDefaultProject: ['eslint.config.ts', 'knip.ts', 'node_package/tests/*.test.{ts,tsx}'],
170168
defaultProject: 'tsconfig.eslint.json',
171169
},
172170
},
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import * as React from 'react';
2+
import { Suspense } from 'react';
3+
import { renderToReadableStream } from 'react-dom/server';
4+
import { hydrateRoot } from 'react-dom/client';
5+
import { screen, act, waitFor } from '@testing-library/react';
6+
import '@testing-library/jest-dom';
7+
8+
/**
9+
* Tests React's Suspense hydration behavior for async components
10+
*
11+
* This test verifies that async components are only hydrated on the client side after:
12+
* 1. The component is rendered on the server
13+
* 2. The rendered HTML is streamed to the client
14+
*
15+
* This behavior is critical for RSCRoute components, as they require their server-side
16+
* RSC payload to be present in the page before client-side hydration can occur.
17+
* And because the RSCRoute is rendered on the server because it's being hydrated on the client,
18+
* we can sure that the RSC payload is present in the page before the RSCRoute is hydrated on the client.
19+
* That's because `getRSCPayloadStream` function embeds the RSC payload immediately to the html stream even before the RSCRoute is rendered on the server.
20+
* Without this guarantee, hydration would fail or produce incorrect results.
21+
*/
22+
23+
const AsyncComponent = async ({
24+
promise,
25+
onRendered,
26+
}: {
27+
promise: Promise<string>;
28+
onRendered: () => void;
29+
}) => {
30+
const result = await promise;
31+
onRendered();
32+
return <div>{result}</div>;
33+
};
34+
35+
const AsyncComponentContainer = ({
36+
onContainerRendered,
37+
onAsyncComponentRendered,
38+
}: {
39+
onContainerRendered: () => void;
40+
onAsyncComponentRendered: () => void;
41+
}) => {
42+
onContainerRendered();
43+
const promise = new Promise<string>((resolve) => {
44+
setTimeout(() => resolve('Hello World'), 0);
45+
});
46+
return (
47+
<Suspense fallback={<div>Loading...</div>}>
48+
<AsyncComponent promise={promise} onRendered={onAsyncComponentRendered} />
49+
</Suspense>
50+
);
51+
};
52+
53+
// Function: appendHTMLWithScripts
54+
// ------------------------------
55+
// In a Jest/jsdom environment, scripts injected via innerHTML remain inert (they do not execute).
56+
// Even with runScripts: 'dangerously', jsdom will not auto-execute <script> tags inserted as strings.
57+
// This function addresses that by:
58+
// 1. Parsing the HTML string into a DocumentFragment via a <template> element.
59+
// 2. Locating each <script> node in the fragment (both inline and external).
60+
// 3. Re-creating a fresh <script> element for each found script, copying over all attributes
61+
// (e.g., src, type, async, data-*, etc.) and inline code content.
62+
// 4. Replacing the inert original <script> with the new element, which the browser/jsdom will execute.
63+
// 5. Appending the entire fragment to the document in one operation, ensuring all non-script nodes
64+
// and newly created scripts are inserted correctly.
65+
//
66+
// Use this helper whenever you need to dynamically inject HTML containing scripts in tests and
67+
// want to ensure those scripts run as they would in a real browser environment.
68+
function appendHTMLWithScripts(htmlString: string) {
69+
const template = document.createElement('template');
70+
template.innerHTML = htmlString;
71+
const frag = template.content;
72+
73+
// re-create each <script> so it executes
74+
frag.querySelectorAll('script').forEach((oldScript) => {
75+
const newScript = document.createElement('script');
76+
// copy attributes
77+
for (const { name, value } of oldScript.attributes) {
78+
newScript.setAttribute(name, value);
79+
}
80+
// copy inline code
81+
newScript.textContent = oldScript.textContent;
82+
83+
// replace the inert one with the real one
84+
oldScript.replaceWith(newScript);
85+
});
86+
87+
// finally append everything in one go
88+
document.body.appendChild(frag);
89+
}
90+
91+
async function renderAndHydrate() {
92+
// create container div element
93+
const container = document.createElement('div');
94+
container.id = 'container';
95+
document.body.appendChild(container);
96+
97+
const onContainerRendered = jest.fn();
98+
const onAsyncComponentRendered = jest.fn();
99+
const stream = await renderToReadableStream(
100+
<AsyncComponentContainer
101+
onContainerRendered={onContainerRendered}
102+
onAsyncComponentRendered={onAsyncComponentRendered}
103+
/>,
104+
);
105+
106+
const onContainerHydrated = jest.fn();
107+
const onAsyncComponentHydrated = jest.fn();
108+
const hydrate = () =>
109+
hydrateRoot(
110+
container,
111+
<AsyncComponentContainer
112+
onContainerRendered={onContainerHydrated}
113+
onAsyncComponentRendered={onAsyncComponentHydrated}
114+
/>,
115+
);
116+
117+
const reader = stream.getReader();
118+
const writeFirstChunk = async () => {
119+
const result = await reader.read();
120+
const decoded = new TextDecoder().decode(result.value as Buffer);
121+
container.innerHTML = decoded;
122+
return decoded;
123+
};
124+
125+
const writeSecondChunk = async () => {
126+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
127+
let { done, value } = await reader.read();
128+
let decoded = '';
129+
while (!done) {
130+
decoded += new TextDecoder().decode(value as Buffer);
131+
// eslint-disable-next-line no-await-in-loop, @typescript-eslint/no-unsafe-assignment
132+
({ done, value } = await reader.read());
133+
}
134+
135+
appendHTMLWithScripts(decoded);
136+
return decoded;
137+
};
138+
139+
return {
140+
onContainerRendered,
141+
onAsyncComponentRendered,
142+
onContainerHydrated,
143+
onAsyncComponentHydrated,
144+
writeFirstChunk,
145+
writeSecondChunk,
146+
hydrate,
147+
};
148+
}
149+
150+
beforeEach(() => {
151+
jest.clearAllMocks();
152+
document.body.innerHTML = '';
153+
});
154+
155+
it('hydrates the container when its content is written to the document', async () => {
156+
const { onContainerHydrated, onAsyncComponentHydrated, writeFirstChunk, hydrate } =
157+
await renderAndHydrate();
158+
159+
await act(async () => {
160+
hydrate();
161+
await writeFirstChunk();
162+
});
163+
await waitFor(() => {
164+
expect(screen.queryByText('Loading...')).toBeInTheDocument();
165+
});
166+
expect(onContainerHydrated).toHaveBeenCalled();
167+
168+
// The async component is not hydrated until the second chunk is written to the document
169+
await new Promise((resolve) => {
170+
setTimeout(resolve, 1000);
171+
});
172+
expect(onAsyncComponentHydrated).not.toHaveBeenCalled();
173+
expect(screen.queryByText('Loading...')).toBeInTheDocument();
174+
expect(screen.queryByText('Hello World')).not.toBeInTheDocument();
175+
});
176+
177+
it('hydrates the container when its content is written to the document', async () => {
178+
const { writeFirstChunk, writeSecondChunk, onAsyncComponentHydrated, onContainerHydrated, hydrate } =
179+
await renderAndHydrate();
180+
181+
await act(async () => {
182+
hydrate();
183+
await writeFirstChunk();
184+
});
185+
await waitFor(() => {
186+
expect(screen.queryByText('Loading...')).toBeInTheDocument();
187+
});
188+
189+
await act(async () => {
190+
const secondChunk = await writeSecondChunk();
191+
expect(secondChunk).toContain('script');
192+
console.log(secondChunk);
193+
});
194+
await waitFor(() => {
195+
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
196+
expect(screen.queryByText('Hello World')).toBeInTheDocument();
197+
});
198+
199+
expect(onContainerHydrated).toHaveBeenCalled();
200+
expect(onAsyncComponentHydrated).toHaveBeenCalled();
201+
});

0 commit comments

Comments
 (0)