main
js 391 lines 11.6 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 * @emails react-core
8 * @jest-environment node
9 */
10
11 'use strict';
12
13 let React;
14 let ReactNoop;
15 let Scheduler;
16 let act;
17 let NormalPriority;
18 let IdlePriority;
19 let runWithPriority;
20 let startTransition;
21 let waitForAll;
22 let waitForPaint;
23 let assertLog;
24 let waitFor;
25
26 describe('ReactSchedulerIntegration', () => {
27 beforeEach(() => {
28 jest.resetModules();
29
30 React = require('react');
31 ReactNoop = require('react-noop-renderer');
32 Scheduler = require('scheduler');
33 act = require('internal-test-utils').act;
34 NormalPriority = Scheduler.unstable_NormalPriority;
35 IdlePriority = Scheduler.unstable_IdlePriority;
36 runWithPriority = Scheduler.unstable_runWithPriority;
37 startTransition = React.startTransition;
38
39 const InternalTestUtils = require('internal-test-utils');
40 waitForAll = InternalTestUtils.waitForAll;
41 waitForPaint = InternalTestUtils.waitForPaint;
42 assertLog = InternalTestUtils.assertLog;
43 waitFor = InternalTestUtils.waitFor;
44 });
45
46 // Note: This is based on a similar component we use in www. We can delete
47 // once the extra div wrapper is no longer necessary.
48 function LegacyHiddenDiv({children, mode}) {
49 return (
50 <div hidden={mode === 'hidden'}>
51 <React.unstable_LegacyHidden
52 mode={mode === 'hidden' ? 'unstable-defer-without-hiding' : mode}>
53 {children}
54 </React.unstable_LegacyHidden>
55 </div>
56 );
57 }
58
59 it('passive effects are called before Normal-pri scheduled in layout effects', async () => {
60 const {useEffect, useLayoutEffect} = React;
61 function Effects({step}) {
62 useLayoutEffect(() => {
63 Scheduler.log('Layout Effect');
64 Scheduler.unstable_scheduleCallback(NormalPriority, () =>
65 Scheduler.log('Scheduled Normal Callback from Layout Effect'),
66 );
67 });
68 useEffect(() => {
69 Scheduler.log('Passive Effect');
70 });
71 return null;
72 }
73 function CleanupEffect() {
74 useLayoutEffect(() => () => {
75 Scheduler.log('Cleanup Layout Effect');
76 Scheduler.unstable_scheduleCallback(NormalPriority, () =>
77 Scheduler.log('Scheduled Normal Callback from Cleanup Layout Effect'),
78 );
79 });
80 return null;
81 }
82 await act(() => {
83 ReactNoop.render(<CleanupEffect />);
84 });
85 assertLog([]);
86 await act(() => {
87 ReactNoop.render(<Effects />);
88 });
89 assertLog([
90 'Cleanup Layout Effect',
91 'Layout Effect',
92 'Passive Effect',
93 // These callbacks should be scheduled after the passive effects.
94 'Scheduled Normal Callback from Cleanup Layout Effect',
95 'Scheduled Normal Callback from Layout Effect',
96 ]);
97 });
98
99 it('requests a paint after committing', async () => {
100 const scheduleCallback = Scheduler.unstable_scheduleCallback;
101
102 const root = ReactNoop.createRoot();
103 root.render('Initial');
104 await waitForAll([]);
105 expect(root).toMatchRenderedOutput('Initial');
106
107 scheduleCallback(NormalPriority, () => Scheduler.log('A'));
108 scheduleCallback(NormalPriority, () => Scheduler.log('B'));
109 scheduleCallback(NormalPriority, () => Scheduler.log('C'));
110
111 // Schedule a React render. React will request a paint after committing it.
112 React.startTransition(() => {
113 root.render('Update');
114 });
115
116 // Perform just a little bit of work. By now, the React task will have
117 // already been scheduled, behind A, B, and C.
118 await waitFor(['A']);
119
120 // Schedule some additional tasks. These won't fire until after the React
121 // update has finished.
122 scheduleCallback(NormalPriority, () => Scheduler.log('D'));
123 scheduleCallback(NormalPriority, () => Scheduler.log('E'));
124
125 // Flush everything up to the next paint. Should yield after the
126 // React commit.
127 await waitForPaint(['B', 'C']);
128 expect(root).toMatchRenderedOutput('Update');
129
130 // Now flush the rest of the work.
131 await waitForAll(['D', 'E']);
132 });
133
134 // @gate enableLegacyHidden
135 it('idle updates are not blocked by offscreen work', async () => {
136 function Text({text}) {
137 Scheduler.log(text);
138 return text;
139 }
140
141 function App({label}) {
142 return (
143 <>
144 <Text text={`Visible: ` + label} />
145 <LegacyHiddenDiv mode="hidden">
146 <Text text={`Hidden: ` + label} />
147 </LegacyHiddenDiv>
148 </>
149 );
150 }
151
152 const root = ReactNoop.createRoot();
153 await act(async () => {
154 root.render(<App label="A" />);
155
156 // Commit the visible content
157 await waitForPaint(['Visible: A']);
158 expect(root).toMatchRenderedOutput(
159 <>
160 Visible: A
161 <div hidden={true} />
162 </>,
163 );
164
165 // Before the hidden content has a chance to render, schedule an
166 // idle update
167 runWithPriority(IdlePriority, () => {
168 root.render(<App label="B" />);
169 });
170
171 // The next commit should only include the visible content
172 await waitForPaint(['Visible: B']);
173 expect(root).toMatchRenderedOutput(
174 <>
175 Visible: B
176 <div hidden={true} />
177 </>,
178 );
179 });
180
181 // The hidden content commits later
182 assertLog(['Hidden: B']);
183 expect(root).toMatchRenderedOutput(
184 <>
185 Visible: B<div hidden={true}>Hidden: B</div>
186 </>,
187 );
188 });
189 });
190
191 describe(
192 'regression test: does not infinite loop if `shouldYield` returns ' +
193 'true after a partial tree expires',
194 () => {
195 let logDuringShouldYield = false;
196
197 beforeEach(() => {
198 jest.resetModules();
199
200 jest.mock('scheduler', () => {
201 const actual = jest.requireActual('scheduler/unstable_mock');
202 return {
203 ...actual,
204 unstable_shouldYield() {
205 if (logDuringShouldYield) {
206 actual.log('shouldYield');
207 }
208 return actual.unstable_shouldYield();
209 },
210 };
211 });
212
213 React = require('react');
214 ReactNoop = require('react-noop-renderer');
215 Scheduler = require('scheduler');
216 startTransition = React.startTransition;
217
218 const InternalTestUtils = require('internal-test-utils');
219 waitForAll = InternalTestUtils.waitForAll;
220 waitForPaint = InternalTestUtils.waitForPaint;
221 assertLog = InternalTestUtils.assertLog;
222 waitFor = InternalTestUtils.waitFor;
223 act = InternalTestUtils.act;
224 });
225
226 afterEach(() => {
227 jest.mock('scheduler', () =>
228 jest.requireActual('scheduler/unstable_mock'),
229 );
230 });
231
232 it('using public APIs to trigger real world scenario', async () => {
233 // This test reproduces a case where React's Scheduler task timed out but
234 // the `shouldYield` method returned true. The bug was that React fell
235 // into an infinite loop, because it would enter the work loop then
236 // immediately yield back to Scheduler.
237 //
238 // (The next test in this suite covers the same case. The difference is
239 // that this test only uses public APIs, whereas the next test mocks
240 // `shouldYield` to check when it is called.)
241 function Text({text}) {
242 return text;
243 }
244
245 function App({step}) {
246 return (
247 <>
248 <Text text="A" />
249 <TriggerErstwhileSchedulerBug />
250 <Text text="B" />
251 <TriggerErstwhileSchedulerBug />
252 <Text text="C" />
253 </>
254 );
255 }
256
257 function TriggerErstwhileSchedulerBug() {
258 // This triggers a once-upon-a-time bug in Scheduler that caused
259 // `shouldYield` to return true even though the current task expired.
260 Scheduler.unstable_advanceTime(10000);
261 Scheduler.unstable_requestPaint();
262 return null;
263 }
264
265 await act(async () => {
266 ReactNoop.render(<App />);
267 await waitForPaint([]);
268 await waitForPaint([]);
269 });
270 });
271
272 it('mock Scheduler module to check if `shouldYield` is called', async () => {
273 // This test reproduces a bug where React's Scheduler task timed out but
274 // the `shouldYield` method returned true. Usually we try not to mock
275 // internal methods, but I've made an exception here since the point is
276 // specifically to test that React is resilient to the behavior of a
277 // Scheduler API. That being said, feel free to rewrite or delete this
278 // test if/when the API changes.
279 function Text({text}) {
280 Scheduler.log(text);
281 return text;
282 }
283
284 function App({step}) {
285 return (
286 <>
287 <Text text="A" />
288 <Text text="B" />
289 <Text text="C" />
290 </>
291 );
292 }
293
294 await act(async () => {
295 // Partially render the tree, then yield
296 startTransition(() => {
297 ReactNoop.render(<App />);
298 });
299 await waitFor(['A']);
300
301 // Start logging whenever shouldYield is called
302 logDuringShouldYield = true;
303 // Let's call it once to confirm the mock actually works
304 await waitFor(['shouldYield']);
305
306 // Expire the task
307 Scheduler.unstable_advanceTime(10000);
308 // Scheduling a new update is a trick to force the expiration to kick
309 // in. We don't check if a update has been starved at the beginning of
310 // working on it, since there's no point — we're already working on it.
311 // We only check before yielding to the main thread (to avoid starvation
312 // by other main thread work) or when receiving an update (to avoid
313 // starvation by incoming updates).
314 startTransition(() => {
315 ReactNoop.render(<App />);
316 });
317 // Because the render expired, React should finish the tree without
318 // consulting `shouldYield` again
319 await waitFor(['B', 'C']);
320 });
321 });
322 },
323 );
324
325 describe('`act` bypasses Scheduler methods completely,', () => {
326 let infiniteLoopGuard;
327
328 beforeEach(() => {
329 jest.resetModules();
330
331 infiniteLoopGuard = 0;
332
333 jest.mock('scheduler', () => {
334 const actual = jest.requireActual('scheduler/unstable_mock');
335 return {
336 ...actual,
337 unstable_shouldYield() {
338 // This simulates a bug report where `shouldYield` returns true in a
339 // unit testing environment. Because `act` will keep working until
340 // there's no more work left, it would fall into an infinite loop.
341 // The fix is that when performing work inside `act`, we should bypass
342 // `shouldYield` completely, because we can't trust it to be correct.
343 if (infiniteLoopGuard++ > 100) {
344 throw new Error('Detected an infinite loop');
345 }
346 return true;
347 },
348 };
349 });
350
351 React = require('react');
352 ReactNoop = require('react-noop-renderer');
353 startTransition = React.startTransition;
354 });
355
356 afterEach(() => {
357 jest.mock('scheduler', () => jest.requireActual('scheduler/unstable_mock'));
358 });
359
360 // @gate __DEV__
361 it('inside `act`, does not call `shouldYield`, even during a concurrent render', async () => {
362 function App() {
363 return (
364 <>
365 <div>A</div>
366 <div>B</div>
367 <div>C</div>
368 </>
369 );
370 }
371
372 const root = ReactNoop.createRoot();
373 const publicAct = React.act;
374 const prevIsReactActEnvironment = global.IS_REACT_ACT_ENVIRONMENT;
375 try {
376 global.IS_REACT_ACT_ENVIRONMENT = true;
377 await publicAct(async () => {
378 startTransition(() => root.render(<App />));
379 });
380 } finally {
381 global.IS_REACT_ACT_ENVIRONMENT = prevIsReactActEnvironment;
382 }
383 expect(root).toMatchRenderedOutput(
384 <>
385 <div>A</div>
386 <div>B</div>
387 <div>C</div>
388 </>,
389 );
390 });
391 });