main
js 447 lines 12.6 KB
Raw
1 let React;
2 let Suspense;
3 let ReactNoop;
4 let Scheduler;
5 let act;
6 let Random;
7 let ReactFeatureFlags;
8
9 const SEED = process.env.FUZZ_TEST_SEED || 'default';
10 const prettyFormatPkg = require('pretty-format');
11
12 function prettyFormat(thing) {
13 return prettyFormatPkg.format(thing, {
14 plugins: [
15 prettyFormatPkg.plugins.ReactElement,
16 prettyFormatPkg.plugins.ReactTestComponent,
17 ],
18 });
19 }
20
21 describe('ReactSuspenseFuzz', () => {
22 beforeEach(() => {
23 jest.resetModules();
24 React = require('react');
25 Suspense = React.Suspense;
26 ReactNoop = require('react-noop-renderer');
27 Scheduler = require('scheduler');
28 act = require('internal-test-utils').act;
29 Random = require('random-seed');
30 ReactFeatureFlags = require('shared/ReactFeatureFlags');
31 });
32
33 jest.setTimeout(20000);
34
35 function createFuzzer() {
36 const {useState, useContext, useLayoutEffect} = React;
37
38 const ShouldSuspendContext = React.createContext(true);
39
40 let pendingTasks = new Set();
41 let cache = new Map();
42
43 function resetCache() {
44 pendingTasks = new Set();
45 cache = new Map();
46 }
47
48 function Container({children, updates}) {
49 const [step, setStep] = useState(0);
50
51 useLayoutEffect(() => {
52 if (updates !== undefined) {
53 const cleanUps = new Set();
54 updates.forEach(({remountAfter}, i) => {
55 const task = {
56 label: `Remount children after ${remountAfter}ms`,
57 };
58 const timeoutID = setTimeout(() => {
59 pendingTasks.delete(task);
60 setStep(i + 1);
61 }, remountAfter);
62 pendingTasks.add(task);
63 cleanUps.add(() => {
64 pendingTasks.delete(task);
65 clearTimeout(timeoutID);
66 });
67 });
68 return () => {
69 cleanUps.forEach(cleanUp => cleanUp());
70 };
71 }
72 }, [updates]);
73
74 return <React.Fragment key={step}>{children}</React.Fragment>;
75 }
76
77 function Text({text, initialDelay = 0, updates}) {
78 const [[step, delay], setStep] = useState([0, initialDelay]);
79
80 useLayoutEffect(() => {
81 if (updates !== undefined) {
82 const cleanUps = new Set();
83 updates.forEach(({beginAfter, suspendFor}, i) => {
84 const task = {
85 label: `Update ${beginAfter}ms after mount and suspend for ${suspendFor}ms [${text}]`,
86 };
87 const timeoutID = setTimeout(() => {
88 pendingTasks.delete(task);
89 setStep([i + 1, suspendFor]);
90 }, beginAfter);
91 pendingTasks.add(task);
92 cleanUps.add(() => {
93 pendingTasks.delete(task);
94 clearTimeout(timeoutID);
95 });
96 });
97 return () => {
98 cleanUps.forEach(cleanUp => cleanUp());
99 };
100 }
101 }, [updates]);
102
103 const fullText = `[${text}:${step}]`;
104
105 const shouldSuspend = useContext(ShouldSuspendContext);
106
107 let resolvedText;
108 if (shouldSuspend && delay > 0) {
109 resolvedText = cache.get(fullText);
110 if (resolvedText === undefined) {
111 const thenable = {
112 then(resolve) {
113 const task = {label: `Promise resolved [${fullText}]`};
114 pendingTasks.add(task);
115 setTimeout(() => {
116 cache.set(fullText, fullText);
117 pendingTasks.delete(task);
118 resolve();
119 }, delay);
120 },
121 };
122 cache.set(fullText, thenable);
123 throw thenable;
124 } else if (typeof resolvedText.then === 'function') {
125 throw resolvedText;
126 }
127 } else {
128 resolvedText = fullText;
129 }
130
131 return resolvedText;
132 }
133
134 async function testResolvedOutput(unwrappedChildren) {
135 const children = (
136 <Suspense fallback="Loading...">{unwrappedChildren}</Suspense>
137 );
138
139 // Render the app multiple times: once without suspending (as if all the
140 // data was already preloaded), and then again with suspensey data.
141 resetCache();
142 const expectedRoot = ReactNoop.createRoot();
143 await act(() => {
144 expectedRoot.render(
145 <ShouldSuspendContext.Provider value={false}>
146 {children}
147 </ShouldSuspendContext.Provider>,
148 );
149 });
150
151 const expectedOutput = expectedRoot.getChildrenAsJSX();
152
153 resetCache();
154
155 const concurrentRootThatSuspends = ReactNoop.createRoot();
156 await act(() => {
157 concurrentRootThatSuspends.render(children);
158 });
159
160 resetCache();
161
162 // Do it again in legacy mode.
163 if (!ReactFeatureFlags.disableLegacyMode) {
164 const legacyRootThatSuspends = ReactNoop.createLegacyRoot();
165 await act(() => {
166 legacyRootThatSuspends.render(children);
167 });
168
169 expect(legacyRootThatSuspends.getChildrenAsJSX()).toEqual(
170 expectedOutput,
171 );
172 }
173
174 // Now compare the final output. It should be the same.
175 expect(concurrentRootThatSuspends.getChildrenAsJSX()).toEqual(
176 expectedOutput,
177 );
178
179 // TODO: There are Scheduler logs in this test file but they were only
180 // added for debugging purposes; we don't make any assertions on them.
181 // Should probably just delete.
182 Scheduler.unstable_clearLog();
183 }
184
185 function pickRandomWeighted(rand, options) {
186 let totalWeight = 0;
187 for (let i = 0; i < options.length; i++) {
188 totalWeight += options[i].weight;
189 }
190 let remainingWeight = rand.floatBetween(0, totalWeight);
191 for (let i = 0; i < options.length; i++) {
192 const {value, weight} = options[i];
193 remainingWeight -= weight;
194 if (remainingWeight <= 0) {
195 return value;
196 }
197 }
198 }
199
200 function generateTestCase(rand, numberOfElements) {
201 let remainingElements = numberOfElements;
202
203 function createRandomChild(hasSibling) {
204 const possibleActions = [
205 {value: 'return', weight: 1},
206 {value: 'text', weight: 1},
207 ];
208
209 if (hasSibling) {
210 possibleActions.push({value: 'container', weight: 1});
211 possibleActions.push({value: 'suspense', weight: 1});
212 }
213
214 const action = pickRandomWeighted(rand, possibleActions);
215
216 switch (action) {
217 case 'text': {
218 remainingElements--;
219
220 const numberOfUpdates = pickRandomWeighted(rand, [
221 {value: 0, weight: 8},
222 {value: 1, weight: 4},
223 {value: 2, weight: 1},
224 ]);
225
226 const updates = [];
227 for (let i = 0; i < numberOfUpdates; i++) {
228 updates.push({
229 beginAfter: rand.intBetween(0, 10000),
230 suspendFor: rand.intBetween(0, 10000),
231 });
232 }
233
234 return (
235 <Text
236 text={(remainingElements + 9).toString(36).toUpperCase()}
237 initialDelay={rand.intBetween(0, 10000)}
238 updates={updates}
239 />
240 );
241 }
242 case 'container': {
243 const numberOfUpdates = pickRandomWeighted(rand, [
244 {value: 0, weight: 8},
245 {value: 1, weight: 4},
246 {value: 2, weight: 1},
247 ]);
248
249 const updates = [];
250 for (let i = 0; i < numberOfUpdates; i++) {
251 updates.push({
252 remountAfter: rand.intBetween(0, 10000),
253 });
254 }
255
256 remainingElements--;
257 const children = createRandomChildren(3);
258 return React.createElement(Container, {updates}, ...children);
259 }
260 case 'suspense': {
261 remainingElements--;
262 const children = createRandomChildren(3);
263
264 const fallbackType = pickRandomWeighted(rand, [
265 {value: 'none', weight: 1},
266 {value: 'normal', weight: 1},
267 {value: 'nested suspense', weight: 1},
268 ]);
269
270 let fallback;
271 if (fallbackType === 'normal') {
272 fallback = 'Loading...';
273 } else if (fallbackType === 'nested suspense') {
274 fallback = React.createElement(
275 React.Fragment,
276 null,
277 ...createRandomChildren(3),
278 );
279 }
280
281 return React.createElement(Suspense, {fallback}, ...children);
282 }
283 case 'return':
284 default:
285 return null;
286 }
287 }
288
289 function createRandomChildren(limit) {
290 const children = [];
291 while (remainingElements > 0 && children.length < limit) {
292 children.push(createRandomChild(children.length > 0));
293 }
294 return children;
295 }
296
297 const children = createRandomChildren(Infinity);
298 return React.createElement(React.Fragment, null, ...children);
299 }
300
301 return {Container, Text, testResolvedOutput, generateTestCase};
302 }
303
304 it('basic cases', async () => {
305 // This demonstrates that the testing primitives work
306 const {Container, Text, testResolvedOutput} = createFuzzer();
307 await testResolvedOutput(
308 <Container updates={[{remountAfter: 150}]}>
309 <Text
310 text="Hi"
311 initialDelay={2000}
312 updates={[{beginAfter: 100, suspendFor: 200}]}
313 />
314 </Container>,
315 );
316 });
317
318 it(`generative tests (random seed: ${SEED})`, async () => {
319 const {generateTestCase, testResolvedOutput} = createFuzzer();
320
321 const rand = Random.create(SEED);
322
323 // If this is too large the test will time out. We use a scheduled CI
324 // workflow to run these tests with a random seed.
325 const NUMBER_OF_TEST_CASES = 250;
326 const ELEMENTS_PER_CASE = 12;
327
328 for (let i = 0; i < NUMBER_OF_TEST_CASES; i++) {
329 const randomTestCase = generateTestCase(rand, ELEMENTS_PER_CASE);
330 try {
331 await testResolvedOutput(randomTestCase);
332 } catch (e) {
333 console.log(`
334 Failed fuzzy test case:
335
336 ${prettyFormat(randomTestCase)}
337
338 Random seed is ${SEED}
339 `);
340
341 throw e;
342 }
343 }
344 });
345
346 describe('hard-coded cases', () => {
347 it('1', async () => {
348 const {Text, testResolvedOutput} = createFuzzer();
349 await testResolvedOutput(
350 <>
351 <Text
352 initialDelay={20}
353 text="A"
354 updates={[{beginAfter: 10, suspendFor: 20}]}
355 />
356 <Suspense fallback="Loading... (B)">
357 <Text
358 initialDelay={10}
359 text="B"
360 updates={[{beginAfter: 30, suspendFor: 50}]}
361 />
362 <Text text="C" />
363 </Suspense>
364 </>,
365 );
366 });
367
368 it('2', async () => {
369 const {Text, Container, testResolvedOutput} = createFuzzer();
370 await testResolvedOutput(
371 <>
372 <Suspense fallback="Loading...">
373 <Text initialDelay={7200} text="A" />
374 </Suspense>
375 <Suspense fallback="Loading...">
376 <Container>
377 <Text initialDelay={1000} text="B" />
378 <Text initialDelay={7200} text="C" />
379 <Text initialDelay={9000} text="D" />
380 </Container>
381 </Suspense>
382 </>,
383 );
384 });
385
386 it('3', async () => {
387 const {Text, Container, testResolvedOutput} = createFuzzer();
388 await testResolvedOutput(
389 <>
390 <Suspense fallback="Loading...">
391 <Text
392 initialDelay={3183}
393 text="A"
394 updates={[
395 {
396 beginAfter: 2256,
397 suspendFor: 6696,
398 },
399 ]}
400 />
401 <Text initialDelay={3251} text="B" />
402 </Suspense>
403 <Container>
404 <Text
405 initialDelay={2700}
406 text="C"
407 updates={[
408 {
409 beginAfter: 3266,
410 suspendFor: 9139,
411 },
412 ]}
413 />
414 <Text initialDelay={6732} text="D" />
415 </Container>
416 </>,
417 );
418 });
419
420 it('4', async () => {
421 const {Text, testResolvedOutput} = createFuzzer();
422 await testResolvedOutput(
423 <React.Suspense fallback="Loading...">
424 <React.Suspense>
425 <React.Suspense>
426 <Text initialDelay={9683} text="E" updates={[]} />
427 </React.Suspense>
428 <Text
429 initialDelay={4053}
430 text="C"
431 updates={[
432 {
433 beginAfter: 1566,
434 suspendFor: 4142,
435 },
436 {
437 beginAfter: 9572,
438 suspendFor: 4832,
439 },
440 ]}
441 />
442 </React.Suspense>
443 </React.Suspense>,
444 );
445 });
446 });
447 });