diff --git a/src/renderers/noop/ReactNoop.js b/src/renderers/noop/ReactNoop.js index 815b3d9748e..2b98dd290ee 100644 --- a/src/renderers/noop/ReactNoop.js +++ b/src/renderers/noop/ReactNoop.js @@ -212,6 +212,40 @@ var rootContainers = new Map(); var roots = new Map(); var DEFAULT_ROOT_ID = ''; +let yieldBeforeNextUnitOfWork = false; +let yieldValue = null; + +function* flushUnitsOfWork(n: number): Generator { + var didStop = false; + while (!didStop && scheduledDeferredCallback !== null) { + var cb = scheduledDeferredCallback; + scheduledDeferredCallback = null; + yieldBeforeNextUnitOfWork = false; + yieldValue = null; + var unitsRemaining = n; + var didYield = false; + cb({ + timeRemaining() { + if (yieldBeforeNextUnitOfWork) { + didYield = true; + return 0; + } + if (unitsRemaining-- > 0) { + return 999; + } + didStop = true; + return 0; + }, + }); + + if (didYield) { + const valueToYield = yieldValue; + yieldValue = null; + yield valueToYield; + } + } +} + var ReactNoop = { getChildren(rootID: string = DEFAULT_ROOT_ID) { const container = rootContainers.get(rootID); @@ -277,22 +311,17 @@ var ReactNoop = { }, flushDeferredPri(timeout: number = Infinity) { - var cb = scheduledDeferredCallback; - if (cb === null) { - return; + // The legacy version of this function decremented the timeout before + // returning the new time. + // TODO: Convert tests to use flushUnitsOfWork or flushAndYield instead. + const n = timeout / 5 - 1; + const iterator = flushUnitsOfWork(n); + let value = iterator.next(); + while (!value.done) { + value = iterator.next(); } - scheduledDeferredCallback = null; - var timeRemaining = timeout; - cb({ - timeRemaining() { - // Simulate a fix amount of time progressing between each call. - timeRemaining -= 5; - if (timeRemaining < 0) { - timeRemaining = 0; - } - return timeRemaining; - }, - }); + // Don't flush animation priority in this legacy function. Some tests may + // still rely on this behavior. }, flush() { @@ -300,6 +329,30 @@ var ReactNoop = { ReactNoop.flushDeferredPri(); }, + *flushAndYield(unitsOfWork: number = Infinity): Generator { + for (const value of flushUnitsOfWork(unitsOfWork)) { + yield value; + } + ReactNoop.flushAnimationPri(); + }, + + flushUnitsOfWork(n: number) { + const iterator = flushUnitsOfWork(n); + let value = iterator.next(); + while (!value.done) { + value = iterator.next(); + } + // TODO: We should always flush animation priority after flushing normal/low + // priority. Move this to flushUnitsOfWork generator once tests + // are converted. + ReactNoop.flushAnimationPri(); + }, + + yield(value: mixed) { + yieldBeforeNextUnitOfWork = true; + yieldValue = value; + }, + performAnimationWork(fn: Function) { NoopRenderer.performWithPriority(AnimationPriority, fn); }, diff --git a/src/renderers/shared/fiber/__tests__/ReactIncrementalTriangle-test.js b/src/renderers/shared/fiber/__tests__/ReactIncrementalTriangle-test.js new file mode 100644 index 00000000000..c99d8cbd113 --- /dev/null +++ b/src/renderers/shared/fiber/__tests__/ReactIncrementalTriangle-test.js @@ -0,0 +1,325 @@ +/** + * Copyright 2013-present, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @emails react-core + */ + +'use strict'; + +var React; +var ReactNoop; +var ReactFeatureFlags; + +describe('ReactIncrementalTriangle', () => { + beforeEach(() => { + jest.resetModules(); + React = require('react'); + ReactNoop = require('ReactNoop'); + + ReactFeatureFlags = require('ReactFeatureFlags'); + ReactFeatureFlags.disableNewFiberFeatures = false; + }); + + function span(prop) { + return {type: 'span', children: [], prop}; + } + + const FLUSH = 'FLUSH'; + function flush(unitsOfWork = Infinity) { + return { + type: FLUSH, + unitsOfWork, + }; + } + + const STEP = 'STEP'; + function step(counter) { + return { + type: STEP, + counter, + }; + } + + const INTERRUPT = 'INTERRUPT'; + function interrupt(key) { + return { + type: INTERRUPT, + }; + } + + const TOGGLE = 'TOGGLE'; + function toggle(childIndex) { + return { + type: TOGGLE, + childIndex, + }; + } + + function TriangleSimulator() { + let triangles = []; + let leafTriangles = []; + let yieldAfterEachRender = false; + class Triangle extends React.Component { + constructor(props) { + super(); + this.index = triangles.length; + triangles.push(this); + if (props.depth === 0) { + this.leafIndex = leafTriangles.length; + leafTriangles.push(this); + } + this.state = {isActive: false}; + } + activate() { + if (this.props.depth !== 0) { + throw new Error('Cannot activate non-leaf component'); + } + ReactNoop.performAnimationWork(() => { + this.setState({isActive: true}); + }); + } + deactivate() { + if (this.props.depth !== 0) { + throw new Error('Cannot deactivate non-leaf component'); + } + ReactNoop.performAnimationWork(() => { + this.setState({isActive: false}); + }); + } + shouldComponentUpdate(nextProps, nextState) { + return ( + this.props.counter !== nextProps.counter || + this.state.isActive !== nextState.isActive + ); + } + render() { + if (yieldAfterEachRender) { + ReactNoop.yield(this); + } + const {counter, depth} = this.props; + if (depth === 0) { + if (this.state.isActive) { + return ; + } + return ; + } + return [ + , + , + , + ]; + } + } + + let appInstance; + class App extends React.Component { + state = {counter: 0}; + interrupt() { + // Triggers a restart from the top. + ReactNoop.performAnimationWork(() => { + this.forceUpdate(); + }); + } + setCounter(counter) { + const currentCounter = this.state.counter; + this.setState({counter}); + return currentCounter; + } + render() { + appInstance = this; + return ; + } + } + + const depth = 3; + + let keyCounter = 0; + function reset(nextStep = 0) { + triangles = []; + leafTriangles = []; + // Remounts the whole tree by changing the key + ReactNoop.render(); + ReactNoop.flush(); + assertConsistentTree(); + return appInstance; + } + + reset(); + const totalChildren = leafTriangles.length; + const totalTriangles = triangles.length; + + function assertConsistentTree(activeTriangle, counter) { + const activeIndex = activeTriangle ? activeTriangle.leafIndex : -1; + + const children = ReactNoop.getChildren(); + for (let i = 0; i < children.length; i++) { + let child = children[i]; + let num = child.prop; + + // If an expected counter is not specified, use the value of the + // first child. + if (counter === undefined) { + if (typeof num === 'string') { + counter = num.substr(1, num.length - 2); + } else { + counter = num; + } + } + + if (i === activeIndex) { + if (num !== `*${counter}*`) { + throw new Error( + `Triangle ${i} is inconsistent: ${num} instead of *${counter}*.`, + ); + } + } else { + if (num !== counter) { + throw new Error( + `Triangle ${i} is inconsistent: ${num} instead of ${counter}.`, + ); + } + } + } + } + + function simulate(...actions) { + const app = reset(); + let expectedCounterAtEnd = app.state.counter; + + let activeTriangle = null; + for (let i = 0; i < actions.length; i++) { + const action = actions[i]; + switch (action.type) { + case FLUSH: + ReactNoop.flushUnitsOfWork(action.unitsOfWork); + break; + case STEP: + app.setCounter(action.counter); + expectedCounterAtEnd = action.counter; + break; + case INTERRUPT: + app.interrupt(); + break; + case TOGGLE: + const targetTriangle = leafTriangles[action.childIndex]; + if (targetTriangle === undefined) { + throw new Error('Target index is out of bounds'); + } + if (targetTriangle === activeTriangle) { + activeTriangle = null; + targetTriangle.deactivate(); + } else { + if (activeTriangle !== null) { + activeTriangle.deactivate(); + } + activeTriangle = targetTriangle; + targetTriangle.activate(); + } + ReactNoop.flushAnimationPri(); + break; + default: + break; + } + } + // Flush remaining work + ReactNoop.flush(); + assertConsistentTree(activeTriangle, expectedCounterAtEnd); + } + + return {simulate, totalChildren, totalTriangles}; + } + + xit('renders the triangle demo without inconsistencies', () => { + const {simulate} = TriangleSimulator(); + simulate(step(1)); + simulate(toggle(0), step(1), toggle(0)); + simulate(step(1), toggle(0), flush(2), step(2), toggle(0)); + }); + + xit('fuzz tester', () => { + // This test is not deterministic because the inputs are randomized. It runs + // a limited number of tests on every run. If it fails, it will output the + // case that led to the failure. Add the failing case to the test above + // to prevent future regressions. + const {simulate, totalTriangles, totalChildren} = TriangleSimulator(); + + const limit = 1000; + + function randomInteger(min, max) { + min = Math.ceil(min); + max = Math.floor(max); + return Math.floor(Math.random() * (max - min)) + min; + } + + function randomAction() { + switch (randomInteger(0, 4)) { + case 0: + return flush(randomInteger(0, totalTriangles * 1.5)); + case 1: + return step(randomInteger(0, 10)); + case 2: + return interrupt(); + case 3: + return toggle(randomInteger(0, totalChildren)); + default: + throw new Error('Switch statement should be exhaustive'); + } + } + + function randomActions(n) { + let actions = []; + for (let i = 0; i < n; i++) { + actions.push(randomAction()); + } + return actions; + } + + function formatActions(actions) { + let result = 'simulate('; + for (let i = 0; i < actions.length; i++) { + const action = actions[i]; + switch (action.type) { + case FLUSH: + result += `flush(${action.unitsOfWork})`; + break; + case STEP: + result += `step(${action.counter})`; + break; + case INTERRUPT: + result += 'interrupt()'; + break; + case TOGGLE: + result += `toggle(${action.childIndex})`; + break; + default: + throw new Error('Switch statement should be exhaustive'); + } + if (i !== actions.length - 1) { + result += ', '; + } + } + result += ')'; + return result; + } + + for (let i = 0; i < limit; i++) { + const actions = randomActions(5); + try { + simulate(...actions); + } catch (e) { + console.error( + ` +Triangle fuzz tester error! Copy and paste the following line into the test suite: + ${formatActions(actions)} + `, + ); + throw e; + } + } + }); +}); diff --git a/src/renderers/shared/fiber/__tests__/__snapshots__/ReactIncrementalPerf-test.js.snap b/src/renderers/shared/fiber/__tests__/__snapshots__/ReactIncrementalPerf-test.js.snap index 760598a4ce5..86ec327dfd0 100644 --- a/src/renderers/shared/fiber/__tests__/__snapshots__/ReactIncrementalPerf-test.js.snap +++ b/src/renderers/shared/fiber/__tests__/__snapshots__/ReactIncrementalPerf-test.js.snap @@ -85,6 +85,8 @@ exports[`ReactDebugFiberPerf does not treat setState from cWM or cWRP as cascadi ⚛ (Committing Changes) ⚛ (Committing Host Effects: 2 Total) ⚛ (Calling Lifecycle Methods: 2 Total) + +⚛ (React Tree Reconciliation) " `;