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