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