|
| 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