@samitouri / QOS-React / commits / e58ecda9a2

Suspense fuzz tester (#14147)

* Don't warn if an unmounted component is pinged * Suspense fuzz tester The fuzzer works by generating a random tree of React elements. The tree two types of custom components: - A Text component suspends rendering on initial mount for a fuzzy duration of time. It may update a fuzzy number of times; each update supsends for a fuzzy duration of time. - A Container component wraps some children. It may remount its children a fuzzy number of times, by updating its key. The tree may also include nested Suspense components. After this tree is generated, the tester sets a flag to temporarily disable Text components from suspending. The tree is rendered synchronously. The output of this render is the expected output. Then the tester flips the flag back to enable suspending. It renders the tree again. This time the Text components will suspend for the amount of time configured by the props. The tester waits until everything has resolved. The resolved output is then compared to the expected output generated in the previous step. Finally, we render once more, but this time in concurrent mode. Once again, the resolved output is compared to the expected output. I tested by commenting out various parts of the Suspense implementation to see if broke in the expected way. I also confirmed that it would have caught #14133, a recent bug related to deletions. * When a generated test case fails, log its input * Moar fuzziness Adds more fuzziness to the generated tests. Specifcally, introduces nested Suspense cases, where the fallback of a Suspense component also suspends. This flushed out a bug (yay!) whose test case I've hard coded. * Use seeded random number generator So if there's a failure, we can bisect.

Andrew Clark committed Nov 8, 2018 at 17:26 UTC e58ecda9a2381735f2c326ee99a1ffa6486321ab
4 files changed +411 -11
packages/react-reconciler/src/ReactFiberBeginWork.js
+5 -4
@@ -1178,7 +1178,6 @@ function updateSuspenseComponent(
1178 currentPrimaryChildFragment.pendingProps,
1179 NoWork,
1180 );
1181 - primaryChildFragment.effectTag |= Placement;
1181
1182 if ((workInProgress.mode & ConcurrentMode) === NoContext) {
1183 // Outside of concurrent mode, we commit the effects from the
@@ -1213,7 +1212,6 @@ function updateSuspenseComponent(
1212 nextFallbackChildren,
1213 currentFallbackChildFragment.expirationTime,
1214 ));
1216 - fallbackChildFragment.effectTag |= Placement;
1215 child = primaryChildFragment;
1216 primaryChildFragment.childExpirationTime = NoWork;
1217 // Skip the primary children, and continue working on the
@@ -1257,11 +1255,14 @@ function updateSuspenseComponent(
1255 NoWork,
1256 null,
1257 );
1260 -
1261 - primaryChildFragment.effectTag |= Placement;
1258 primaryChildFragment.child = currentPrimaryChild;
1259 currentPrimaryChild.return = primaryChildFragment;
1260
1261 + // Even though we're creating a new fiber, there are no new children,
1262 + // because we're reusing an already mounted tree. So we don't need to
1263 + // schedule a placement.
1264 + // primaryChildFragment.effectTag |= Placement;
1265 +
1266 if ((workInProgress.mode & ConcurrentMode) === NoContext) {
1267 // Outside of concurrent mode, we commit the effects from the
1268 // partially completed, timed-out tree, too.
packages/react-reconciler/src/ReactFiberCommitWork.js
+10
@@ -558,6 +558,16 @@ function hideOrUnhideAllChildren(finishedWork, isHidden) {
558 } else {
559 unhideTextInstance(instance, node.memoizedProps);
560 }
561 + } else if (
562 + node.tag === SuspenseComponent &&
563 + node.memoizedState !== null
564 + ) {
565 + // Found a nested Suspense component that timed out. Skip over the
566 + // primary child fragment, which should remain hidden.
567 + const fallbackChildFragment: Fiber = (node.child: any).sibling;
568 + fallbackChildFragment.return = node;
569 + node = fallbackChildFragment;
570 + continue;
571 } else if (node.child !== null) {
572 node.child.return = node;
573 node = node.child;
packages/react-reconciler/src/ReactFiberCompleteWork.js
+11 -7
@@ -45,6 +45,7 @@ import {
45 Update,
46 NoEffect,
47 DidCapture,
48 + Deletion,
49 } from 'shared/ReactSideEffectTags';
50 import invariant from 'shared/invariant';
51
@@ -82,7 +83,6 @@ import {
83 popHydrationState,
84 } from './ReactFiberHydrationContext';
85 import {ConcurrentMode, NoContext} from './ReactTypeOfMode';
85 -import {reconcileChildFibers} from './ReactChildFiber';
86
87 function markUpdate(workInProgress: Fiber) {
88 // Tag the fiber with an update effect. This turns a Placement into
@@ -715,12 +715,16 @@ function completeWork(
715 // the stateNode during the begin phase?
716 const currentFallbackChild: Fiber | null = (current.child: any).sibling;
717 if (currentFallbackChild !== null) {
718 - reconcileChildFibers(
719 - workInProgress,
720 - currentFallbackChild,
721 - null,
722 - renderExpirationTime,
723 - );
718 + // Deletions go at the beginning of the return fiber's effect list
719 + const first = workInProgress.firstEffect;
720 + if (first !== null) {
721 + workInProgress.firstEffect = currentFallbackChild;
722 + currentFallbackChild.nextEffect = first;
723 + } else {
724 + workInProgress.firstEffect = workInProgress.lastEffect = currentFallbackChild;
725 + currentFallbackChild.nextEffect = null;
726 + }
727 + currentFallbackChild.effectTag = Deletion;
728 }
729 }
730
packages/react-reconciler/src/__tests__/ReactSuspenseFuzz-test.internal.js new
+385
@@ -0,0 +1,385 @@
1 +let React;
2 +let Suspense;
3 +let ReactTestRenderer;
4 +let ReactFeatureFlags;
5 +let Random;
6 +
7 +const SEED = 0;
8 +
9 +const prettyFormatPkg = require('pretty-format');
10 +
11 +function prettyFormat(thing) {
12 + return prettyFormatPkg(thing, {
13 + plugins: [
14 + prettyFormatPkg.plugins.ReactElement,
15 + prettyFormatPkg.plugins.ReactTestComponent,
16 + ],
17 + });
18 +}
19 +
20 +describe('ReactSuspenseFuzz', () => {
21 + beforeEach(() => {
22 + jest.resetModules();
23 + ReactFeatureFlags = require('shared/ReactFeatureFlags');
24 + ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false;
25 + ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback = false;
26 + ReactFeatureFlags.enableHooks = true;
27 + React = require('react');
28 + Suspense = React.Suspense;
29 + ReactTestRenderer = require('react-test-renderer');
30 + Random = require('random-seed');
31 + });
32 +
33 + function createFuzzer() {
34 + const {useState, useContext, useLayoutEffect} = React;
35 +
36 + const ShouldSuspendContext = React.createContext(true);
37 +
38 + let pendingTasks = new Set();
39 + let cache = new Map();
40 +
41 + function resetCache() {
42 + pendingTasks = new Set();
43 + cache = new Map();
44 + }
45 +
46 + function Container({children, updates}) {
47 + const [step, setStep] = useState(0);
48 +
49 + useLayoutEffect(
50 + () => {
51 + if (updates !== undefined) {
52 + const cleanUps = new Set();
53 + updates.forEach(({remountAfter}, i) => {
54 + const task = {
55 + label: `Remount childen after ${remountAfter}ms`,
56 + };
57 + const timeoutID = setTimeout(() => {
58 + pendingTasks.delete(task);
59 + ReactTestRenderer.unstable_yield(task.label);
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 + },
73 + [updates],
74 + );
75 +
76 + return <React.Fragment key={step}>{children}</React.Fragment>;
77 + }
78 +
79 + function Text({text, initialDelay = 0, updates}) {
80 + const [[step, delay], setStep] = useState([0, initialDelay]);
81 +
82 + useLayoutEffect(
83 + () => {
84 + if (updates !== undefined) {
85 + const cleanUps = new Set();
86 + updates.forEach(({beginAfter, suspendFor}, i) => {
87 + const task = {
88 + label: `Update ${beginAfter}ms after mount and suspend for ${suspendFor}ms [${text}]`,
89 + };
90 + const timeoutID = setTimeout(() => {
91 + pendingTasks.delete(task);
92 + ReactTestRenderer.unstable_yield(task.label);
93 + setStep([i + 1, suspendFor]);
94 + }, beginAfter);
95 + pendingTasks.add(task);
96 + cleanUps.add(() => {
97 + pendingTasks.delete(task);
98 + clearTimeout(timeoutID);
99 + });
100 + });
101 + return () => {
102 + cleanUps.forEach(cleanUp => cleanUp());
103 + };
104 + }
105 + },
106 + [updates],
107 + );
108 +
109 + const fullText = `${text}:${step}`;
110 +
111 + const shouldSuspend = useContext(ShouldSuspendContext);
112 +
113 + let resolvedText;
114 + if (shouldSuspend && delay > 0) {
115 + resolvedText = cache.get(fullText);
116 + if (resolvedText === undefined) {
117 + const thenable = {
118 + then(resolve) {
119 + const task = {label: `Promise resolved [${fullText}]`};
120 + pendingTasks.add(task);
121 + setTimeout(() => {
122 + cache.set(fullText, fullText);
123 + pendingTasks.delete(task);
124 + ReactTestRenderer.unstable_yield(task.label);
125 + resolve();
126 + }, delay);
127 + },
128 + };
129 + cache.set(fullText, thenable);
130 + ReactTestRenderer.unstable_yield(`Suspended! [${fullText}]`);
131 + throw thenable;
132 + } else if (typeof resolvedText.then === 'function') {
133 + const thenable = resolvedText;
134 + ReactTestRenderer.unstable_yield(`Suspended! [${fullText}]`);
135 + throw thenable;
136 + }
137 + } else {
138 + resolvedText = fullText;
139 + }
140 +
141 + ReactTestRenderer.unstable_yield(resolvedText);
142 + return resolvedText;
143 + }
144 +
145 + function renderToRoot(
146 + root,
147 + children,
148 + {shouldSuspend} = {shouldSuspend: true},
149 + ) {
150 + root.update(
151 + <ShouldSuspendContext.Provider value={shouldSuspend}>
152 + {children}
153 + </ShouldSuspendContext.Provider>,
154 + );
155 + root.unstable_flushAll();
156 +
157 + let elapsedTime = 0;
158 + while (pendingTasks && pendingTasks.size > 0) {
159 + if ((elapsedTime += 1000) > 1000000) {
160 + throw new Error('Something did not resolve properly.');
161 + }
162 + jest.advanceTimersByTime(1000);
163 + root.unstable_flushAll();
164 + }
165 +
166 + return root.toJSON();
167 + }
168 +
169 + function testResolvedOutput(unwrappedChildren) {
170 + const children = (
171 + <Suspense fallback="Loading...">{unwrappedChildren}</Suspense>
172 + );
173 +
174 + const expectedRoot = ReactTestRenderer.create(null);
175 + const expectedOutput = renderToRoot(expectedRoot, children, {
176 + shouldSuspend: false,
177 + });
178 + expectedRoot.unmount();
179 +
180 + resetCache();
181 + const syncRoot = ReactTestRenderer.create(null);
182 + const syncOutput = renderToRoot(syncRoot, children);
183 + expect(syncOutput).toEqual(expectedOutput);
184 + syncRoot.unmount();
185 +
186 + resetCache();
187 + const concurrentRoot = ReactTestRenderer.create(null, {
188 + unstable_isConcurrent: true,
189 + });
190 + const concurrentOutput = renderToRoot(concurrentRoot, children);
191 + expect(concurrentOutput).toEqual(expectedOutput);
192 + concurrentRoot.unmount();
193 + concurrentRoot.unstable_flushAll();
194 +
195 + ReactTestRenderer.unstable_clearYields();
196 + }
197 +
198 + function pickRandomWeighted(rand, options) {
199 + let totalWeight = 0;
200 + for (let i = 0; i < options.length; i++) {
201 + totalWeight += options[i].weight;
202 + }
203 + let remainingWeight = rand.floatBetween(0, totalWeight);
204 + for (let i = 0; i < options.length; i++) {
205 + const {value, weight} = options[i];
206 + remainingWeight -= weight;
207 + if (remainingWeight <= 0) {
208 + return value;
209 + }
210 + }
211 + }
212 +
213 + function generateTestCase(rand, numberOfElements) {
214 + let remainingElements = numberOfElements;
215 +
216 + function createRandomChild(hasSibling) {
217 + const possibleActions = [
218 + {value: 'return', weight: 1},
219 + {value: 'text', weight: 1},
220 + ];
221 +
222 + if (hasSibling) {
223 + possibleActions.push({value: 'container', weight: 1});
224 + possibleActions.push({value: 'suspense', weight: 1});
225 + }
226 +
227 + const action = pickRandomWeighted(rand, possibleActions);
228 +
229 + switch (action) {
230 + case 'text': {
231 + remainingElements--;
232 +
233 + const numberOfUpdates = pickRandomWeighted(rand, [
234 + {value: 0, weight: 8},
235 + {value: 1, weight: 4},
236 + {value: 2, weight: 1},
237 + ]);
238 +
239 + let updates = [];
240 + for (let i = 0; i < numberOfUpdates; i++) {
241 + updates.push({
242 + beginAfter: rand.intBetween(0, 10000),
243 + suspendFor: rand.intBetween(0, 10000),
244 + });
245 + }
246 +
247 + return (
248 + <Text
249 + text={(remainingElements + 9).toString(36).toUpperCase()}
250 + initialDelay={rand.intBetween(0, 10000)}
251 + updates={updates}
252 + />
253 + );
254 + }
255 + case 'container': {
256 + const numberOfUpdates = pickRandomWeighted(rand, [
257 + {value: 0, weight: 8},
258 + {value: 1, weight: 4},
259 + {value: 2, weight: 1},
260 + ]);
261 +
262 + let updates = [];
263 + for (let i = 0; i < numberOfUpdates; i++) {
264 + updates.push({
265 + remountAfter: rand.intBetween(0, 10000),
266 + });
267 + }
268 +
269 + remainingElements--;
270 + const children = createRandomChildren(3);
271 + return React.createElement(Container, {updates}, ...children);
272 + }
273 + case 'suspense': {
274 + remainingElements--;
275 + const children = createRandomChildren(3);
276 +
277 + const maxDuration = pickRandomWeighted(rand, [
278 + {value: undefined, weight: 1},
279 + {value: rand.intBetween(0, 5000), weight: 1},
280 + ]);
281 +
282 + const fallbackType = pickRandomWeighted(rand, [
283 + {value: 'none', weight: 1},
284 + {value: 'normal', weight: 1},
285 + {value: 'nested suspense', weight: 1},
286 + ]);
287 +
288 + let fallback;
289 + if (fallbackType === 'normal') {
290 + fallback = 'Loading...';
291 + } else if (fallbackType === 'nested suspense') {
292 + fallback = React.createElement(
293 + React.Fragment,
294 + null,
295 + ...createRandomChildren(3),
296 + );
297 + }
298 +
299 + return React.createElement(
300 + Suspense,
301 + {maxDuration, fallback},
302 + ...children,
303 + );
304 + }
305 + case 'return':
306 + default:
307 + return null;
308 + }
309 + }
310 +
311 + function createRandomChildren(limit) {
312 + const children = [];
313 + while (remainingElements > 0 && children.length < limit) {
314 + children.push(createRandomChild(children.length > 0));
315 + }
316 + return children;
317 + }
318 +
319 + const children = createRandomChildren(Infinity);
320 + return React.createElement(React.Fragment, null, ...children);
321 + }
322 +
323 + return {Container, Text, testResolvedOutput, generateTestCase};
324 + }
325 +
326 + it('basic cases', () => {
327 + // This demonstrates that the testing primitives work
328 + const {Container, Text, testResolvedOutput} = createFuzzer();
329 + testResolvedOutput(
330 + <Container updates={[{remountAfter: 150}]}>
331 + <Text
332 + text="Hi"
333 + initialDelay={2000}
334 + updates={[{beginAfter: 100, suspendFor: 200}]}
335 + />
336 + </Container>,
337 + );
338 + });
339 +
340 + it('hard-coded cases', () => {
341 + const {Text, testResolvedOutput} = createFuzzer();
342 +
343 + testResolvedOutput(
344 + <React.Fragment>
345 + <Text
346 + initialDelay={20}
347 + text="A"
348 + updates={[{beginAfter: 10, suspendFor: 20}]}
349 + />
350 + <Suspense fallback="Loading... (B)">
351 + <Text
352 + initialDelay={10}
353 + text="B"
354 + updates={[{beginAfter: 30, suspendFor: 50}]}
355 + />
356 + <Text text="C" />
357 + </Suspense>
358 + </React.Fragment>,
359 + );
360 + });
361 +
362 + it('generative tests', () => {
363 + const {generateTestCase, testResolvedOutput} = createFuzzer();
364 +
365 + const rand = Random.create(SEED);
366 +
367 + const NUMBER_OF_TEST_CASES = 500;
368 + const ELEMENTS_PER_CASE = 12;
369 +
370 + for (let i = 0; i < NUMBER_OF_TEST_CASES; i++) {
371 + const randomTestCase = generateTestCase(rand, ELEMENTS_PER_CASE);
372 + try {
373 + testResolvedOutput(randomTestCase);
374 + } catch (e) {
375 + console.log(`
376 +Failed fuzzy test case:
377 +
378 +${prettyFormat(randomTestCase)}
379 +`);
380 +
381 + throw e;
382 + }
383 + }
384 + });
385 +});