When a root expires, flush all expired work in a single batch (#13503)
Instead of flushing each level one at a time.
Andrew Clark committed
Sep 6, 2018 at 07:07 UTC
f765f022534958bcf49120bf23bc1aa665e8f651
5 files changed
+243
-5
packages/react-noop-renderer/src/createReactNoop.js
+19
-5
@@ -40,6 +40,7 @@ if (__DEV__) {
40
41
function createReactNoop(reconciler: Function, useMutation: boolean) {
42
let scheduledCallback = null;
43
+ let scheduledCallbackTimeout = -1;
44
let instanceCounter = 0;
45
let hostDiffCounter = 0;
46
let hostUpdateCounter = 0;
@@ -251,7 +252,7 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
252
return inst;
253
},
254
254
- scheduleDeferredCallback(callback) {
255
+ scheduleDeferredCallback(callback, options) {
256
if (scheduledCallback) {
257
throw new Error(
258
'Scheduling a callback twice is excessive. Instead, keep track of ' +
@@ -259,6 +260,19 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
260
);
261
}
262
scheduledCallback = callback;
263
+ if (
264
+ typeof options === 'object' &&
265
+ options !== null &&
266
+ typeof options.timeout === 'number'
267
+ ) {
268
+ const newTimeout = options.timeout;
269
+ if (
270
+ scheduledCallbackTimeout === -1 ||
271
+ scheduledCallbackTimeout > newTimeout
272
+ ) {
273
+ scheduledCallbackTimeout = newTimeout;
274
+ }
275
+ }
276
return 0;
277
},
278
@@ -267,6 +281,7 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
281
throw new Error('No callback is scheduled.');
282
}
283
scheduledCallback = null;
284
+ scheduledCallbackTimeout = -1;
285
},
286
287
scheduleTimeout: setTimeout,
@@ -409,10 +424,9 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
424
didStop = true;
425
return 0;
426
},
412
- // React's scheduler has its own way of keeping track of expired
413
- // work and doesn't read this, so don't bother setting it to the
414
- // correct value.
415
- didTimeout: false,
427
+ didTimeout:
428
+ scheduledCallbackTimeout !== -1 &&
429
+ elapsedTimeInMs > scheduledCallbackTimeout,
430
});
431
432
if (yieldedValues !== null) {
packages/react-reconciler/src/ReactFiberPendingPriority.js
+11
@@ -242,6 +242,17 @@ export function findEarliestOutstandingPriorityLevel(
242
return earliestExpirationTime;
243
}
244
245
+export function didExpireAtExpirationTime(
246
+ root: FiberRoot,
247
+ currentTime: ExpirationTime,
248
+): void {
249
+ const expirationTime = root.expirationTime;
250
+ if (expirationTime !== NoWork && currentTime >= expirationTime) {
251
+ // The root has expired. Flush all work up to the current time.
252
+ root.nextExpirationTimeToWorkOn = currentTime;
253
+ }
254
+}
255
+
256
function findNextExpirationTimeToWorkOn(completedExpirationTime, root) {
257
const earliestSuspendedTime = root.earliestSuspendedTime;
258
const latestSuspendedTime = root.latestSuspendedTime;
packages/react-reconciler/src/ReactFiberScheduler.js
+17
@@ -80,6 +80,7 @@ import {
80
hasLowerPriorityWork,
81
isPriorityLevelSuspended,
82
findEarliestOutstandingPriorityLevel,
83
+ didExpireAtExpirationTime,
84
} from './ReactFiberPendingPriority';
85
import {
86
recordEffect,
@@ -2109,6 +2110,22 @@ function findHighestPriorityRoot() {
2110
}
2111
2112
function performAsyncWork(dl) {
2113
+ if (dl.didTimeout) {
2114
+ // The callback timed out. That means at least one update has expired.
2115
+ // Iterate through the root schedule. If they contain expired work, set
2116
+ // the next render expiration time to the current time. This has the effect
2117
+ // of flushing all expired work in a single batch, instead of flushing each
2118
+ // level one at a time.
2119
+ if (firstScheduledRoot !== null) {
2120
+ recomputeCurrentRendererTime();
2121
+ let root: FiberRoot = firstScheduledRoot;
2122
+ do {
2123
+ didExpireAtExpirationTime(root, currentRendererTime);
2124
+ // The root schedule is circular, so this is never null.
2125
+ root = (root.nextScheduledRoot: any);
2126
+ } while (root !== firstScheduledRoot);
2127
+ }
2128
+ }
2129
performWork(NoWork, dl);
2130
}
2131
packages/react-reconciler/src/__tests__/ReactIncrementalUpdates-test.internal.js
+149
@@ -453,4 +453,153 @@ describe('ReactIncrementalUpdates', () => {
453
});
454
expect(ReactNoop.getChildren()).toEqual([span('derived state')]);
455
});
456
+
457
+ it('flushes all expired updates in a single batch', () => {
458
+ class Foo extends React.Component {
459
+ componentDidUpdate() {
460
+ ReactNoop.yield('Commit: ' + this.props.prop);
461
+ }
462
+ componentDidMount() {
463
+ ReactNoop.yield('Commit: ' + this.props.prop);
464
+ }
465
+ render() {
466
+ ReactNoop.yield('Render: ' + this.props.prop);
467
+ return <span prop={this.props.prop} />;
468
+ }
469
+ }
470
+
471
+ // First, as a sanity check, assert what happens when four low pri
472
+ // updates in separate batches are all flushed in the same callback
473
+ ReactNoop.render(<Foo prop="" />);
474
+ ReactNoop.expire(1000);
475
+ jest.advanceTimersByTime(1000);
476
+ ReactNoop.render(<Foo prop="he" />);
477
+ ReactNoop.expire(1000);
478
+ jest.advanceTimersByTime(1000);
479
+ ReactNoop.render(<Foo prop="hell" />);
480
+ ReactNoop.expire(1000);
481
+ jest.advanceTimersByTime(1000);
482
+ ReactNoop.render(<Foo prop="hello" />);
483
+
484
+ // There should be a separate render and commit for each update
485
+ expect(ReactNoop.flush()).toEqual([
486
+ 'Render: ',
487
+ 'Commit: ',
488
+ 'Render: he',
489
+ 'Commit: he',
490
+ 'Render: hell',
491
+ 'Commit: hell',
492
+ 'Render: hello',
493
+ 'Commit: hello',
494
+ ]);
495
+ expect(ReactNoop.getChildren()).toEqual([span('hello')]);
496
+
497
+ // Now do the same thing, except this time expire all the updates
498
+ // before flushing them.
499
+ ReactNoop.render(<Foo prop="" />);
500
+ ReactNoop.expire(1000);
501
+ jest.advanceTimersByTime(1000);
502
+ ReactNoop.render(<Foo prop="go" />);
503
+ ReactNoop.expire(1000);
504
+ jest.advanceTimersByTime(1000);
505
+ ReactNoop.render(<Foo prop="good" />);
506
+ ReactNoop.expire(1000);
507
+ jest.advanceTimersByTime(1000);
508
+ ReactNoop.render(<Foo prop="goodbye" />);
509
+
510
+ ReactNoop.advanceTime(10000);
511
+ jest.advanceTimersByTime(10000);
512
+
513
+ // All the updates should render and commit in a single batch.
514
+ expect(ReactNoop.flush()).toEqual(['Render: goodbye', 'Commit: goodbye']);
515
+ expect(ReactNoop.getChildren()).toEqual([span('goodbye')]);
516
+ });
517
+
518
+ it('flushes all expired updates in a single batch across multiple roots', () => {
519
+ // Same as previous test, but with two roots.
520
+ class Foo extends React.Component {
521
+ componentDidUpdate() {
522
+ ReactNoop.yield('Commit: ' + this.props.prop);
523
+ }
524
+ componentDidMount() {
525
+ ReactNoop.yield('Commit: ' + this.props.prop);
526
+ }
527
+ render() {
528
+ ReactNoop.yield('Render: ' + this.props.prop);
529
+ return <span prop={this.props.prop} />;
530
+ }
531
+ }
532
+
533
+ // First, as a sanity check, assert what happens when four low pri
534
+ // updates in separate batches are all flushed in the same callback
535
+ ReactNoop.renderToRootWithID(<Foo prop="" />, 'a');
536
+ ReactNoop.renderToRootWithID(<Foo prop="" />, 'b');
537
+
538
+ ReactNoop.expire(1000);
539
+ jest.advanceTimersByTime(1000);
540
+ ReactNoop.renderToRootWithID(<Foo prop="he" />, 'a');
541
+ ReactNoop.renderToRootWithID(<Foo prop="he" />, 'b');
542
+
543
+ ReactNoop.expire(1000);
544
+ jest.advanceTimersByTime(1000);
545
+ ReactNoop.renderToRootWithID(<Foo prop="hell" />, 'a');
546
+ ReactNoop.renderToRootWithID(<Foo prop="hell" />, 'b');
547
+
548
+ ReactNoop.expire(1000);
549
+ jest.advanceTimersByTime(1000);
550
+ ReactNoop.renderToRootWithID(<Foo prop="hello" />, 'a');
551
+ ReactNoop.renderToRootWithID(<Foo prop="hello" />, 'b');
552
+
553
+ // There should be a separate render and commit for each update
554
+ expect(ReactNoop.flush()).toEqual([
555
+ 'Render: ',
556
+ 'Commit: ',
557
+ 'Render: ',
558
+ 'Commit: ',
559
+ 'Render: he',
560
+ 'Commit: he',
561
+ 'Render: he',
562
+ 'Commit: he',
563
+ 'Render: hell',
564
+ 'Commit: hell',
565
+ 'Render: hell',
566
+ 'Commit: hell',
567
+ 'Render: hello',
568
+ 'Commit: hello',
569
+ 'Render: hello',
570
+ 'Commit: hello',
571
+ ]);
572
+ expect(ReactNoop.getChildren('a')).toEqual([span('hello')]);
573
+ expect(ReactNoop.getChildren('b')).toEqual([span('hello')]);
574
+
575
+ // Now do the same thing, except this time expire all the updates
576
+ // before flushing them.
577
+ ReactNoop.renderToRootWithID(<Foo prop="" />, 'a');
578
+ ReactNoop.renderToRootWithID(<Foo prop="" />, 'b');
579
+ ReactNoop.expire(1000);
580
+ jest.advanceTimersByTime(1000);
581
+ ReactNoop.renderToRootWithID(<Foo prop="go" />, 'a');
582
+ ReactNoop.renderToRootWithID(<Foo prop="go" />, 'b');
583
+ ReactNoop.expire(1000);
584
+ jest.advanceTimersByTime(1000);
585
+ ReactNoop.renderToRootWithID(<Foo prop="good" />, 'a');
586
+ ReactNoop.renderToRootWithID(<Foo prop="good" />, 'b');
587
+ ReactNoop.expire(1000);
588
+ jest.advanceTimersByTime(1000);
589
+ ReactNoop.renderToRootWithID(<Foo prop="goodbye" />, 'a');
590
+ ReactNoop.renderToRootWithID(<Foo prop="goodbye" />, 'b');
591
+
592
+ ReactNoop.advanceTime(10000);
593
+ jest.advanceTimersByTime(10000);
594
+
595
+ // All the updates should render and commit in a single batch.
596
+ expect(ReactNoop.flush()).toEqual([
597
+ 'Render: goodbye',
598
+ 'Commit: goodbye',
599
+ 'Render: goodbye',
600
+ 'Commit: goodbye',
601
+ ]);
602
+ expect(ReactNoop.getChildren('a')).toEqual([span('goodbye')]);
603
+ expect(ReactNoop.getChildren('b')).toEqual([span('goodbye')]);
604
+ });
605
});
packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js
+47
@@ -782,6 +782,53 @@ describe('ReactSuspense', () => {
782
expect(ReactNoop.getChildren()).toEqual([div(span('Async'))]);
783
});
784
785
+ it('flushes all expired updates in a single batch', async () => {
786
+ class Foo extends React.Component {
787
+ componentDidUpdate() {
788
+ ReactNoop.yield('Commit: ' + this.props.text);
789
+ }
790
+ componentDidMount() {
791
+ ReactNoop.yield('Commit: ' + this.props.text);
792
+ }
793
+ render() {
794
+ return (
795
+ <Placeholder fallback={<Text text="Loading..." />}>
796
+ <AsyncText ms={20000} text={this.props.text} />
797
+ </Placeholder>
798
+ );
799
+ }
800
+ }
801
+
802
+ ReactNoop.render(<Foo text="" />);
803
+ ReactNoop.expire(1000);
804
+ jest.advanceTimersByTime(1000);
805
+ ReactNoop.render(<Foo text="go" />);
806
+ ReactNoop.expire(1000);
807
+ jest.advanceTimersByTime(1000);
808
+ ReactNoop.render(<Foo text="good" />);
809
+ ReactNoop.expire(1000);
810
+ jest.advanceTimersByTime(1000);
811
+ ReactNoop.render(<Foo text="goodbye" />);
812
+
813
+ ReactNoop.advanceTime(10000);
814
+ jest.advanceTimersByTime(10000);
815
+
816
+ expect(ReactNoop.flush()).toEqual([
817
+ 'Suspend! [goodbye]',
818
+ 'Loading...',
819
+ 'Commit: goodbye',
820
+ ]);
821
+ expect(ReactNoop.getChildren()).toEqual([span('Loading...')]);
822
+
823
+ ReactNoop.advanceTime(20000);
824
+ await advanceTimers(20000);
825
+ expect(ReactNoop.clearYields()).toEqual(['Promise resolved [goodbye]']);
826
+ expect(ReactNoop.getChildren()).toEqual([span('Loading...')]);
827
+
828
+ expect(ReactNoop.flush()).toEqual(['goodbye']);
829
+ expect(ReactNoop.getChildren()).toEqual([span('goodbye')]);
830
+ });
831
+
832
describe('a Delay component', () => {
833
function Never() {
834
// Throws a promise that resolves after some arbitrarily large