forked from facebook/react
-
Notifications
You must be signed in to change notification settings - Fork 9
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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 facebook#14133, a recent bug related to deletions.
- Loading branch information
Showing
1 changed file
with
328 additions
and
0 deletions.
There are no files selected for viewing
328 changes: 328 additions & 0 deletions
328
packages/react-reconciler/src/__tests__/ReactSuspenseFuzz-test.internal.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,328 @@ | ||
let React; | ||
let Suspense; | ||
let ReactTestRenderer; | ||
let ReactFeatureFlags; | ||
|
||
// const prettyFormatPkg = require('pretty-format'); | ||
// function prettyFormat(thing) { | ||
// prettyFormatPkg(thing, { | ||
// plugins: [ | ||
// prettyFormatPkg.plugins.ReactElement, | ||
// prettyFormatPkg.plugins.ReactTestComponent, | ||
// ], | ||
// }); | ||
// } | ||
|
||
describe('ReactSuspenseFuzz', () => { | ||
beforeEach(() => { | ||
jest.resetModules(); | ||
ReactFeatureFlags = require('shared/ReactFeatureFlags'); | ||
ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false; | ||
ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback = false; | ||
ReactFeatureFlags.enableHooks = true; | ||
React = require('react'); | ||
Suspense = React.Suspense; | ||
ReactTestRenderer = require('react-test-renderer'); | ||
}); | ||
|
||
function createFuzzer() { | ||
const {useState, useContext, useLayoutEffect} = React; | ||
|
||
const ShouldSuspendContext = React.createContext(true); | ||
|
||
let pendingTasks = new Set(); | ||
let cache = new Map(); | ||
|
||
function resetCache() { | ||
pendingTasks = new Set(); | ||
cache = new Map(); | ||
} | ||
|
||
function Container({children, updates}) { | ||
const [step, setStep] = useState(0); | ||
|
||
useLayoutEffect( | ||
() => { | ||
if (updates !== undefined) { | ||
const cleanUps = new Set(); | ||
updates.forEach(({remountAfter}, i) => { | ||
const task = { | ||
label: `Remount childen after ${remountAfter}ms`, | ||
}; | ||
const timeoutID = setTimeout(() => { | ||
pendingTasks.delete(task); | ||
ReactTestRenderer.unstable_yield(task.label); | ||
setStep(i + 1); | ||
}, remountAfter); | ||
pendingTasks.add(task); | ||
cleanUps.add(() => { | ||
pendingTasks.delete(task); | ||
clearTimeout(timeoutID); | ||
}); | ||
}); | ||
return () => { | ||
cleanUps.forEach(cleanUp => cleanUp()); | ||
}; | ||
} | ||
}, | ||
[updates], | ||
); | ||
|
||
return <React.Fragment key={step}>{children}</React.Fragment>; | ||
} | ||
|
||
function Text({text, initialDelay = 0, updates}) { | ||
const [[step, delay], setStep] = useState([0, initialDelay]); | ||
|
||
useLayoutEffect( | ||
() => { | ||
if (updates !== undefined) { | ||
const cleanUps = new Set(); | ||
updates.forEach(({beginAfter, suspendFor}, i) => { | ||
const task = { | ||
label: `Update ${beginAfter}ms after mount and suspend for ${suspendFor}ms [${text}]`, | ||
}; | ||
const timeoutID = setTimeout(() => { | ||
pendingTasks.delete(task); | ||
ReactTestRenderer.unstable_yield(task.label); | ||
setStep([i + 1, suspendFor]); | ||
}, beginAfter); | ||
pendingTasks.add(task); | ||
cleanUps.add(() => { | ||
pendingTasks.delete(task); | ||
clearTimeout(timeoutID); | ||
}); | ||
}); | ||
return () => { | ||
cleanUps.forEach(cleanUp => cleanUp()); | ||
}; | ||
} | ||
}, | ||
[updates], | ||
); | ||
|
||
const fullText = `${text}:${step}`; | ||
|
||
const shouldSuspend = useContext(ShouldSuspendContext); | ||
|
||
let resolvedText; | ||
if (shouldSuspend && delay > 0) { | ||
resolvedText = cache.get(fullText); | ||
if (resolvedText === undefined) { | ||
const thenable = { | ||
then(resolve) { | ||
const task = {label: `Promise resolved [${fullText}]`}; | ||
pendingTasks.add(task); | ||
setTimeout(() => { | ||
cache.set(fullText, fullText); | ||
pendingTasks.delete(task); | ||
ReactTestRenderer.unstable_yield(task.label); | ||
resolve(); | ||
}, delay); | ||
}, | ||
}; | ||
cache.set(fullText, thenable); | ||
ReactTestRenderer.unstable_yield(`Suspended! [${fullText}]`); | ||
throw thenable; | ||
} else if (typeof resolvedText.then === 'function') { | ||
const thenable = resolvedText; | ||
ReactTestRenderer.unstable_yield(`Suspended! [${fullText}]`); | ||
throw thenable; | ||
} | ||
} else { | ||
resolvedText = fullText; | ||
} | ||
|
||
ReactTestRenderer.unstable_yield(resolvedText); | ||
return resolvedText; | ||
} | ||
|
||
function renderToRoot( | ||
root, | ||
children, | ||
{shouldSuspend} = {shouldSuspend: true}, | ||
) { | ||
root.update( | ||
<ShouldSuspendContext.Provider value={shouldSuspend}> | ||
{children} | ||
</ShouldSuspendContext.Provider>, | ||
); | ||
root.unstable_flushAll(); | ||
|
||
let elapsedTime = 0; | ||
while (pendingTasks && pendingTasks.size > 0) { | ||
if ((elapsedTime += 1000) > 1000000) { | ||
throw new Error('Something did not resolve properly.'); | ||
} | ||
jest.advanceTimersByTime(1000); | ||
root.unstable_flushAll(); | ||
} | ||
|
||
return root.toJSON(); | ||
} | ||
|
||
function testResolvedOutput(unwrappedChildren) { | ||
const children = ( | ||
<Suspense fallback="Loading...">{unwrappedChildren}</Suspense> | ||
); | ||
|
||
const expectedRoot = ReactTestRenderer.create(null); | ||
const expectedOutput = renderToRoot(expectedRoot, children, { | ||
shouldSuspend: false, | ||
}); | ||
|
||
resetCache(); | ||
const syncRoot = ReactTestRenderer.create(null); | ||
const syncOutput = renderToRoot(syncRoot, children); | ||
expect(syncOutput).toEqual(expectedOutput); | ||
|
||
resetCache(); | ||
const concurrentRoot = ReactTestRenderer.create(null, { | ||
unstable_isConcurrent: true, | ||
}); | ||
const concurrentOutput = renderToRoot(concurrentRoot, children); | ||
expect(concurrentOutput).toEqual(expectedOutput); | ||
|
||
ReactTestRenderer.unstable_clearYields(); | ||
} | ||
|
||
function pickRandomWeighted(options) { | ||
let totalWeight = 0; | ||
for (let i = 0; i < options.length; i++) { | ||
totalWeight += options[i].weight; | ||
} | ||
const randomNumber = Math.random() * totalWeight; | ||
let remainingWeight = randomNumber; | ||
for (let i = 0; i < options.length; i++) { | ||
const {value, weight} = options[i]; | ||
remainingWeight -= weight; | ||
if (remainingWeight <= 0) { | ||
return value; | ||
} | ||
} | ||
} | ||
|
||
function randomInteger(min, max) { | ||
min = Math.ceil(min); | ||
max = Math.floor(max); | ||
return Math.floor(Math.random() * (max - min)) + min; | ||
} | ||
|
||
function generateTestCase(numberOfElements) { | ||
let remainingElements = numberOfElements; | ||
|
||
function createRandomChild(hasSibling) { | ||
const possibleActions = [ | ||
{value: 'return', weight: 1}, | ||
{value: 'text', weight: 1}, | ||
]; | ||
|
||
if (hasSibling) { | ||
possibleActions.push({value: 'container', weight: 1}); | ||
possibleActions.push({value: 'suspense', weight: 1}); | ||
} | ||
|
||
const action = pickRandomWeighted(possibleActions); | ||
|
||
switch (action) { | ||
case 'text': { | ||
remainingElements--; | ||
|
||
const numberOfUpdates = pickRandomWeighted([ | ||
{value: 0, weight: 8}, | ||
{value: 1, weight: 4}, | ||
{value: 2, weight: 1}, | ||
]); | ||
|
||
let updates = []; | ||
for (let i = 0; i < numberOfUpdates; i++) { | ||
updates.push({ | ||
beginAfter: randomInteger(0, 10000), | ||
suspendFor: randomInteger(0, 10000), | ||
}); | ||
} | ||
|
||
return ( | ||
<Text | ||
text={(remainingElements + 9).toString(36).toUpperCase()} | ||
initialDelay={randomInteger(0, 10000)} | ||
updates={updates} | ||
/> | ||
); | ||
} | ||
case 'container': { | ||
const numberOfUpdates = pickRandomWeighted([ | ||
{value: 0, weight: 8}, | ||
{value: 1, weight: 4}, | ||
{value: 2, weight: 1}, | ||
]); | ||
|
||
let updates = []; | ||
for (let i = 0; i < numberOfUpdates; i++) { | ||
updates.push({ | ||
remountAfter: randomInteger(0, 10000), | ||
}); | ||
} | ||
|
||
remainingElements--; | ||
const children = createRandomChildren(3); | ||
return React.createElement(Container, {updates}, ...children); | ||
} | ||
case 'suspense': { | ||
remainingElements--; | ||
const children = createRandomChildren(3); | ||
|
||
const maxDuration = pickRandomWeighted([ | ||
{value: undefined, weight: 1}, | ||
{value: randomInteger(0, 5000), weight: 1}, | ||
]); | ||
|
||
return React.createElement(Suspense, {maxDuration}, ...children); | ||
} | ||
case 'return': | ||
default: | ||
return null; | ||
} | ||
} | ||
|
||
function createRandomChildren(limit) { | ||
const children = []; | ||
while (remainingElements > 0 && children.length < limit) { | ||
children.push(createRandomChild(children.length > 0)); | ||
} | ||
return children; | ||
} | ||
|
||
const children = createRandomChildren(Infinity); | ||
return React.createElement(React.Fragment, null, ...children); | ||
} | ||
|
||
return {Container, Text, testResolvedOutput, generateTestCase}; | ||
} | ||
|
||
it('basic cases', () => { | ||
// This demonstrates that the testing primitives work | ||
const {Container, Text, testResolvedOutput} = createFuzzer(); | ||
testResolvedOutput( | ||
<Container updates={[{remountAfter: 150}]}> | ||
<Text | ||
text="Hi" | ||
initialDelay={2000} | ||
updates={[{beginAfter: 100, suspendFor: 200}]} | ||
/> | ||
</Container>, | ||
); | ||
}); | ||
|
||
it('generative tests', () => { | ||
const {generateTestCase, testResolvedOutput} = createFuzzer(); | ||
|
||
const NUMBER_OF_TEST_CASES = 500; | ||
const ELEMENTS_PER_CASE = 8; | ||
|
||
for (let i = 0; i < NUMBER_OF_TEST_CASES; i++) { | ||
const randomTestCase = generateTestCase(ELEMENTS_PER_CASE); | ||
testResolvedOutput(randomTestCase); | ||
} | ||
}); | ||
}); |