From 077adddd7a646c30432e2e82355b40f5dfe503d1 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Tue, 28 Aug 2018 12:57:56 -0700 Subject: [PATCH] When a root expires, flush all expired work in a single batch Instead of flushing each level one at a time. --- .../src/createReactNoop.js | 24 ++- .../src/ReactFiberPendingPriority.js | 11 ++ .../src/ReactFiberScheduler.js | 17 ++ .../ReactIncrementalUpdates-test.internal.js | 149 ++++++++++++++++++ .../__tests__/ReactSuspense-test.internal.js | 47 ++++++ 5 files changed, 243 insertions(+), 5 deletions(-) diff --git a/packages/react-noop-renderer/src/createReactNoop.js b/packages/react-noop-renderer/src/createReactNoop.js index 98da3b8053b7..11424d5cbbff 100644 --- a/packages/react-noop-renderer/src/createReactNoop.js +++ b/packages/react-noop-renderer/src/createReactNoop.js @@ -40,6 +40,7 @@ if (__DEV__) { function createReactNoop(reconciler: Function, useMutation: boolean) { let scheduledCallback = null; + let scheduledCallbackTimeout = -1; let instanceCounter = 0; let hostDiffCounter = 0; let hostUpdateCounter = 0; @@ -251,7 +252,7 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { return inst; }, - scheduleDeferredCallback(callback) { + scheduleDeferredCallback(callback, options) { if (scheduledCallback) { throw new Error( 'Scheduling a callback twice is excessive. Instead, keep track of ' + @@ -259,6 +260,19 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { ); } scheduledCallback = callback; + if ( + typeof options === 'object' && + options !== null && + typeof options.timeout === 'number' + ) { + const newTimeout = options.timeout; + if ( + scheduledCallbackTimeout === -1 || + scheduledCallbackTimeout > newTimeout + ) { + scheduledCallbackTimeout = newTimeout; + } + } return 0; }, @@ -267,6 +281,7 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { throw new Error('No callback is scheduled.'); } scheduledCallback = null; + scheduledCallbackTimeout = -1; }, scheduleTimeout: setTimeout, @@ -409,10 +424,9 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { didStop = true; return 0; }, - // React's scheduler has its own way of keeping track of expired - // work and doesn't read this, so don't bother setting it to the - // correct value. - didTimeout: false, + didTimeout: + scheduledCallbackTimeout !== -1 && + elapsedTimeInMs > scheduledCallbackTimeout, }); if (yieldedValues !== null) { diff --git a/packages/react-reconciler/src/ReactFiberPendingPriority.js b/packages/react-reconciler/src/ReactFiberPendingPriority.js index 0e5c60637694..ebf445c20306 100644 --- a/packages/react-reconciler/src/ReactFiberPendingPriority.js +++ b/packages/react-reconciler/src/ReactFiberPendingPriority.js @@ -242,6 +242,17 @@ export function findEarliestOutstandingPriorityLevel( return earliestExpirationTime; } +export function didExpireAtExpirationTime( + root: FiberRoot, + currentTime: ExpirationTime, +): void { + const expirationTime = root.expirationTime; + if (expirationTime !== NoWork && currentTime >= expirationTime) { + // The root has expired. Flush all work up to the current time. + root.nextExpirationTimeToWorkOn = currentTime; + } +} + function findNextExpirationTimeToWorkOn(completedExpirationTime, root) { const earliestSuspendedTime = root.earliestSuspendedTime; const latestSuspendedTime = root.latestSuspendedTime; diff --git a/packages/react-reconciler/src/ReactFiberScheduler.js b/packages/react-reconciler/src/ReactFiberScheduler.js index 327cafccbc1f..7d3f601120dd 100644 --- a/packages/react-reconciler/src/ReactFiberScheduler.js +++ b/packages/react-reconciler/src/ReactFiberScheduler.js @@ -80,6 +80,7 @@ import { hasLowerPriorityWork, isPriorityLevelSuspended, findEarliestOutstandingPriorityLevel, + didExpireAtExpirationTime, } from './ReactFiberPendingPriority'; import { recordEffect, @@ -2109,6 +2110,22 @@ function findHighestPriorityRoot() { } function performAsyncWork(dl) { + if (dl.didTimeout) { + // The callback timed out. That means at least one update has expired. + // Iterate through the root schedule. If they contain expired work, set + // the next render expiration time to the current time. This has the effect + // of flushing all expired work in a single batch, instead of flushing each + // level one at a time. + if (firstScheduledRoot !== null) { + recomputeCurrentRendererTime(); + let root: FiberRoot = firstScheduledRoot; + do { + didExpireAtExpirationTime(root, currentRendererTime); + // The root schedule is circular, so this is never null. + root = (root.nextScheduledRoot: any); + } while (root !== firstScheduledRoot); + } + } performWork(NoWork, dl); } diff --git a/packages/react-reconciler/src/__tests__/ReactIncrementalUpdates-test.internal.js b/packages/react-reconciler/src/__tests__/ReactIncrementalUpdates-test.internal.js index f33100e3cbbd..00b9b1aff529 100644 --- a/packages/react-reconciler/src/__tests__/ReactIncrementalUpdates-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactIncrementalUpdates-test.internal.js @@ -453,4 +453,153 @@ describe('ReactIncrementalUpdates', () => { }); expect(ReactNoop.getChildren()).toEqual([span('derived state')]); }); + + it('flushes all expired updates in a single batch', () => { + class Foo extends React.Component { + componentDidUpdate() { + ReactNoop.yield('Commit: ' + this.props.prop); + } + componentDidMount() { + ReactNoop.yield('Commit: ' + this.props.prop); + } + render() { + ReactNoop.yield('Render: ' + this.props.prop); + return ; + } + } + + // First, as a sanity check, assert what happens when four low pri + // updates in separate batches are all flushed in the same callback + ReactNoop.render(); + ReactNoop.expire(1000); + jest.advanceTimersByTime(1000); + ReactNoop.render(); + ReactNoop.expire(1000); + jest.advanceTimersByTime(1000); + ReactNoop.render(); + ReactNoop.expire(1000); + jest.advanceTimersByTime(1000); + ReactNoop.render(); + + // There should be a separate render and commit for each update + expect(ReactNoop.flush()).toEqual([ + 'Render: ', + 'Commit: ', + 'Render: he', + 'Commit: he', + 'Render: hell', + 'Commit: hell', + 'Render: hello', + 'Commit: hello', + ]); + expect(ReactNoop.getChildren()).toEqual([span('hello')]); + + // Now do the same thing, except this time expire all the updates + // before flushing them. + ReactNoop.render(); + ReactNoop.expire(1000); + jest.advanceTimersByTime(1000); + ReactNoop.render(); + ReactNoop.expire(1000); + jest.advanceTimersByTime(1000); + ReactNoop.render(); + ReactNoop.expire(1000); + jest.advanceTimersByTime(1000); + ReactNoop.render(); + + ReactNoop.advanceTime(10000); + jest.advanceTimersByTime(10000); + + // All the updates should render and commit in a single batch. + expect(ReactNoop.flush()).toEqual(['Render: goodbye', 'Commit: goodbye']); + expect(ReactNoop.getChildren()).toEqual([span('goodbye')]); + }); + + it('flushes all expired updates in a single batch across multiple roots', () => { + // Same as previous test, but with two roots. + class Foo extends React.Component { + componentDidUpdate() { + ReactNoop.yield('Commit: ' + this.props.prop); + } + componentDidMount() { + ReactNoop.yield('Commit: ' + this.props.prop); + } + render() { + ReactNoop.yield('Render: ' + this.props.prop); + return ; + } + } + + // First, as a sanity check, assert what happens when four low pri + // updates in separate batches are all flushed in the same callback + ReactNoop.renderToRootWithID(, 'a'); + ReactNoop.renderToRootWithID(, 'b'); + + ReactNoop.expire(1000); + jest.advanceTimersByTime(1000); + ReactNoop.renderToRootWithID(, 'a'); + ReactNoop.renderToRootWithID(, 'b'); + + ReactNoop.expire(1000); + jest.advanceTimersByTime(1000); + ReactNoop.renderToRootWithID(, 'a'); + ReactNoop.renderToRootWithID(, 'b'); + + ReactNoop.expire(1000); + jest.advanceTimersByTime(1000); + ReactNoop.renderToRootWithID(, 'a'); + ReactNoop.renderToRootWithID(, 'b'); + + // There should be a separate render and commit for each update + expect(ReactNoop.flush()).toEqual([ + 'Render: ', + 'Commit: ', + 'Render: ', + 'Commit: ', + 'Render: he', + 'Commit: he', + 'Render: he', + 'Commit: he', + 'Render: hell', + 'Commit: hell', + 'Render: hell', + 'Commit: hell', + 'Render: hello', + 'Commit: hello', + 'Render: hello', + 'Commit: hello', + ]); + expect(ReactNoop.getChildren('a')).toEqual([span('hello')]); + expect(ReactNoop.getChildren('b')).toEqual([span('hello')]); + + // Now do the same thing, except this time expire all the updates + // before flushing them. + ReactNoop.renderToRootWithID(, 'a'); + ReactNoop.renderToRootWithID(, 'b'); + ReactNoop.expire(1000); + jest.advanceTimersByTime(1000); + ReactNoop.renderToRootWithID(, 'a'); + ReactNoop.renderToRootWithID(, 'b'); + ReactNoop.expire(1000); + jest.advanceTimersByTime(1000); + ReactNoop.renderToRootWithID(, 'a'); + ReactNoop.renderToRootWithID(, 'b'); + ReactNoop.expire(1000); + jest.advanceTimersByTime(1000); + ReactNoop.renderToRootWithID(, 'a'); + ReactNoop.renderToRootWithID(, 'b'); + + ReactNoop.advanceTime(10000); + jest.advanceTimersByTime(10000); + + // All the updates should render and commit in a single batch. + expect(ReactNoop.flush()).toEqual([ + 'Render: goodbye', + 'Commit: goodbye', + 'Render: goodbye', + 'Commit: goodbye', + ]); + expect(ReactNoop.getChildren('a')).toEqual([span('goodbye')]); + expect(ReactNoop.getChildren('b')).toEqual([span('goodbye')]); + }); }); diff --git a/packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js b/packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js index ecbf53ed5eab..82ae91526632 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js @@ -782,6 +782,53 @@ describe('ReactSuspense', () => { expect(ReactNoop.getChildren()).toEqual([div(span('Async'))]); }); + it('flushes all expired updates in a single batch', async () => { + class Foo extends React.Component { + componentDidUpdate() { + ReactNoop.yield('Commit: ' + this.props.text); + } + componentDidMount() { + ReactNoop.yield('Commit: ' + this.props.text); + } + render() { + return ( + }> + + + ); + } + } + + ReactNoop.render(); + ReactNoop.expire(1000); + jest.advanceTimersByTime(1000); + ReactNoop.render(); + ReactNoop.expire(1000); + jest.advanceTimersByTime(1000); + ReactNoop.render(); + ReactNoop.expire(1000); + jest.advanceTimersByTime(1000); + ReactNoop.render(); + + ReactNoop.advanceTime(10000); + jest.advanceTimersByTime(10000); + + expect(ReactNoop.flush()).toEqual([ + 'Suspend! [goodbye]', + 'Loading...', + 'Commit: goodbye', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Loading...')]); + + ReactNoop.advanceTime(20000); + await advanceTimers(20000); + expect(ReactNoop.clearYields()).toEqual(['Promise resolved [goodbye]']); + expect(ReactNoop.getChildren()).toEqual([span('Loading...')]); + + expect(ReactNoop.flush()).toEqual(['goodbye']); + expect(ReactNoop.getChildren()).toEqual([span('goodbye')]); + }); + describe('a Delay component', () => { function Never() { // Throws a promise that resolves after some arbitrarily large