main
js 444 lines 13.2 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 *
7 * @jest-environment node
8 */
9
10 let React;
11 let Scheduler;
12 let waitForAll;
13 let assertLog;
14 let ReactNoop;
15 let useState;
16 let act;
17 let Suspense;
18 let startTransition;
19 let getCacheForType;
20 let caches;
21 let assertConsoleErrorDev;
22
23 // These tests are mostly concerned with concurrent roots. The legacy root
24 // behavior is covered by other older test suites and is unchanged from
25 // React 17.
26 describe('act warnings', () => {
27 beforeEach(() => {
28 jest.resetModules();
29 React = require('react');
30 Scheduler = require('scheduler');
31 ReactNoop = require('react-noop-renderer');
32 act = React.act;
33 useState = React.useState;
34 Suspense = React.Suspense;
35 startTransition = React.startTransition;
36 getCacheForType = React.unstable_getCacheForType;
37 caches = [];
38
39 const InternalTestUtils = require('internal-test-utils');
40 waitForAll = InternalTestUtils.waitForAll;
41 assertLog = InternalTestUtils.assertLog;
42 assertConsoleErrorDev = InternalTestUtils.assertConsoleErrorDev;
43 });
44
45 function createTextCache() {
46 const data = new Map();
47 const version = caches.length + 1;
48 const cache = {
49 version,
50 data,
51 resolve(text) {
52 const record = data.get(text);
53 if (record === undefined) {
54 const newRecord = {
55 status: 'resolved',
56 value: text,
57 };
58 data.set(text, newRecord);
59 } else if (record.status === 'pending') {
60 const thenable = record.value;
61 record.status = 'resolved';
62 record.value = text;
63 thenable.pings.forEach(t => t());
64 }
65 },
66 reject(text, error) {
67 const record = data.get(text);
68 if (record === undefined) {
69 const newRecord = {
70 status: 'rejected',
71 value: error,
72 };
73 data.set(text, newRecord);
74 } else if (record.status === 'pending') {
75 const thenable = record.value;
76 record.status = 'rejected';
77 record.value = error;
78 thenable.pings.forEach(t => t());
79 }
80 },
81 };
82 caches.push(cache);
83 return cache;
84 }
85
86 function readText(text) {
87 const textCache = getCacheForType(createTextCache);
88 const record = textCache.data.get(text);
89 if (record !== undefined) {
90 switch (record.status) {
91 case 'pending':
92 Scheduler.log(`Suspend! [${text}]`);
93 throw record.value;
94 case 'rejected':
95 Scheduler.log(`Error! [${text}]`);
96 throw record.value;
97 case 'resolved':
98 return textCache.version;
99 }
100 } else {
101 Scheduler.log(`Suspend! [${text}]`);
102
103 const thenable = {
104 pings: [],
105 then(resolve) {
106 if (newRecord.status === 'pending') {
107 thenable.pings.push(resolve);
108 } else {
109 Promise.resolve().then(() => resolve(newRecord.value));
110 }
111 },
112 };
113
114 const newRecord = {
115 status: 'pending',
116 value: thenable,
117 };
118 textCache.data.set(text, newRecord);
119
120 throw thenable;
121 }
122 }
123
124 function Text({text}) {
125 Scheduler.log(text);
126 return text;
127 }
128
129 function AsyncText({text}) {
130 readText(text);
131 Scheduler.log(text);
132 return text;
133 }
134
135 function resolveText(text) {
136 if (caches.length === 0) {
137 throw Error('Cache does not exist.');
138 } else {
139 // Resolve the most recently created cache. An older cache can by
140 // resolved with `caches[index].resolve(text)`.
141 caches[caches.length - 1].resolve(text);
142 }
143 }
144
145 async function withActEnvironment(value, scope) {
146 const prevValue = global.IS_REACT_ACT_ENVIRONMENT;
147 global.IS_REACT_ACT_ENVIRONMENT = value;
148 try {
149 return await scope();
150 } finally {
151 global.IS_REACT_ACT_ENVIRONMENT = prevValue;
152 }
153 }
154
155 it('warns about unwrapped updates only if environment flag is enabled', async () => {
156 let setState;
157 function App() {
158 const [state, _setState] = useState(0);
159 setState = _setState;
160 return <Text text={state} />;
161 }
162
163 const root = ReactNoop.createRoot();
164 root.render(<App />);
165 await waitForAll([0]);
166 expect(root).toMatchRenderedOutput('0');
167
168 // Default behavior. Flag is undefined. No warning.
169 expect(global.IS_REACT_ACT_ENVIRONMENT).toBe(undefined);
170 setState(1);
171 await waitForAll([1]);
172 expect(root).toMatchRenderedOutput('1');
173
174 // Flag is true. Warn.
175 await withActEnvironment(true, async () => {
176 setState(2);
177 assertConsoleErrorDev([
178 'An update to App inside a test was not wrapped in act(...).\n' +
179 '\n' +
180 'When testing, code that causes React state updates should be wrapped into act(...):\n' +
181 '\n' +
182 'act(() => {\n' +
183 ' /* fire events that update state */\n' +
184 '});\n' +
185 '/* assert on the output */\n' +
186 '\n' +
187 "This ensures that you're testing the behavior the user would see in the browser. " +
188 'Learn more at https://react.dev/link/wrap-tests-with-act\n' +
189 ' in App (at **)',
190 ]);
191 await waitForAll([2]);
192 expect(root).toMatchRenderedOutput('2');
193 });
194
195 // Flag is false. No warning.
196 await withActEnvironment(false, async () => {
197 setState(3);
198 await waitForAll([3]);
199 expect(root).toMatchRenderedOutput('3');
200 });
201 });
202
203 // @gate __DEV__
204 it('act warns if the environment flag is not enabled', async () => {
205 let setState;
206 function App() {
207 const [state, _setState] = useState(0);
208 setState = _setState;
209 return <Text text={state} />;
210 }
211
212 const root = ReactNoop.createRoot();
213 root.render(<App />);
214 await waitForAll([0]);
215 expect(root).toMatchRenderedOutput('0');
216
217 // Default behavior. Flag is undefined. Warn.
218 expect(global.IS_REACT_ACT_ENVIRONMENT).toBe(undefined);
219 act(() => {
220 setState(1);
221 });
222 assertConsoleErrorDev([
223 'The current testing environment is not configured to support act(...)',
224 ]);
225 assertLog([1]);
226 expect(root).toMatchRenderedOutput('1');
227
228 // Flag is true. Don't warn.
229 await withActEnvironment(true, () => {
230 act(() => {
231 setState(2);
232 });
233 assertLog([2]);
234 expect(root).toMatchRenderedOutput('2');
235 });
236
237 // Flag is false. Warn.
238 await withActEnvironment(false, () => {
239 act(() => {
240 setState(1);
241 });
242 assertConsoleErrorDev([
243 'The current testing environment is not configured to support act(...)',
244 ]);
245 assertLog([1]);
246 expect(root).toMatchRenderedOutput('1');
247 });
248 });
249
250 it('warns if root update is not wrapped', async () => {
251 await withActEnvironment(true, () => {
252 const root = ReactNoop.createRoot();
253 root.render('Hi');
254 assertConsoleErrorDev([
255 // TODO: Better error message that doesn't make it look like "Root" is
256 // the name of a custom component
257 'An update to Root inside a test was not wrapped in act(...).\n' +
258 '\n' +
259 'When testing, code that causes React state updates should be wrapped into act(...):\n' +
260 '\n' +
261 'act(() => {\n' +
262 ' /* fire events that update state */\n' +
263 '});\n' +
264 '/* assert on the output */\n' +
265 '\n' +
266 "This ensures that you're testing the behavior the user would see in the browser. " +
267 'Learn more at https://react.dev/link/wrap-tests-with-act',
268 ]);
269 });
270 });
271
272 // @gate __DEV__
273 it('warns if class update is not wrapped', async () => {
274 let app;
275 class App extends React.Component {
276 state = {count: 0};
277 render() {
278 app = this;
279 return <Text text={this.state.count} />;
280 }
281 }
282
283 await withActEnvironment(true, () => {
284 const root = ReactNoop.createRoot();
285 act(() => {
286 root.render(<App />);
287 });
288 app.setState({count: 1});
289 assertConsoleErrorDev([
290 'An update to App inside a test was not wrapped in act(...).\n' +
291 '\n' +
292 'When testing, code that causes React state updates should be wrapped into act(...):\n' +
293 '\n' +
294 'act(() => {\n' +
295 ' /* fire events that update state */\n' +
296 '});\n' +
297 '/* assert on the output */\n' +
298 '\n' +
299 "This ensures that you're testing the behavior the user would see in the browser. " +
300 'Learn more at https://react.dev/link/wrap-tests-with-act\n' +
301 ' in App (at **)',
302 ]);
303 });
304 });
305
306 // @gate __DEV__
307 it('warns even if update is synchronous', async () => {
308 let setState;
309 function App() {
310 const [state, _setState] = useState(0);
311 setState = _setState;
312 return <Text text={state} />;
313 }
314
315 await withActEnvironment(true, () => {
316 const root = ReactNoop.createRoot();
317 act(() => root.render(<App />));
318 assertLog([0]);
319 expect(root).toMatchRenderedOutput('0');
320
321 // Even though this update is synchronous, we should still fire a warning,
322 // because it could have spawned additional asynchronous work
323 ReactNoop.flushSync(() => setState(1));
324 assertConsoleErrorDev([
325 'An update to App inside a test was not wrapped in act(...).\n' +
326 '\n' +
327 'When testing, code that causes React state updates should be wrapped into act(...):\n' +
328 '\n' +
329 'act(() => {\n' +
330 ' /* fire events that update state */\n' +
331 '});\n' +
332 '/* assert on the output */\n' +
333 '\n' +
334 "This ensures that you're testing the behavior the user would see in the browser. " +
335 'Learn more at https://react.dev/link/wrap-tests-with-act\n' +
336 ' in App (at **)',
337 ]);
338
339 assertLog([1]);
340 expect(root).toMatchRenderedOutput('1');
341 });
342 });
343
344 // @gate __DEV__
345 // @gate enableLegacyCache
346 it('warns if Suspense retry is not wrapped', async () => {
347 function App() {
348 return (
349 <Suspense fallback={<Text text="Loading..." />}>
350 <AsyncText text="Async" />
351 </Suspense>
352 );
353 }
354
355 await withActEnvironment(true, () => {
356 const root = ReactNoop.createRoot();
357 act(() => {
358 root.render(<App />);
359 });
360 assertLog([
361 'Suspend! [Async]',
362 'Loading...',
363 // pre-warming
364 'Suspend! [Async]',
365 ]);
366 expect(root).toMatchRenderedOutput('Loading...');
367
368 // This is a retry, not a ping, because we already showed a fallback.
369 resolveText('Async');
370 assertConsoleErrorDev([
371 'A suspended resource finished loading inside a test, but the event was not wrapped in act(...).\n' +
372 '\n' +
373 'When testing, code that resolves suspended data should be wrapped into act(...):\n' +
374 '\n' +
375 'act(() => {\n' +
376 ' /* finish loading suspended data */\n' +
377 '});\n' +
378 '/* assert on the output */\n' +
379 '\n' +
380 "This ensures that you're testing the behavior the user would see in the browser. " +
381 'Learn more at https://react.dev/link/wrap-tests-with-act',
382
383 // pre-warming
384
385 'A suspended resource finished loading inside a test, but the event was not wrapped in act(...).\n' +
386 '\n' +
387 'When testing, code that resolves suspended data should be wrapped into act(...):\n' +
388 '\n' +
389 'act(() => {\n' +
390 ' /* finish loading suspended data */\n' +
391 '});\n' +
392 '/* assert on the output */\n' +
393 '\n' +
394 "This ensures that you're testing the behavior the user would see in the browser. " +
395 'Learn more at https://react.dev/link/wrap-tests-with-act',
396 ]);
397 });
398 });
399
400 // @gate __DEV__
401 // @gate enableLegacyCache
402 it('warns if Suspense ping is not wrapped', async () => {
403 function App({showMore}) {
404 return (
405 <Suspense fallback={<Text text="Loading..." />}>
406 {showMore ? <AsyncText text="Async" /> : <Text text="(empty)" />}
407 </Suspense>
408 );
409 }
410
411 await withActEnvironment(true, () => {
412 const root = ReactNoop.createRoot();
413 act(() => {
414 root.render(<App showMore={false} />);
415 });
416 assertLog(['(empty)']);
417 expect(root).toMatchRenderedOutput('(empty)');
418
419 act(() => {
420 startTransition(() => {
421 root.render(<App showMore={true} />);
422 });
423 });
424 assertLog(['Suspend! [Async]', 'Loading...']);
425 expect(root).toMatchRenderedOutput('(empty)');
426
427 // This is a ping, not a retry, because no fallback is showing.
428 resolveText('Async');
429 assertConsoleErrorDev([
430 'A suspended resource finished loading inside a test, but the event was not wrapped in act(...).\n' +
431 '\n' +
432 'When testing, code that resolves suspended data should be wrapped into act(...):\n' +
433 '\n' +
434 'act(() => {\n' +
435 ' /* finish loading suspended data */\n' +
436 '});\n' +
437 '/* assert on the output */\n' +
438 '\n' +
439 "This ensures that you're testing the behavior the user would see in the browser. " +
440 'Learn more at https://react.dev/link/wrap-tests-with-act',
441 ]);
442 });
443 });
444 });