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
+});