@samitouri / QOS-React / commits / 8a09a2fc53

Interactive updates (#12100)

* Updates inside controlled events (onChange) are sync even in async mode This guarantees the DOM is in a consistent state before we yield back to the browser. We'll need to figure out a separate strategy for other interactive events. * Don't rely on flushing behavior of public batchedUpdates implementation Flush work as an explicit step at the end of the event, right before restoring controlled state. * Interactive updates At the beginning of an interactive browser event (events that fire as the result of a user interaction, like a click), check for pending updates that were scheduled in a previous interactive event. Flush the pending updates synchronously so that the event handlers are up-to-date before responding to the current event. We now have three classes of events: - Controlled events. Updates are always flushed synchronously. - Interactive events. Updates are async, unless another a subsequent event is fired before it can complete, as described above. They are also slightly higher priority than a normal async update. - Non-interactive events. These are treated as normal, low-priority async updates. * Flush lowest pending interactive update time Accounts for case when multiple interactive updates are scheduled at different priorities. This can happen when an interactive event is dispatched inside an async subtree, and there's an event handler on an ancestor that is outside the subtree. * Update comment about restoring controlled components

Andrew Clark committed Jan 29, 2018 at 23:49 UTC 8a09a2fc538e98523f67022095de9120e5dc2c32
16 files changed +963 -332
packages/events/ReactControlledComponent.js
+4
@@ -63,6 +63,10 @@ export function enqueueStateRestore(target) {
63 }
64 }
65
66 +export function needsStateRestore(): boolean {
67 + return restoreTarget !== null || restoreQueue !== null;
68 +}
69 +
70 export function restoreStateIfNeeded() {
71 if (!restoreTarget) {
72 return;
packages/events/ReactGenericBatching.js
+37 -16
@@ -5,7 +5,10 @@
5 * LICENSE file in the root directory of this source tree.
6 */
7
8 -import {restoreStateIfNeeded} from './ReactControlledComponent';
8 +import {
9 + needsStateRestore,
10 + restoreStateIfNeeded,
11 +} from './ReactControlledComponent';
12
13 // Used as a way to call batchedUpdates when we don't have a reference to
14 // the renderer. Such as when we're dispatching events or if third party
@@ -14,35 +17,53 @@ import {restoreStateIfNeeded} from './ReactControlledComponent';
17 // scheduled work and instead do synchronous work.
18
19 // Defaults
17 -let fiberBatchedUpdates = function(fn, bookkeeping) {
20 +let _batchedUpdates = function(fn, bookkeeping) {
21 return fn(bookkeeping);
22 };
23 +let _interactiveUpdates = function(fn, a, b) {
24 + return fn(a, b);
25 +};
26 +let _flushInteractiveUpdates = function() {};
27
21 -let isNestingBatched = false;
28 +let isBatching = false;
29 export function batchedUpdates(fn, bookkeeping) {
23 - if (isNestingBatched) {
30 + if (isBatching) {
31 // If we are currently inside another batch, we need to wait until it
25 - // fully completes before restoring state. Therefore, we add the target to
26 - // a queue of work.
27 - return fiberBatchedUpdates(fn, bookkeeping);
32 + // fully completes before restoring state.
33 + return fn(bookkeeping);
34 }
29 - isNestingBatched = true;
35 + isBatching = true;
36 try {
31 - return fiberBatchedUpdates(fn, bookkeeping);
37 + return _batchedUpdates(fn, bookkeeping);
38 } finally {
39 // Here we wait until all updates have propagated, which is important
40 // when using controlled components within layers:
41 // https://github.com/facebook/react/issues/1698
42 // Then we restore state of any controlled component.
37 - isNestingBatched = false;
38 - restoreStateIfNeeded();
43 + isBatching = false;
44 + const controlledComponentsHavePendingUpdates = needsStateRestore();
45 + if (controlledComponentsHavePendingUpdates) {
46 + // If a controlled event was fired, we may need to restore the state of
47 + // the DOM node back to the controlled value. This is necessary when React
48 + // bails out of the update without touching the DOM.
49 + _flushInteractiveUpdates();
50 + restoreStateIfNeeded();
51 + }
52 }
53 }
54
42 -const ReactGenericBatchingInjection = {
43 - injectFiberBatchedUpdates: function(_batchedUpdates) {
44 - fiberBatchedUpdates = _batchedUpdates;
55 +export function interactiveUpdates(fn, a, b) {
56 + return _interactiveUpdates(fn, a, b);
57 +}
58 +
59 +export function flushInteractiveUpdates() {
60 + return _flushInteractiveUpdates();
61 +}
62 +
63 +export const injection = {
64 + injectRenderer(renderer) {
65 + _batchedUpdates = renderer.batchedUpdates;
66 + _interactiveUpdates = renderer.interactiveUpdates;
67 + _flushInteractiveUpdates = renderer.flushInteractiveUpdates;
68 },
69 };
47 -
48 -export const injection = ReactGenericBatchingInjection;
packages/events/ReactSyntheticEventType.js
+1
@@ -17,6 +17,7 @@ export type DispatchConfig = {
17 captured: string,
18 },
19 registrationName?: string,
20 + isInteractive?: boolean,
21 };
22
23 export type ReactSyntheticEvent = {
packages/react-dom/src/client/ReactDOM.js
+2 -4
@@ -989,9 +989,7 @@ const DOMRenderer = ReactFiberReconciler({
989 cancelDeferredCallback: ReactDOMFrameScheduling.cIC,
990 });
991
992 -ReactGenericBatching.injection.injectFiberBatchedUpdates(
993 - DOMRenderer.batchedUpdates,
994 -);
992 +ReactGenericBatching.injection.injectRenderer(DOMRenderer);
993
994 let warnedAboutHydrateAPI = false;
995
@@ -1282,7 +1280,7 @@ const ReactDOM: Object = {
1280 return createPortal(...args);
1281 },
1282
1285 - unstable_batchedUpdates: ReactGenericBatching.batchedUpdates,
1283 + unstable_batchedUpdates: DOMRenderer.batchedUpdates,
1284
1285 unstable_deferredUpdates: DOMRenderer.deferredUpdates,
1286
packages/react-dom/src/events/ReactDOMEventListener.js
+29 -3
@@ -5,7 +5,11 @@
5 * LICENSE file in the root directory of this source tree.
6 */
7
8 -import {batchedUpdates} from 'events/ReactGenericBatching';
8 +import {
9 + batchedUpdates,
10 + flushInteractiveUpdates,
11 + interactiveUpdates,
12 +} from 'events/ReactGenericBatching';
13 import {runExtractedEventsInBatch} from 'events/EventPluginHub';
14 import {isFiberMounted} from 'react-reconciler/reflection';
15 import {HostRoot} from 'shared/ReactTypeOfWork';
@@ -13,6 +17,9 @@ import {HostRoot} from 'shared/ReactTypeOfWork';
17 import {addEventBubbleListener, addEventCaptureListener} from './EventListener';
18 import getEventTarget from './getEventTarget';
19 import {getClosestInstanceFromNode} from '../client/ReactDOMComponentTree';
20 +import SimpleEventPlugin from './SimpleEventPlugin';
21 +
22 +const {isInteractiveTopLevelEventType} = SimpleEventPlugin;
23
24 const CALLBACK_BOOKKEEPING_POOL_SIZE = 10;
25 const callbackBookkeepingPool = [];
@@ -120,10 +127,15 @@ export function trapBubbledEvent(topLevelType, handlerBaseName, element) {
127 if (!element) {
128 return null;
129 }
130 + const dispatch = isInteractiveTopLevelEventType(topLevelType)
131 + ? dispatchInteractiveEvent
132 + : dispatchEvent;
133 +
134 addEventBubbleListener(
135 element,
136 handlerBaseName,
126 - dispatchEvent.bind(null, topLevelType),
137 + // Check if interactive and wrap in interactiveUpdates
138 + dispatch.bind(null, topLevelType),
139 );
140 }
141
@@ -141,13 +153,27 @@ export function trapCapturedEvent(topLevelType, handlerBaseName, element) {
153 if (!element) {
154 return null;
155 }
156 + const dispatch = isInteractiveTopLevelEventType(topLevelType)
157 + ? dispatchInteractiveEvent
158 + : dispatchEvent;
159 +
160 addEventCaptureListener(
161 element,
162 handlerBaseName,
147 - dispatchEvent.bind(null, topLevelType),
163 + // Check if interactive and wrap in interactiveUpdates
164 + dispatch.bind(null, topLevelType),
165 );
166 }
167
168 +function dispatchInteractiveEvent(topLevelType, nativeEvent) {
169 + // If there are any pending interactive updates, synchronously flush them.
170 + // This needs to happen before we read any handlers, because the effect of the
171 + // previous event may affect which handlers are called during this event.
172 + flushInteractiveUpdates();
173 + // Increase the priority of updates inside this event.
174 + interactiveUpdates(dispatchEvent, topLevelType, nativeEvent);
175 +}
176 +
177 export function dispatchEvent(topLevelType, nativeEvent) {
178 if (!_enabled) {
179 return;
packages/react-dom/src/events/SimpleEventPlugin.js
+52 -34
@@ -49,77 +49,82 @@ import getEventCharCode from './getEventCharCode';
49 * 'topAbort': { sameConfig }
50 * };
51 */
52 -const eventTypes: EventTypes = {};
53 -const topLevelEventsToDispatchConfig: {
54 - [key: TopLevelTypes]: DispatchConfig,
55 -} = {};
56 -[
57 - 'abort',
58 - 'animationEnd',
59 - 'animationIteration',
60 - 'animationStart',
52 +const interactiveEventTypeNames: Array<string> = [
53 'blur',
54 'cancel',
63 - 'canPlay',
64 - 'canPlayThrough',
55 'click',
56 'close',
57 'contextMenu',
58 'copy',
59 'cut',
60 'doubleClick',
71 - 'drag',
61 'dragEnd',
62 + 'dragStart',
63 + 'drop',
64 + 'focus',
65 + 'input',
66 + 'invalid',
67 + 'keyDown',
68 + 'keyPress',
69 + 'keyUp',
70 + 'mouseDown',
71 + 'mouseUp',
72 + 'paste',
73 + 'pause',
74 + 'play',
75 + 'rateChange',
76 + 'reset',
77 + 'seeked',
78 + 'submit',
79 + 'touchCancel',
80 + 'touchEnd',
81 + 'touchStart',
82 + 'volumeChange',
83 +];
84 +const nonInteractiveEventTypeNames: Array<string> = [
85 + 'abort',
86 + 'animationEnd',
87 + 'animationIteration',
88 + 'animationStart',
89 + 'canPlay',
90 + 'canPlayThrough',
91 + 'drag',
92 'dragEnter',
93 'dragExit',
94 'dragLeave',
95 'dragOver',
77 - 'dragStart',
78 - 'drop',
96 'durationChange',
97 'emptied',
98 'encrypted',
99 'ended',
100 'error',
84 - 'focus',
85 - 'input',
86 - 'invalid',
87 - 'keyDown',
88 - 'keyPress',
89 - 'keyUp',
101 'load',
102 'loadedData',
103 'loadedMetadata',
104 'loadStart',
94 - 'mouseDown',
105 'mouseMove',
106 'mouseOut',
107 'mouseOver',
98 - 'mouseUp',
99 - 'paste',
100 - 'pause',
101 - 'play',
108 'playing',
109 'progress',
104 - 'rateChange',
105 - 'reset',
110 'scroll',
107 - 'seeked',
111 'seeking',
112 'stalled',
110 - 'submit',
113 'suspend',
114 'timeUpdate',
115 'toggle',
114 - 'touchCancel',
115 - 'touchEnd',
116 'touchMove',
117 - 'touchStart',
117 'transitionEnd',
119 - 'volumeChange',
118 'waiting',
119 'wheel',
122 -].forEach(event => {
120 +];
121 +
122 +const eventTypes: EventTypes = {};
123 +const topLevelEventsToDispatchConfig: {
124 + [key: TopLevelTypes]: DispatchConfig,
125 +} = {};
126 +
127 +function addEventTypeNameToConfig(event: string, isInteractive: boolean) {
128 const capitalizedEvent = event[0].toUpperCase() + event.slice(1);
129 const onEvent = 'on' + capitalizedEvent;
130 const topEvent = 'top' + capitalizedEvent;
@@ -130,9 +135,17 @@ const topLevelEventsToDispatchConfig: {
135 captured: onEvent + 'Capture',
136 },
137 dependencies: [topEvent],
138 + isInteractive,
139 };
140 eventTypes[event] = type;
141 topLevelEventsToDispatchConfig[topEvent] = type;
142 +}
143 +
144 +interactiveEventTypeNames.forEach(eventTypeName => {
145 + addEventTypeNameToConfig(eventTypeName, true);
146 +});
147 +nonInteractiveEventTypeNames.forEach(eventTypeName => {
148 + addEventTypeNameToConfig(eventTypeName, false);
149 });
150
151 // Only used in DEV for exhaustiveness validation.
@@ -173,6 +186,11 @@ const knownHTMLTopLevelTypes = [
186 const SimpleEventPlugin: PluginModule<MouseEvent> = {
187 eventTypes: eventTypes,
188
189 + isInteractiveTopLevelEventType(topLevelType: TopLevelTypes): boolean {
190 + const config = topLevelEventsToDispatchConfig[topLevelType];
191 + return config !== undefined && config.isInteractive === true;
192 + },
193 +
194 extractEvents: function(
195 topLevelType: TopLevelTypes,
196 targetInst: Fiber,
packages/react-dom/src/events/__tests__/ChangeEventPlugin-test.internal.js renamed
+280 -1
@@ -10,7 +10,8 @@
10 'use strict';
11
12 const React = require('react');
13 -const ReactDOM = require('react-dom');
13 +let ReactDOM = require('react-dom');
14 +let ReactFeatureFlags;
15
16 const setUntrackedChecked = Object.getOwnPropertyDescriptor(
17 HTMLInputElement.prototype,
@@ -22,6 +23,11 @@ const setUntrackedValue = Object.getOwnPropertyDescriptor(
23 'value',
24 ).set;
25
26 +const setUntrackedTextareaValue = Object.getOwnPropertyDescriptor(
27 + HTMLTextAreaElement.prototype,
28 + 'value',
29 +).set;
30 +
31 describe('ChangeEventPlugin', () => {
32 let container;
33
@@ -439,4 +445,277 @@ describe('ChangeEventPlugin', () => {
445 document.createElement = originalCreateElement;
446 }
447 });
448 +
449 + describe('async mode', () => {
450 + beforeEach(() => {
451 + jest.resetModules();
452 + ReactFeatureFlags = require('shared/ReactFeatureFlags');
453 + ReactFeatureFlags.enableAsyncSubtreeAPI = true;
454 + ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false;
455 + ReactFeatureFlags.enableCreateRoot = true;
456 + ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false;
457 + ReactDOM = require('react-dom');
458 + });
459 + it('text input', () => {
460 + const root = ReactDOM.createRoot(container);
461 + let input;
462 +
463 + let ops = [];
464 +
465 + class ControlledInput extends React.Component {
466 + state = {value: 'initial'};
467 + onChange = event => this.setState({value: event.target.value});
468 + render() {
469 + ops.push(`render: ${this.state.value}`);
470 + const controlledValue =
471 + this.state.value === 'changed' ? 'changed [!]' : this.state.value;
472 + return (
473 + <input
474 + ref={el => (input = el)}
475 + type="text"
476 + value={controlledValue}
477 + onChange={this.onChange}
478 + />
479 + );
480 + }
481 + }
482 +
483 + // Initial mount. Test that this is async.
484 + root.render(<ControlledInput />);
485 + // Should not have flushed yet.
486 + expect(ops).toEqual([]);
487 + expect(input).toBe(undefined);
488 + // Flush callbacks.
489 + jest.runAllTimers();
490 + expect(ops).toEqual(['render: initial']);
491 + expect(input.value).toBe('initial');
492 +
493 + ops = [];
494 +
495 + // Trigger a change event.
496 + setUntrackedValue.call(input, 'changed');
497 + input.dispatchEvent(
498 + new Event('input', {bubbles: true, cancelable: true}),
499 + );
500 + // Change should synchronously flush
501 + expect(ops).toEqual(['render: changed']);
502 + // Value should be the controlled value, not the original one
503 + expect(input.value).toBe('changed [!]');
504 + });
505 +
506 + it('checkbox input', () => {
507 + const root = ReactDOM.createRoot(container);
508 + let input;
509 +
510 + let ops = [];
511 +
512 + class ControlledInput extends React.Component {
513 + state = {checked: false};
514 + onChange = event => {
515 + this.setState({checked: event.target.checked});
516 + };
517 + render() {
518 + ops.push(`render: ${this.state.checked}`);
519 + const controlledValue = this.props.reverse
520 + ? !this.state.checked
521 + : this.state.checked;
522 + return (
523 + <input
524 + ref={el => (input = el)}
525 + type="checkbox"
526 + checked={controlledValue}
527 + onChange={this.onChange}
528 + />
529 + );
530 + }
531 + }
532 +
533 + // Initial mount. Test that this is async.
534 + root.render(<ControlledInput reverse={false} />);
535 + // Should not have flushed yet.
536 + expect(ops).toEqual([]);
537 + expect(input).toBe(undefined);
538 + // Flush callbacks.
539 + jest.runAllTimers();
540 + expect(ops).toEqual(['render: false']);
541 + expect(input.checked).toBe(false);
542 +
543 + ops = [];
544 +
545 + // Trigger a change event.
546 + input.dispatchEvent(
547 + new MouseEvent('click', {bubbles: true, cancelable: true}),
548 + );
549 + // Change should synchronously flush
550 + expect(ops).toEqual(['render: true']);
551 + expect(input.checked).toBe(true);
552 +
553 + // Now let's make sure we're using the controlled value.
554 + root.render(<ControlledInput reverse={true} />);
555 + jest.runAllTimers();
556 +
557 + ops = [];
558 +
559 + // Trigger another change event.
560 + input.dispatchEvent(
561 + new MouseEvent('click', {bubbles: true, cancelable: true}),
562 + );
563 + // Change should synchronously flush
564 + expect(ops).toEqual(['render: true']);
565 + expect(input.checked).toBe(false);
566 + });
567 +
568 + it('textarea', () => {
569 + const root = ReactDOM.createRoot(container);
570 + let textarea;
571 +
572 + let ops = [];
573 +
574 + class ControlledTextarea extends React.Component {
575 + state = {value: 'initial'};
576 + onChange = event => this.setState({value: event.target.value});
577 + render() {
578 + ops.push(`render: ${this.state.value}`);
579 + const controlledValue =
580 + this.state.value === 'changed' ? 'changed [!]' : this.state.value;
581 + return (
582 + <textarea
583 + ref={el => (textarea = el)}
584 + type="text"
585 + value={controlledValue}
586 + onChange={this.onChange}
587 + />
588 + );
589 + }
590 + }
591 +
592 + // Initial mount. Test that this is async.
593 + root.render(<ControlledTextarea />);
594 + // Should not have flushed yet.
595 + expect(ops).toEqual([]);
596 + expect(textarea).toBe(undefined);
597 + // Flush callbacks.
598 + jest.runAllTimers();
599 + expect(ops).toEqual(['render: initial']);
600 + expect(textarea.value).toBe('initial');
601 +
602 + ops = [];
603 +
604 + // Trigger a change event.
605 + setUntrackedTextareaValue.call(textarea, 'changed');
606 + textarea.dispatchEvent(
607 + new Event('input', {bubbles: true, cancelable: true}),
608 + );
609 + // Change should synchronously flush
610 + expect(ops).toEqual(['render: changed']);
611 + // Value should be the controlled value, not the original one
612 + expect(textarea.value).toBe('changed [!]');
613 + });
614 +
615 + it('parent of input', () => {
616 + const root = ReactDOM.createRoot(container);
617 + let input;
618 +
619 + let ops = [];
620 +
621 + class ControlledInput extends React.Component {
622 + state = {value: 'initial'};
623 + onChange = event => this.setState({value: event.target.value});
624 + render() {
625 + ops.push(`render: ${this.state.value}`);
626 + const controlledValue =
627 + this.state.value === 'changed' ? 'changed [!]' : this.state.value;
628 + return (
629 + <div onChange={this.onChange}>
630 + <input
631 + ref={el => (input = el)}
632 + type="text"
633 + value={controlledValue}
634 + onChange={() => {
635 + // Does nothing. Parent handler is reponsible for updating.
636 + }}
637 + />
638 + </div>
639 + );
640 + }
641 + }
642 +
643 + // Initial mount. Test that this is async.
644 + root.render(<ControlledInput />);
645 + // Should not have flushed yet.
646 + expect(ops).toEqual([]);
647 + expect(input).toBe(undefined);
648 + // Flush callbacks.
649 + jest.runAllTimers();
650 + expect(ops).toEqual(['render: initial']);
651 + expect(input.value).toBe('initial');
652 +
653 + ops = [];
654 +
655 + // Trigger a change event.
656 + setUntrackedValue.call(input, 'changed');
657 + input.dispatchEvent(
658 + new Event('input', {bubbles: true, cancelable: true}),
659 + );
660 + // Change should synchronously flush
661 + expect(ops).toEqual(['render: changed']);
662 + // Value should be the controlled value, not the original one
663 + expect(input.value).toBe('changed [!]');
664 + });
665 +
666 + it('is async for non-input events', () => {
667 + const root = ReactDOM.createRoot(container);
668 + let input;
669 +
670 + let ops = [];
671 +
672 + class ControlledInput extends React.Component {
673 + state = {value: 'initial'};
674 + onChange = event => this.setState({value: event.target.value});
675 + reset = () => {
676 + this.setState({value: ''});
677 + };
678 + render() {
679 + ops.push(`render: ${this.state.value}`);
680 + const controlledValue =
681 + this.state.value === 'changed' ? 'changed [!]' : this.state.value;
682 + return (
683 + <input
684 + ref={el => (input = el)}
685 + type="text"
686 + value={controlledValue}
687 + onChange={this.onChange}
688 + onClick={this.reset}
689 + />
690 + );
691 + }
692 + }
693 +
694 + // Initial mount. Test that this is async.
695 + root.render(<ControlledInput />);
696 + // Should not have flushed yet.
697 + expect(ops).toEqual([]);
698 + expect(input).toBe(undefined);
699 + // Flush callbacks.
700 + jest.runAllTimers();
701 + expect(ops).toEqual(['render: initial']);
702 + expect(input.value).toBe('initial');
703 +
704 + ops = [];
705 +
706 + // Trigger a click event
707 + input.dispatchEvent(
708 + new Event('click', {bubbles: true, cancelable: true}),
709 + );
710 + // Nothing should have changed
711 + expect(ops).toEqual([]);
712 + expect(input.value).toBe('initial');
713 +
714 + // Flush callbacks.
715 + jest.runAllTimers();
716 + // Now the click update has flushed.
717 + expect(ops).toEqual(['render: ']);
718 + expect(input.value).toBe('');
719 + });
720 + });
721 });
packages/react-dom/src/events/__tests__/SimpleEventPlugin-test.internal.js new
+412
@@ -0,0 +1,412 @@
1 +/**
2 + * Copyright (c) 2013-present, Facebook, Inc.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @emails react-core
8 + */
9 +
10 +'use strict';
11 +
12 +describe('SimpleEventPlugin', function() {
13 + let React;
14 + let ReactDOM;
15 + let ReactTestUtils;
16 + let ReactFeatureFlags;
17 +
18 + let onClick;
19 +
20 + function expectClickThru(element) {
21 + ReactTestUtils.SimulateNative.click(ReactDOM.findDOMNode(element));
22 + expect(onClick.mock.calls.length).toBe(1);
23 + }
24 +
25 + function expectNoClickThru(element) {
26 + ReactTestUtils.SimulateNative.click(ReactDOM.findDOMNode(element));
27 + expect(onClick.mock.calls.length).toBe(0);
28 + }
29 +
30 + function mounted(element) {
31 + element = ReactTestUtils.renderIntoDocument(element);
32 + return element;
33 + }
34 +
35 + beforeEach(function() {
36 + React = require('react');
37 + ReactDOM = require('react-dom');
38 + ReactTestUtils = require('react-dom/test-utils');
39 +
40 + onClick = jest.fn();
41 + });
42 +
43 + it('A non-interactive tags click when disabled', function() {
44 + const element = <div onClick={onClick} />;
45 + expectClickThru(mounted(element));
46 + });
47 +
48 + it('A non-interactive tags clicks bubble when disabled', function() {
49 + const element = ReactTestUtils.renderIntoDocument(
50 + <div onClick={onClick}>
51 + <div />
52 + </div>,
53 + );
54 + const child = ReactDOM.findDOMNode(element).firstChild;
55 +
56 + ReactTestUtils.SimulateNative.click(child);
57 + expect(onClick.mock.calls.length).toBe(1);
58 + });
59 +
60 + it('does not register a click when clicking a child of a disabled element', function() {
61 + const element = ReactTestUtils.renderIntoDocument(
62 + <button onClick={onClick} disabled={true}>
63 + <span />
64 + </button>,
65 + );
66 + const child = ReactDOM.findDOMNode(element).querySelector('span');
67 +
68 + ReactTestUtils.SimulateNative.click(child);
69 + expect(onClick.mock.calls.length).toBe(0);
70 + });
71 +
72 + it('triggers click events for children of disabled elements', function() {
73 + const element = ReactTestUtils.renderIntoDocument(
74 + <button disabled={true}>
75 + <span onClick={onClick} />
76 + </button>,
77 + );
78 + const child = ReactDOM.findDOMNode(element).querySelector('span');
79 +
80 + ReactTestUtils.SimulateNative.click(child);
81 + expect(onClick.mock.calls.length).toBe(1);
82 + });
83 +
84 + it('triggers parent captured click events when target is a child of a disabled elements', function() {
85 + const element = ReactTestUtils.renderIntoDocument(
86 + <div onClickCapture={onClick}>
87 + <button disabled={true}>
88 + <span />
89 + </button>
90 + </div>,
91 + );
92 + const child = ReactDOM.findDOMNode(element).querySelector('span');
93 +
94 + ReactTestUtils.SimulateNative.click(child);
95 + expect(onClick.mock.calls.length).toBe(1);
96 + });
97 +
98 + it('triggers captured click events for children of disabled elements', function() {
99 + const element = ReactTestUtils.renderIntoDocument(
100 + <button disabled={true}>
101 + <span onClickCapture={onClick} />
102 + </button>,
103 + );
104 + const child = ReactDOM.findDOMNode(element).querySelector('span');
105 +
106 + ReactTestUtils.SimulateNative.click(child);
107 + expect(onClick.mock.calls.length).toBe(1);
108 + });
109 +
110 + ['button', 'input', 'select', 'textarea'].forEach(function(tagName) {
111 + describe(tagName, function() {
112 + it('should forward clicks when it starts out not disabled', () => {
113 + const element = React.createElement(tagName, {
114 + onClick: onClick,
115 + });
116 +
117 + expectClickThru(mounted(element));
118 + });
119 +
120 + it('should not forward clicks when it starts out disabled', () => {
121 + const element = React.createElement(tagName, {
122 + onClick: onClick,
123 + disabled: true,
124 + });
125 +
126 + expectNoClickThru(mounted(element));
127 + });
128 +
129 + it('should forward clicks when it becomes not disabled', () => {
130 + const container = document.createElement('div');
131 + let element = ReactDOM.render(
132 + React.createElement(tagName, {onClick: onClick, disabled: true}),
133 + container,
134 + );
135 + element = ReactDOM.render(
136 + React.createElement(tagName, {onClick: onClick}),
137 + container,
138 + );
139 + expectClickThru(element);
140 + });
141 +
142 + it('should not forward clicks when it becomes disabled', () => {
143 + const container = document.createElement('div');
144 + let element = ReactDOM.render(
145 + React.createElement(tagName, {onClick: onClick}),
146 + container,
147 + );
148 + element = ReactDOM.render(
149 + React.createElement(tagName, {onClick: onClick, disabled: true}),
150 + container,
151 + );
152 + expectNoClickThru(element);
153 + });
154 +
155 + it('should work correctly if the listener is changed', () => {
156 + const container = document.createElement('div');
157 + let element = ReactDOM.render(
158 + React.createElement(tagName, {onClick: onClick, disabled: true}),
159 + container,
160 + );
161 + element = ReactDOM.render(
162 + React.createElement(tagName, {onClick: onClick, disabled: false}),
163 + container,
164 + );
165 + expectClickThru(element);
166 + });
167 + });
168 + });
169 +
170 + describe('interactive events, in async mode', () => {
171 + beforeEach(() => {
172 + jest.resetModules();
173 + ReactFeatureFlags = require('shared/ReactFeatureFlags');
174 + ReactFeatureFlags.enableAsyncSubtreeAPI = true;
175 + ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false;
176 + ReactFeatureFlags.enableCreateRoot = true;
177 + ReactDOM = require('react-dom');
178 + });
179 +
180 + it('flushes pending interactive work before extracting event handler', () => {
181 + const container = document.createElement('div');
182 + const root = ReactDOM.createRoot(container);
183 + document.body.appendChild(container);
184 +
185 + let ops = [];
186 +
187 + let button;
188 + class Button extends React.Component {
189 + state = {disabled: false};
190 + onClick = () => {
191 + // Perform some side-effect
192 + ops.push('Side-effect');
193 + // Disable the button
194 + this.setState({disabled: true});
195 + };
196 + render() {
197 + ops.push(
198 + `render button: ${this.state.disabled ? 'disabled' : 'enabled'}`,
199 + );
200 + return (
201 + <button
202 + ref={el => (button = el)}
203 + // Handler is removed after the first click
204 + onClick={this.state.disabled ? null : this.onClick}
205 + />
206 + );
207 + }
208 + }
209 +
210 + // Initial mount
211 + root.render(<Button />);
212 + // Should not have flushed yet because it's async
213 + expect(ops).toEqual([]);
214 + expect(button).toBe(undefined);
215 + // Flush async work
216 + jest.runAllTimers();
217 + expect(ops).toEqual(['render button: enabled']);
218 +
219 + ops = [];
220 +
221 + function click() {
222 + button.dispatchEvent(
223 + new MouseEvent('click', {bubbles: true, cancelable: true}),
224 + );
225 + }
226 +
227 + // Click the button to trigger the side-effect
228 + click();
229 + expect(ops).toEqual([
230 + // The handler fired
231 + 'Side-effect',
232 + // but the component did not re-render yet, because it's async
233 + ]);
234 +
235 + ops = [];
236 +
237 + // Click the button again
238 + click();
239 + expect(ops).toEqual([
240 + // Before handling this second click event, the previous interactive
241 + // update is flushed
242 + 'render button: disabled',
243 + // The event handler was removed from the button, so there's no second
244 + // side-effect
245 + ]);
246 +
247 + ops = [];
248 +
249 + // The handler should not fire again no matter how many times we
250 + // click the handler.
251 + click();
252 + click();
253 + click();
254 + click();
255 + click();
256 + jest.runAllTimers();
257 + expect(ops).toEqual([]);
258 + });
259 +
260 + it('end result of many interactive updates is deterministic', () => {
261 + const container = document.createElement('div');
262 + const root = ReactDOM.createRoot(container);
263 + document.body.appendChild(container);
264 +
265 + let button;
266 + class Button extends React.Component {
267 + state = {count: 0};
268 + render() {
269 + return (
270 + <button
271 + ref={el => (button = el)}
272 + onClick={() =>
273 + // Intentionally not using the updater form here
274 + this.setState({count: this.state.count + 1})
275 + }>
276 + Count: {this.state.count}
277 + </button>
278 + );
279 + }
280 + }
281 +
282 + // Initial mount
283 + root.render(<Button />);
284 + // Should not have flushed yet because it's async
285 + expect(button).toBe(undefined);
286 + // Flush async work
287 + jest.runAllTimers();
288 + expect(button.textContent).toEqual('Count: 0');
289 +
290 + function click() {
291 + button.dispatchEvent(
292 + new MouseEvent('click', {bubbles: true, cancelable: true}),
293 + );
294 + }
295 +
296 + // Click the button a single time
297 + click();
298 + // The counter should not have updated yet because it's async
299 + expect(button.textContent).toEqual('Count: 0');
300 +
301 + // Click the button many more times
302 + click();
303 + click();
304 + click();
305 + click();
306 + click();
307 + click();
308 +
309 + // Flush the remaining work
310 + jest.runAllTimers();
311 + // The counter should equal the total number of clicks
312 + expect(button.textContent).toEqual('Count: 7');
313 + });
314 +
315 + it('flushes lowest pending interactive priority', () => {
316 + const container = document.createElement('div');
317 + document.body.appendChild(container);
318 +
319 + let button;
320 + class Button extends React.Component {
321 + state = {lowPriCount: 0};
322 + render() {
323 + return (
324 + <button
325 + ref={el => (button = el)}
326 + onClick={
327 + // Intentionally not using the updater form here
328 + () => this.setState({lowPriCount: this.state.lowPriCount + 1})
329 + }>
330 + High-pri count: {this.props.highPriCount}, Low-pri count:{' '}
331 + {this.state.lowPriCount}
332 + </button>
333 + );
334 + }
335 + }
336 +
337 + class Wrapper extends React.Component {
338 + state = {highPriCount: 0};
339 + render() {
340 + return (
341 + <div
342 + onClick={
343 + // Intentionally not using the updater form here
344 + () => this.setState({highPriCount: this.state.highPriCount + 1})
345 + }>
346 + <React.unstable_AsyncMode>
347 + <Button highPriCount={this.state.highPriCount} />
348 + </React.unstable_AsyncMode>
349 + </div>
350 + );
351 + }
352 + }
353 +
354 + // Initial mount
355 + ReactDOM.render(<Wrapper />, container);
356 + expect(button.textContent).toEqual('High-pri count: 0, Low-pri count: 0');
357 +
358 + function click() {
359 + button.dispatchEvent(
360 + new MouseEvent('click', {bubbles: true, cancelable: true}),
361 + );
362 + }
363 +
364 + // Click the button a single time
365 + click();
366 + // The high-pri counter should flush synchronously, but not the
367 + // low-pri counter
368 + expect(button.textContent).toEqual('High-pri count: 1, Low-pri count: 0');
369 +
370 + // Click the button many more times
371 + click();
372 + click();
373 + click();
374 + click();
375 + click();
376 + click();
377 +
378 + // Flush the remaining work
379 + jest.runAllTimers();
380 + // Both counters should equal the total number of clicks
381 + expect(button.textContent).toEqual('High-pri count: 7, Low-pri count: 7');
382 + });
383 + });
384 +
385 + describe('iOS bubbling click fix', function() {
386 + // See http://www.quirksmode.org/blog/archives/2010/09/click_event_del.html
387 +
388 + it('does not add a local click to interactive elements', function() {
389 + const container = document.createElement('div');
390 +
391 + ReactDOM.render(<button onClick={onClick} />, container);
392 +
393 + const node = container.firstChild;
394 +
395 + node.dispatchEvent(new MouseEvent('click'));
396 +
397 + expect(onClick.mock.calls.length).toBe(0);
398 + });
399 +
400 + it('adds a local click listener to non-interactive elements', function() {
401 + const container = document.createElement('div');
402 +
403 + ReactDOM.render(<div onClick={onClick} />, container);
404 +
405 + const node = container.firstChild;
406 +
407 + node.dispatchEvent(new MouseEvent('click'));
408 +
409 + expect(onClick.mock.calls.length).toBe(0);
410 + });
411 + });
412 +});
packages/react-dom/src/events/__tests__/SimpleEventPlugin-test.js deleted
-196
@@ -1,196 +0,0 @@
1 -/**
2 - * Copyright (c) 2013-present, Facebook, Inc.
3 - *
4 - * This source code is licensed under the MIT license found in the
5 - * LICENSE file in the root directory of this source tree.
6 - *
7 - * @emails react-core
8 - */
9 -
10 -'use strict';
11 -
12 -describe('SimpleEventPlugin', function() {
13 - let React;
14 - let ReactDOM;
15 - let ReactTestUtils;
16 -
17 - let onClick;
18 -
19 - function expectClickThru(element) {
20 - ReactTestUtils.SimulateNative.click(ReactDOM.findDOMNode(element));
21 - expect(onClick.mock.calls.length).toBe(1);
22 - }
23 -
24 - function expectNoClickThru(element) {
25 - ReactTestUtils.SimulateNative.click(ReactDOM.findDOMNode(element));
26 - expect(onClick.mock.calls.length).toBe(0);
27 - }
28 -
29 - function mounted(element) {
30 - element = ReactTestUtils.renderIntoDocument(element);
31 - return element;
32 - }
33 -
34 - beforeEach(function() {
35 - React = require('react');
36 - ReactDOM = require('react-dom');
37 - ReactTestUtils = require('react-dom/test-utils');
38 -
39 - onClick = jest.fn();
40 - });
41 -
42 - it('A non-interactive tags click when disabled', function() {
43 - const element = <div onClick={onClick} />;
44 - expectClickThru(mounted(element));
45 - });
46 -
47 - it('A non-interactive tags clicks bubble when disabled', function() {
48 - const element = ReactTestUtils.renderIntoDocument(
49 - <div onClick={onClick}>
50 - <div />
51 - </div>,
52 - );
53 - const child = ReactDOM.findDOMNode(element).firstChild;
54 -
55 - ReactTestUtils.SimulateNative.click(child);
56 - expect(onClick.mock.calls.length).toBe(1);
57 - });
58 -
59 - it('does not register a click when clicking a child of a disabled element', function() {
60 - const element = ReactTestUtils.renderIntoDocument(
61 - <button onClick={onClick} disabled={true}>
62 - <span />
63 - </button>,
64 - );
65 - const child = ReactDOM.findDOMNode(element).querySelector('span');
66 -
67 - ReactTestUtils.SimulateNative.click(child);
68 - expect(onClick.mock.calls.length).toBe(0);
69 - });
70 -
71 - it('triggers click events for children of disabled elements', function() {
72 - const element = ReactTestUtils.renderIntoDocument(
73 - <button disabled={true}>
74 - <span onClick={onClick} />
75 - </button>,
76 - );
77 - const child = ReactDOM.findDOMNode(element).querySelector('span');
78 -
79 - ReactTestUtils.SimulateNative.click(child);
80 - expect(onClick.mock.calls.length).toBe(1);
81 - });
82 -
83 - it('triggers parent captured click events when target is a child of a disabled elements', function() {
84 - const element = ReactTestUtils.renderIntoDocument(
85 - <div onClickCapture={onClick}>
86 - <button disabled={true}>
87 - <span />
88 - </button>
89 - </div>,
90 - );
91 - const child = ReactDOM.findDOMNode(element).querySelector('span');
92 -
93 - ReactTestUtils.SimulateNative.click(child);
94 - expect(onClick.mock.calls.length).toBe(1);
95 - });
96 -
97 - it('triggers captured click events for children of disabled elements', function() {
98 - const element = ReactTestUtils.renderIntoDocument(
99 - <button disabled={true}>
100 - <span onClickCapture={onClick} />
101 - </button>,
102 - );
103 - const child = ReactDOM.findDOMNode(element).querySelector('span');
104 -
105 - ReactTestUtils.SimulateNative.click(child);
106 - expect(onClick.mock.calls.length).toBe(1);
107 - });
108 -
109 - ['button', 'input', 'select', 'textarea'].forEach(function(tagName) {
110 - describe(tagName, function() {
111 - it('should forward clicks when it starts out not disabled', () => {
112 - const element = React.createElement(tagName, {
113 - onClick: onClick,
114 - });
115 -
116 - expectClickThru(mounted(element));
117 - });
118 -
119 - it('should not forward clicks when it starts out disabled', () => {
120 - const element = React.createElement(tagName, {
121 - onClick: onClick,
122 - disabled: true,
123 - });
124 -
125 - expectNoClickThru(mounted(element));
126 - });
127 -
128 - it('should forward clicks when it becomes not disabled', () => {
129 - const container = document.createElement('div');
130 - let element = ReactDOM.render(
131 - React.createElement(tagName, {onClick: onClick, disabled: true}),
132 - container,
133 - );
134 - element = ReactDOM.render(
135 - React.createElement(tagName, {onClick: onClick}),
136 - container,
137 - );
138 - expectClickThru(element);
139 - });
140 -
141 - it('should not forward clicks when it becomes disabled', () => {
142 - const container = document.createElement('div');
143 - let element = ReactDOM.render(
144 - React.createElement(tagName, {onClick: onClick}),
145 - container,
146 - );
147 - element = ReactDOM.render(
148 - React.createElement(tagName, {onClick: onClick, disabled: true}),
149 - container,
150 - );
151 - expectNoClickThru(element);
152 - });
153 -
154 - it('should work correctly if the listener is changed', () => {
155 - const container = document.createElement('div');
156 - let element = ReactDOM.render(
157 - React.createElement(tagName, {onClick: onClick, disabled: true}),
158 - container,
159 - );
160 - element = ReactDOM.render(
161 - React.createElement(tagName, {onClick: onClick, disabled: false}),
162 - container,
163 - );
164 - expectClickThru(element);
165 - });
166 - });
167 - });
168 -
169 - describe('iOS bubbling click fix', function() {
170 - // See http://www.quirksmode.org/blog/archives/2010/09/click_event_del.html
171 -
172 - it('does not add a local click to interactive elements', function() {
173 - const container = document.createElement('div');
174 -
175 - ReactDOM.render(<button onClick={onClick} />, container);
176 -
177 - const node = container.firstChild;
178 -
179 - node.dispatchEvent(new MouseEvent('click'));
180 -
181 - expect(onClick.mock.calls.length).toBe(0);
182 - });
183 -
184 - it('adds a local click listener to non-interactive elements', function() {
185 - const container = document.createElement('div');
186 -
187 - ReactDOM.render(<div onClick={onClick} />, container);
188 -
189 - const node = container.firstChild;
190 -
191 - node.dispatchEvent(new MouseEvent('click'));
192 -
193 - expect(onClick.mock.calls.length).toBe(0);
194 - });
195 - });
196 -});
packages/react-dom/src/test-utils/ReactTestUtils.js
+1
@@ -388,6 +388,7 @@ function makeSimulator(eventType) {
388 ReactControlledComponent.enqueueStateRestore(domNode);
389 EventPluginHub.runEventsInBatch(event, true);
390 });
391 + ReactControlledComponent.restoreStateIfNeeded();
392 };
393 }
394
packages/react-native-renderer/src/ReactFabric.js
+1 -3
@@ -32,9 +32,7 @@ import takeSnapshot from './takeSnapshot';
32
33 injectFindHostInstanceFabric(ReactFabricRenderer.findHostInstance);
34
35 -ReactGenericBatching.injection.injectFiberBatchedUpdates(
36 - ReactFabricRenderer.batchedUpdates,
37 -);
35 +ReactGenericBatching.injection.injectRenderer(ReactFabricRenderer);
36
37 const roots = new Map();
38
packages/react-native-renderer/src/ReactFabricRenderer.js
-5
@@ -16,7 +16,6 @@ import type {
16 } from './ReactNativeTypes';
17
18 import {mountSafeCallback, warnForStyleProps} from './NativeMethodsMixinUtils';
19 -import * as ReactGenericBatching from 'events/ReactGenericBatching';
19 import * as ReactNativeAttributePayload from './ReactNativeAttributePayload';
20 import * as ReactNativeFrameScheduling from './ReactNativeFrameScheduling';
21 import * as ReactNativeViewConfigRegistry from './ReactNativeViewConfigRegistry';
@@ -314,8 +313,4 @@ const ReactFabricRenderer = ReactFiberReconciler({
313 },
314 });
315
317 -ReactGenericBatching.injection.injectFiberBatchedUpdates(
318 - ReactFabricRenderer.batchedUpdates,
319 -);
320 -
316 export default ReactFabricRenderer;
packages/react-native-renderer/src/ReactNativeRenderer.js
+1 -3
@@ -34,9 +34,7 @@ import takeSnapshot from './takeSnapshot';
34
35 injectFindHostInstance(ReactNativeFiberRenderer.findHostInstance);
36
37 -ReactGenericBatching.injection.injectFiberBatchedUpdates(
38 - ReactNativeFiberRenderer.batchedUpdates,
39 -);
37 +ReactGenericBatching.injection.injectRenderer(ReactNativeFiberRenderer);
38
39 const roots = new Map();
40
packages/react-noop-renderer/src/ReactNoop.js
+2
@@ -463,6 +463,8 @@ const ReactNoop = {
463
464 unbatchedUpdates: NoopRenderer.unbatchedUpdates,
465
466 + interactiveUpdates: NoopRenderer.interactiveUpdates,
467 +
468 flushSync(fn: () => mixed) {
469 yieldedValues = [];
470 NoopRenderer.flushSync(fn);
packages/react-reconciler/src/ReactFiberReconciler.js
+11 -1
@@ -258,6 +258,7 @@ export type Reconciler<C, I, TI> = {
258 flushSync<A>(fn: () => A): A,
259 flushControlled(fn: () => mixed): void,
260 deferredUpdates<A>(fn: () => A): A,
261 + interactiveUpdates<A>(fn: () => A): A,
262 injectIntoDevTools(devToolsConfig: DevToolsConfig<I, TI>): boolean,
263 computeUniqueAsyncExpiration(): ExpirationTime,
264
@@ -303,6 +304,9 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
304 flushSync,
305 flushControlled,
306 deferredUpdates,
307 + syncUpdates,
308 + interactiveUpdates,
309 + flushInteractiveUpdates,
310 } = ReactFiberScheduler(config);
311
312 function scheduleRootUpdate(
@@ -433,10 +437,16 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
437
438 deferredUpdates,
439
436 - flushSync,
440 + syncUpdates,
441 +
442 + interactiveUpdates,
443 +
444 + flushInteractiveUpdates,
445
446 flushControlled,
447
448 + flushSync,
449 +
450 getPublicRootInstance(
451 container: OpaqueRoot,
452 ): React$Component<any, any> | PI | null {
packages/react-reconciler/src/ReactFiberScheduler.js
+130 -66
@@ -704,23 +704,16 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
704 return next;
705 }
706
707 - function workLoop(expirationTime: ExpirationTime) {
707 + function workLoop(isAsync) {
708 if (capturedErrors !== null) {
709 // If there are unhandled errors, switch to the slow work loop.
710 // TODO: How to avoid this check in the fast path? Maybe the renderer
711 // could keep track of which roots have unhandled errors and call a
712 // forked version of renderRoot.
713 - slowWorkLoopThatChecksForFailedWork(expirationTime);
713 + slowWorkLoopThatChecksForFailedWork(isAsync);
714 return;
715 }
716 - if (
717 - nextRenderExpirationTime === NoWork ||
718 - nextRenderExpirationTime > expirationTime
719 - ) {
720 - return;
721 - }
722 -
723 - if (nextRenderExpirationTime <= mostRecentCurrentTime) {
716 + if (!isAsync) {
717 // Flush all expired work.
718 while (nextUnitOfWork !== null) {
719 nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
@@ -733,15 +726,8 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
726 }
727 }
728
736 - function slowWorkLoopThatChecksForFailedWork(expirationTime: ExpirationTime) {
737 - if (
738 - nextRenderExpirationTime === NoWork ||
739 - nextRenderExpirationTime > expirationTime
740 - ) {
741 - return;
742 - }
743 -
744 - if (nextRenderExpirationTime <= mostRecentCurrentTime) {
729 + function slowWorkLoopThatChecksForFailedWork(isAsync) {
730 + if (!isAsync) {
731 // Flush all expired work.
732 while (nextUnitOfWork !== null) {
733 if (hasCapturedError(nextUnitOfWork)) {
@@ -768,7 +754,7 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
754 root: FiberRoot,
755 failedWork: Fiber,
756 boundary: Fiber,
771 - expirationTime: ExpirationTime,
757 + isAsync: boolean,
758 ) {
759 // We're going to restart the error boundary that captured the error.
760 // Conceptually, we're unwinding the stack. We need to unwind the
@@ -783,12 +769,13 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
769 nextUnitOfWork = performFailedUnitOfWork(boundary);
770
771 // Continue working.
786 - workLoop(expirationTime);
772 + workLoop(isAsync);
773 }
774
775 function renderRoot(
776 root: FiberRoot,
777 expirationTime: ExpirationTime,
778 + isAsync: boolean,
779 ): Fiber | null {
780 invariant(
781 !isWorking,
@@ -824,14 +811,14 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
811 let didError = false;
812 let error = null;
813 if (__DEV__) {
827 - invokeGuardedCallback(null, workLoop, null, expirationTime);
814 + invokeGuardedCallback(null, workLoop, null, isAsync);
815 if (hasCaughtError()) {
816 didError = true;
817 error = clearCaughtError();
818 }
819 } else {
820 try {
834 - workLoop(expirationTime);
821 + workLoop(isAsync);
822 } catch (e) {
823 didError = true;
824 error = e;
@@ -879,7 +866,7 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
866 root,
867 failedWork,
868 boundary,
882 - expirationTime,
869 + isAsync,
870 );
871 if (hasCaughtError()) {
872 didError = true;
@@ -888,7 +875,7 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
875 }
876 } else {
877 try {
891 - renderRootCatchBlock(root, failedWork, boundary, expirationTime);
878 + renderRootCatchBlock(root, failedWork, boundary, isAsync);
879 error = null;
880 } catch (e) {
881 didError = true;
@@ -1169,6 +1156,14 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
1156 return computeExpirationBucket(currentTime, expirationMs, bucketSizeMs);
1157 }
1158
1159 + function computeInteractiveExpiration() {
1160 + // Should complete within ~500ms. 600ms max.
1161 + const currentTime = recalculateCurrentTime();
1162 + const expirationMs = 500;
1163 + const bucketSizeMs = 100;
1164 + return computeExpirationBucket(currentTime, expirationMs, bucketSizeMs);
1165 + }
1166 +
1167 // Creates a unique async expiration time.
1168 function computeUniqueAsyncExpiration(): ExpirationTime {
1169 let result = computeAsyncExpiration();
@@ -1201,13 +1196,29 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
1196 // No explicit expiration context was set, and we're not currently
1197 // performing work. Calculate a new expiration time.
1198 if (fiber.mode & AsyncMode) {
1204 - // This is an async update
1205 - expirationTime = computeAsyncExpiration();
1199 + if (isBatchingInteractiveUpdates) {
1200 + // This is an interactive update
1201 + expirationTime = computeInteractiveExpiration();
1202 + } else {
1203 + // This is an async update
1204 + expirationTime = computeAsyncExpiration();
1205 + }
1206 } else {
1207 // This is a sync update
1208 expirationTime = Sync;
1209 }
1210 }
1211 + if (isBatchingInteractiveUpdates) {
1212 + // This is an interactive update. Keep track of the lowest pending
1213 + // interactive expiration time. This allows us to synchronously flush
1214 + // all interactive updates when needed.
1215 + if (
1216 + lowestPendingInteractiveExpirationTime === NoWork ||
1217 + expirationTime > lowestPendingInteractiveExpirationTime
1218 + ) {
1219 + lowestPendingInteractiveExpirationTime = expirationTime;
1220 + }
1221 + }
1222 return expirationTime;
1223 }
1224
@@ -1309,11 +1320,17 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
1320 }
1321 }
1322
1312 - function syncUpdates<A>(fn: () => A): A {
1323 + function syncUpdates<A, B, C0, D, R>(
1324 + fn: (A, B, C0, D) => R,
1325 + a: A,
1326 + b: B,
1327 + c: C0,
1328 + d: D,
1329 + ): R {
1330 const previousExpirationContext = expirationContext;
1331 expirationContext = Sync;
1332 try {
1316 - return fn();
1333 + return fn(a, b, c, d);
1334 } finally {
1335 expirationContext = previousExpirationContext;
1336 }
@@ -1331,6 +1348,7 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
1348 let isRendering: boolean = false;
1349 let nextFlushedRoot: FiberRoot | null = null;
1350 let nextFlushedExpirationTime: ExpirationTime = NoWork;
1351 + let lowestPendingInteractiveExpirationTime: ExpirationTime = NoWork;
1352 let deadlineDidExpire: boolean = false;
1353 let hasUnhandledError: boolean = false;
1354 let unhandledError: mixed | null = null;
@@ -1338,6 +1356,7 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
1356
1357 let isBatchingUpdates: boolean = false;
1358 let isUnbatchingUpdates: boolean = false;
1359 + let isBatchingInteractiveUpdates: boolean = false;
1360
1361 let completedBatches: Array<Batch> | null = null;
1362
@@ -1417,20 +1436,20 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
1436 }
1437
1438 if (isBatchingUpdates) {
1420 - // Flush work at the end of the batch.
1439 if (isUnbatchingUpdates) {
1440 + // Flush work at the end of the batch.
1441 // ...unless we're inside unbatchedUpdates, in which case we should
1442 // flush it now.
1443 nextFlushedRoot = root;
1444 nextFlushedExpirationTime = Sync;
1426 - performWorkOnRoot(root, Sync, recalculateCurrentTime());
1445 + performWorkOnRoot(root, Sync, false);
1446 }
1447 return;
1448 }
1449
1450 // TODO: Get rid of Sync and use current time?
1451 if (expirationTime === Sync) {
1433 - performWork(Sync, null);
1452 + performSyncWork();
1453 } else {
1454 scheduleCallbackWithExpiration(expirationTime);
1455 }
@@ -1513,10 +1532,18 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
1532 }
1533
1534 function performAsyncWork(dl) {
1516 - performWork(NoWork, dl);
1535 + performWork(NoWork, true, dl);
1536 + }
1537 +
1538 + function performSyncWork() {
1539 + performWork(Sync, false, null);
1540 }
1541
1519 - function performWork(minExpirationTime: ExpirationTime, dl: Deadline | null) {
1542 + function performWork(
1543 + minExpirationTime: ExpirationTime,
1544 + isAsync: boolean,
1545 + dl: Deadline | null,
1546 + ) {
1547 deadline = dl;
1548
1549 // Keep working on roots until there's no more work, or until the we reach
@@ -1528,20 +1555,32 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
1555 stopRequestCallbackTimer(didExpire);
1556 }
1557
1531 - while (
1532 - nextFlushedRoot !== null &&
1533 - nextFlushedExpirationTime !== NoWork &&
1534 - (minExpirationTime === NoWork ||
1535 - nextFlushedExpirationTime <= minExpirationTime) &&
1536 - !deadlineDidExpire
1537 - ) {
1538 - performWorkOnRoot(
1539 - nextFlushedRoot,
1540 - nextFlushedExpirationTime,
1541 - recalculateCurrentTime(),
1542 - );
1543 - // Find the next highest priority work.
1544 - findHighestPriorityRoot();
1558 + if (isAsync) {
1559 + while (
1560 + nextFlushedRoot !== null &&
1561 + nextFlushedExpirationTime !== NoWork &&
1562 + (minExpirationTime === NoWork ||
1563 + minExpirationTime >= nextFlushedExpirationTime) &&
1564 + (!deadlineDidExpire ||
1565 + recalculateCurrentTime() >= nextFlushedExpirationTime)
1566 + ) {
1567 + performWorkOnRoot(
1568 + nextFlushedRoot,
1569 + nextFlushedExpirationTime,
1570 + !deadlineDidExpire,
1571 + );
1572 + findHighestPriorityRoot();
1573 + }
1574 + } else {
1575 + while (
1576 + nextFlushedRoot !== null &&
1577 + nextFlushedExpirationTime !== NoWork &&
1578 + (minExpirationTime === NoWork ||
1579 + minExpirationTime >= nextFlushedExpirationTime)
1580 + ) {
1581 + performWorkOnRoot(nextFlushedRoot, nextFlushedExpirationTime, false);
1582 + findHighestPriorityRoot();
1583 + }
1584 }
1585
1586 // We're done flushing work. Either we ran out of time in this callback,
@@ -1574,7 +1613,7 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
1613 // Perform work on root as if the given expiration time is the current time.
1614 // This has the effect of synchronously flushing all work up to and
1615 // including the given time.
1577 - performWorkOnRoot(root, expirationTime, expirationTime);
1616 + performWorkOnRoot(root, expirationTime, false);
1617 finishRendering();
1618 }
1619
@@ -1606,7 +1645,7 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
1645 function performWorkOnRoot(
1646 root: FiberRoot,
1647 expirationTime: ExpirationTime,
1609 - currentTime: ExpirationTime,
1648 + isAsync: boolean,
1649 ) {
1650 invariant(
1651 !isRendering,
@@ -1617,7 +1656,7 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
1656 isRendering = true;
1657
1658 // Check if this is async work or sync/expired work.
1620 - if (expirationTime <= currentTime) {
1659 + if (!isAsync) {
1660 // Flush sync work.
1661 let finishedWork = root.finishedWork;
1662 if (finishedWork !== null) {
@@ -1625,7 +1664,7 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
1664 completeRoot(root, finishedWork, expirationTime);
1665 } else {
1666 root.finishedWork = null;
1628 - finishedWork = renderRoot(root, expirationTime);
1667 + finishedWork = renderRoot(root, expirationTime, false);
1668 if (finishedWork !== null) {
1669 // We've completed the root. Commit it.
1670 completeRoot(root, finishedWork, expirationTime);
@@ -1639,7 +1678,7 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
1678 completeRoot(root, finishedWork, expirationTime);
1679 } else {
1680 root.finishedWork = null;
1642 - finishedWork = renderRoot(root, expirationTime);
1681 + finishedWork = renderRoot(root, expirationTime, true);
1682 if (finishedWork !== null) {
1683 // We've completed the root. Check the deadline one more time
1684 // before committing.
@@ -1727,40 +1766,62 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
1766 } finally {
1767 isBatchingUpdates = previousIsBatchingUpdates;
1768 if (!isBatchingUpdates && !isRendering) {
1730 - performWork(Sync, null);
1769 + performSyncWork();
1770 }
1771 }
1772 }
1773
1774 // TODO: Batching should be implemented at the renderer level, not inside
1775 // the reconciler.
1737 - function unbatchedUpdates<A>(fn: () => A): A {
1776 + function unbatchedUpdates<A, R>(fn: (a: A) => R, a: A): R {
1777 if (isBatchingUpdates && !isUnbatchingUpdates) {
1778 isUnbatchingUpdates = true;
1779 try {
1741 - return fn();
1780 + return fn(a);
1781 } finally {
1782 isUnbatchingUpdates = false;
1783 }
1784 }
1746 - return fn();
1785 + return fn(a);
1786 }
1787
1788 // TODO: Batching should be implemented at the renderer level, not within
1789 // the reconciler.
1751 - function flushSync<A>(fn: () => A): A {
1790 + function flushSync<A, R>(fn: (a: A) => R, a: A): R {
1791 + invariant(
1792 + !isRendering,
1793 + 'flushSync was called from inside a lifecycle method. It cannot be ' +
1794 + 'called when React is already rendering.',
1795 + );
1796 const previousIsBatchingUpdates = isBatchingUpdates;
1797 isBatchingUpdates = true;
1798 try {
1755 - return syncUpdates(fn);
1799 + return syncUpdates(fn, a);
1800 } finally {
1801 isBatchingUpdates = previousIsBatchingUpdates;
1758 - invariant(
1759 - !isRendering,
1760 - 'flushSync was called from inside a lifecycle method. It cannot be ' +
1761 - 'called when React is already rendering.',
1762 - );
1763 - performWork(Sync, null);
1802 + performSyncWork();
1803 + }
1804 + }
1805 +
1806 + function interactiveUpdates<A, B, R>(fn: (A, B) => R, a: A, b: B): R {
1807 + if (isBatchingInteractiveUpdates) {
1808 + return fn(a, b);
1809 + }
1810 +
1811 + const previousIsBatchingInteractiveUpdates = isBatchingInteractiveUpdates;
1812 + isBatchingInteractiveUpdates = true;
1813 + try {
1814 + return fn(a, b);
1815 + } finally {
1816 + isBatchingInteractiveUpdates = previousIsBatchingInteractiveUpdates;
1817 + }
1818 + }
1819 +
1820 + function flushInteractiveUpdates() {
1821 + if (!isRendering && lowestPendingInteractiveExpirationTime !== NoWork) {
1822 + // Synchronously flush pending interactive updates.
1823 + performWork(lowestPendingInteractiveExpirationTime, false, null);
1824 + lowestPendingInteractiveExpirationTime = NoWork;
1825 }
1826 }
1827
@@ -1772,7 +1833,7 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
1833 } finally {
1834 isBatchingUpdates = previousIsBatchingUpdates;
1835 if (!isBatchingUpdates && !isRendering) {
1775 - performWork(Sync, null);
1836 + performWork(Sync, false, null);
1837 }
1838 }
1839 }
@@ -1787,6 +1848,9 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
1848 flushSync,
1849 flushControlled,
1850 deferredUpdates,
1851 + syncUpdates,
1852 + interactiveUpdates,
1853 + flushInteractiveUpdates,
1854 computeUniqueAsyncExpiration,
1855 };
1856 }