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
This commit is contained in:
Andrew Clark 2018-01-29 23:49:10 -08:00 committed by GitHub
parent 3e08e60a34
commit 8a09a2fc53
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 966 additions and 335 deletions

View File

@ -63,6 +63,10 @@ export function enqueueStateRestore(target) {
}
}
export function needsStateRestore(): boolean {
return restoreTarget !== null || restoreQueue !== null;
}
export function restoreStateIfNeeded() {
if (!restoreTarget) {
return;

View File

@ -5,7 +5,10 @@
* LICENSE file in the root directory of this source tree.
*/
import {restoreStateIfNeeded} from './ReactControlledComponent';
import {
needsStateRestore,
restoreStateIfNeeded,
} from './ReactControlledComponent';
// Used as a way to call batchedUpdates when we don't have a reference to
// the renderer. Such as when we're dispatching events or if third party
@ -14,35 +17,53 @@ import {restoreStateIfNeeded} from './ReactControlledComponent';
// scheduled work and instead do synchronous work.
// Defaults
let fiberBatchedUpdates = function(fn, bookkeeping) {
let _batchedUpdates = function(fn, bookkeeping) {
return fn(bookkeeping);
};
let _interactiveUpdates = function(fn, a, b) {
return fn(a, b);
};
let _flushInteractiveUpdates = function() {};
let isNestingBatched = false;
let isBatching = false;
export function batchedUpdates(fn, bookkeeping) {
if (isNestingBatched) {
if (isBatching) {
// If we are currently inside another batch, we need to wait until it
// fully completes before restoring state. Therefore, we add the target to
// a queue of work.
return fiberBatchedUpdates(fn, bookkeeping);
// fully completes before restoring state.
return fn(bookkeeping);
}
isNestingBatched = true;
isBatching = true;
try {
return fiberBatchedUpdates(fn, bookkeeping);
return _batchedUpdates(fn, bookkeeping);
} finally {
// Here we wait until all updates have propagated, which is important
// when using controlled components within layers:
// https://github.com/facebook/react/issues/1698
// Then we restore state of any controlled component.
isNestingBatched = false;
restoreStateIfNeeded();
isBatching = false;
const controlledComponentsHavePendingUpdates = needsStateRestore();
if (controlledComponentsHavePendingUpdates) {
// If a controlled event was fired, we may need to restore the state of
// the DOM node back to the controlled value. This is necessary when React
// bails out of the update without touching the DOM.
_flushInteractiveUpdates();
restoreStateIfNeeded();
}
}
}
const ReactGenericBatchingInjection = {
injectFiberBatchedUpdates: function(_batchedUpdates) {
fiberBatchedUpdates = _batchedUpdates;
export function interactiveUpdates(fn, a, b) {
return _interactiveUpdates(fn, a, b);
}
export function flushInteractiveUpdates() {
return _flushInteractiveUpdates();
}
export const injection = {
injectRenderer(renderer) {
_batchedUpdates = renderer.batchedUpdates;
_interactiveUpdates = renderer.interactiveUpdates;
_flushInteractiveUpdates = renderer.flushInteractiveUpdates;
},
};
export const injection = ReactGenericBatchingInjection;

View File

@ -17,6 +17,7 @@ export type DispatchConfig = {
captured: string,
},
registrationName?: string,
isInteractive?: boolean,
};
export type ReactSyntheticEvent = {

View File

@ -989,9 +989,7 @@ const DOMRenderer = ReactFiberReconciler({
cancelDeferredCallback: ReactDOMFrameScheduling.cIC,
});
ReactGenericBatching.injection.injectFiberBatchedUpdates(
DOMRenderer.batchedUpdates,
);
ReactGenericBatching.injection.injectRenderer(DOMRenderer);
let warnedAboutHydrateAPI = false;
@ -1282,7 +1280,7 @@ const ReactDOM: Object = {
return createPortal(...args);
},
unstable_batchedUpdates: ReactGenericBatching.batchedUpdates,
unstable_batchedUpdates: DOMRenderer.batchedUpdates,
unstable_deferredUpdates: DOMRenderer.deferredUpdates,

View File

@ -5,7 +5,11 @@
* LICENSE file in the root directory of this source tree.
*/
import {batchedUpdates} from 'events/ReactGenericBatching';
import {
batchedUpdates,
flushInteractiveUpdates,
interactiveUpdates,
} from 'events/ReactGenericBatching';
import {runExtractedEventsInBatch} from 'events/EventPluginHub';
import {isFiberMounted} from 'react-reconciler/reflection';
import {HostRoot} from 'shared/ReactTypeOfWork';
@ -13,6 +17,9 @@ import {HostRoot} from 'shared/ReactTypeOfWork';
import {addEventBubbleListener, addEventCaptureListener} from './EventListener';
import getEventTarget from './getEventTarget';
import {getClosestInstanceFromNode} from '../client/ReactDOMComponentTree';
import SimpleEventPlugin from './SimpleEventPlugin';
const {isInteractiveTopLevelEventType} = SimpleEventPlugin;
const CALLBACK_BOOKKEEPING_POOL_SIZE = 10;
const callbackBookkeepingPool = [];
@ -120,10 +127,15 @@ export function trapBubbledEvent(topLevelType, handlerBaseName, element) {
if (!element) {
return null;
}
const dispatch = isInteractiveTopLevelEventType(topLevelType)
? dispatchInteractiveEvent
: dispatchEvent;
addEventBubbleListener(
element,
handlerBaseName,
dispatchEvent.bind(null, topLevelType),
// Check if interactive and wrap in interactiveUpdates
dispatch.bind(null, topLevelType),
);
}
@ -141,13 +153,27 @@ export function trapCapturedEvent(topLevelType, handlerBaseName, element) {
if (!element) {
return null;
}
const dispatch = isInteractiveTopLevelEventType(topLevelType)
? dispatchInteractiveEvent
: dispatchEvent;
addEventCaptureListener(
element,
handlerBaseName,
dispatchEvent.bind(null, topLevelType),
// Check if interactive and wrap in interactiveUpdates
dispatch.bind(null, topLevelType),
);
}
function dispatchInteractiveEvent(topLevelType, nativeEvent) {
// If there are any pending interactive updates, synchronously flush them.
// This needs to happen before we read any handlers, because the effect of the
// previous event may affect which handlers are called during this event.
flushInteractiveUpdates();
// Increase the priority of updates inside this event.
interactiveUpdates(dispatchEvent, topLevelType, nativeEvent);
}
export function dispatchEvent(topLevelType, nativeEvent) {
if (!_enabled) {
return;

View File

@ -49,77 +49,82 @@ import getEventCharCode from './getEventCharCode';
* 'topAbort': { sameConfig }
* };
*/
const eventTypes: EventTypes = {};
const topLevelEventsToDispatchConfig: {
[key: TopLevelTypes]: DispatchConfig,
} = {};
[
'abort',
'animationEnd',
'animationIteration',
'animationStart',
const interactiveEventTypeNames: Array<string> = [
'blur',
'cancel',
'canPlay',
'canPlayThrough',
'click',
'close',
'contextMenu',
'copy',
'cut',
'doubleClick',
'drag',
'dragEnd',
'dragEnter',
'dragExit',
'dragLeave',
'dragOver',
'dragStart',
'drop',
'durationChange',
'emptied',
'encrypted',
'ended',
'error',
'focus',
'input',
'invalid',
'keyDown',
'keyPress',
'keyUp',
'load',
'loadedData',
'loadedMetadata',
'loadStart',
'mouseDown',
'mouseMove',
'mouseOut',
'mouseOver',
'mouseUp',
'paste',
'pause',
'play',
'playing',
'progress',
'rateChange',
'reset',
'scroll',
'seeked',
'submit',
'touchCancel',
'touchEnd',
'touchStart',
'volumeChange',
];
const nonInteractiveEventTypeNames: Array<string> = [
'abort',
'animationEnd',
'animationIteration',
'animationStart',
'canPlay',
'canPlayThrough',
'drag',
'dragEnter',
'dragExit',
'dragLeave',
'dragOver',
'durationChange',
'emptied',
'encrypted',
'ended',
'error',
'load',
'loadedData',
'loadedMetadata',
'loadStart',
'mouseMove',
'mouseOut',
'mouseOver',
'playing',
'progress',
'scroll',
'seeking',
'stalled',
'submit',
'suspend',
'timeUpdate',
'toggle',
'touchCancel',
'touchEnd',
'touchMove',
'touchStart',
'transitionEnd',
'volumeChange',
'waiting',
'wheel',
].forEach(event => {
];
const eventTypes: EventTypes = {};
const topLevelEventsToDispatchConfig: {
[key: TopLevelTypes]: DispatchConfig,
} = {};
function addEventTypeNameToConfig(event: string, isInteractive: boolean) {
const capitalizedEvent = event[0].toUpperCase() + event.slice(1);
const onEvent = 'on' + capitalizedEvent;
const topEvent = 'top' + capitalizedEvent;
@ -130,9 +135,17 @@ const topLevelEventsToDispatchConfig: {
captured: onEvent + 'Capture',
},
dependencies: [topEvent],
isInteractive,
};
eventTypes[event] = type;
topLevelEventsToDispatchConfig[topEvent] = type;
}
interactiveEventTypeNames.forEach(eventTypeName => {
addEventTypeNameToConfig(eventTypeName, true);
});
nonInteractiveEventTypeNames.forEach(eventTypeName => {
addEventTypeNameToConfig(eventTypeName, false);
});
// Only used in DEV for exhaustiveness validation.
@ -173,6 +186,11 @@ const knownHTMLTopLevelTypes = [
const SimpleEventPlugin: PluginModule<MouseEvent> = {
eventTypes: eventTypes,
isInteractiveTopLevelEventType(topLevelType: TopLevelTypes): boolean {
const config = topLevelEventsToDispatchConfig[topLevelType];
return config !== undefined && config.isInteractive === true;
},
extractEvents: function(
topLevelType: TopLevelTypes,
targetInst: Fiber,

View File

@ -10,7 +10,8 @@
'use strict';
const React = require('react');
const ReactDOM = require('react-dom');
let ReactDOM = require('react-dom');
let ReactFeatureFlags;
const setUntrackedChecked = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
@ -22,6 +23,11 @@ const setUntrackedValue = Object.getOwnPropertyDescriptor(
'value',
).set;
const setUntrackedTextareaValue = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
'value',
).set;
describe('ChangeEventPlugin', () => {
let container;
@ -439,4 +445,277 @@ describe('ChangeEventPlugin', () => {
document.createElement = originalCreateElement;
}
});
describe('async mode', () => {
beforeEach(() => {
jest.resetModules();
ReactFeatureFlags = require('shared/ReactFeatureFlags');
ReactFeatureFlags.enableAsyncSubtreeAPI = true;
ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false;
ReactFeatureFlags.enableCreateRoot = true;
ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false;
ReactDOM = require('react-dom');
});
it('text input', () => {
const root = ReactDOM.createRoot(container);
let input;
let ops = [];
class ControlledInput extends React.Component {
state = {value: 'initial'};
onChange = event => this.setState({value: event.target.value});
render() {
ops.push(`render: ${this.state.value}`);
const controlledValue =
this.state.value === 'changed' ? 'changed [!]' : this.state.value;
return (
<input
ref={el => (input = el)}
type="text"
value={controlledValue}
onChange={this.onChange}
/>
);
}
}
// Initial mount. Test that this is async.
root.render(<ControlledInput />);
// Should not have flushed yet.
expect(ops).toEqual([]);
expect(input).toBe(undefined);
// Flush callbacks.
jest.runAllTimers();
expect(ops).toEqual(['render: initial']);
expect(input.value).toBe('initial');
ops = [];
// Trigger a change event.
setUntrackedValue.call(input, 'changed');
input.dispatchEvent(
new Event('input', {bubbles: true, cancelable: true}),
);
// Change should synchronously flush
expect(ops).toEqual(['render: changed']);
// Value should be the controlled value, not the original one
expect(input.value).toBe('changed [!]');
});
it('checkbox input', () => {
const root = ReactDOM.createRoot(container);
let input;
let ops = [];
class ControlledInput extends React.Component {
state = {checked: false};
onChange = event => {
this.setState({checked: event.target.checked});
};
render() {
ops.push(`render: ${this.state.checked}`);
const controlledValue = this.props.reverse
? !this.state.checked
: this.state.checked;
return (
<input
ref={el => (input = el)}
type="checkbox"
checked={controlledValue}
onChange={this.onChange}
/>
);
}
}
// Initial mount. Test that this is async.
root.render(<ControlledInput reverse={false} />);
// Should not have flushed yet.
expect(ops).toEqual([]);
expect(input).toBe(undefined);
// Flush callbacks.
jest.runAllTimers();
expect(ops).toEqual(['render: false']);
expect(input.checked).toBe(false);
ops = [];
// Trigger a change event.
input.dispatchEvent(
new MouseEvent('click', {bubbles: true, cancelable: true}),
);
// Change should synchronously flush
expect(ops).toEqual(['render: true']);
expect(input.checked).toBe(true);
// Now let's make sure we're using the controlled value.
root.render(<ControlledInput reverse={true} />);
jest.runAllTimers();
ops = [];
// Trigger another change event.
input.dispatchEvent(
new MouseEvent('click', {bubbles: true, cancelable: true}),
);
// Change should synchronously flush
expect(ops).toEqual(['render: true']);
expect(input.checked).toBe(false);
});
it('textarea', () => {
const root = ReactDOM.createRoot(container);
let textarea;
let ops = [];
class ControlledTextarea extends React.Component {
state = {value: 'initial'};
onChange = event => this.setState({value: event.target.value});
render() {
ops.push(`render: ${this.state.value}`);
const controlledValue =
this.state.value === 'changed' ? 'changed [!]' : this.state.value;
return (
<textarea
ref={el => (textarea = el)}
type="text"
value={controlledValue}
onChange={this.onChange}
/>
);
}
}
// Initial mount. Test that this is async.
root.render(<ControlledTextarea />);
// Should not have flushed yet.
expect(ops).toEqual([]);
expect(textarea).toBe(undefined);
// Flush callbacks.
jest.runAllTimers();
expect(ops).toEqual(['render: initial']);
expect(textarea.value).toBe('initial');
ops = [];
// Trigger a change event.
setUntrackedTextareaValue.call(textarea, 'changed');
textarea.dispatchEvent(
new Event('input', {bubbles: true, cancelable: true}),
);
// Change should synchronously flush
expect(ops).toEqual(['render: changed']);
// Value should be the controlled value, not the original one
expect(textarea.value).toBe('changed [!]');
});
it('parent of input', () => {
const root = ReactDOM.createRoot(container);
let input;
let ops = [];
class ControlledInput extends React.Component {
state = {value: 'initial'};
onChange = event => this.setState({value: event.target.value});
render() {
ops.push(`render: ${this.state.value}`);
const controlledValue =
this.state.value === 'changed' ? 'changed [!]' : this.state.value;
return (
<div onChange={this.onChange}>
<input
ref={el => (input = el)}
type="text"
value={controlledValue}
onChange={() => {
// Does nothing. Parent handler is reponsible for updating.
}}
/>
</div>
);
}
}
// Initial mount. Test that this is async.
root.render(<ControlledInput />);
// Should not have flushed yet.
expect(ops).toEqual([]);
expect(input).toBe(undefined);
// Flush callbacks.
jest.runAllTimers();
expect(ops).toEqual(['render: initial']);
expect(input.value).toBe('initial');
ops = [];
// Trigger a change event.
setUntrackedValue.call(input, 'changed');
input.dispatchEvent(
new Event('input', {bubbles: true, cancelable: true}),
);
// Change should synchronously flush
expect(ops).toEqual(['render: changed']);
// Value should be the controlled value, not the original one
expect(input.value).toBe('changed [!]');
});
it('is async for non-input events', () => {
const root = ReactDOM.createRoot(container);
let input;
let ops = [];
class ControlledInput extends React.Component {
state = {value: 'initial'};
onChange = event => this.setState({value: event.target.value});
reset = () => {
this.setState({value: ''});
};
render() {
ops.push(`render: ${this.state.value}`);
const controlledValue =
this.state.value === 'changed' ? 'changed [!]' : this.state.value;
return (
<input
ref={el => (input = el)}
type="text"
value={controlledValue}
onChange={this.onChange}
onClick={this.reset}
/>
);
}
}
// Initial mount. Test that this is async.
root.render(<ControlledInput />);
// Should not have flushed yet.
expect(ops).toEqual([]);
expect(input).toBe(undefined);
// Flush callbacks.
jest.runAllTimers();
expect(ops).toEqual(['render: initial']);
expect(input.value).toBe('initial');
ops = [];
// Trigger a click event
input.dispatchEvent(
new Event('click', {bubbles: true, cancelable: true}),
);
// Nothing should have changed
expect(ops).toEqual([]);
expect(input.value).toBe('initial');
// Flush callbacks.
jest.runAllTimers();
// Now the click update has flushed.
expect(ops).toEqual(['render: ']);
expect(input.value).toBe('');
});
});
});

View File

@ -0,0 +1,412 @@
/**
* Copyright (c) 2013-present, Facebook, Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @emails react-core
*/
'use strict';
describe('SimpleEventPlugin', function() {
let React;
let ReactDOM;
let ReactTestUtils;
let ReactFeatureFlags;
let onClick;
function expectClickThru(element) {
ReactTestUtils.SimulateNative.click(ReactDOM.findDOMNode(element));
expect(onClick.mock.calls.length).toBe(1);
}
function expectNoClickThru(element) {
ReactTestUtils.SimulateNative.click(ReactDOM.findDOMNode(element));
expect(onClick.mock.calls.length).toBe(0);
}
function mounted(element) {
element = ReactTestUtils.renderIntoDocument(element);
return element;
}
beforeEach(function() {
React = require('react');
ReactDOM = require('react-dom');
ReactTestUtils = require('react-dom/test-utils');
onClick = jest.fn();
});
it('A non-interactive tags click when disabled', function() {
const element = <div onClick={onClick} />;
expectClickThru(mounted(element));
});
it('A non-interactive tags clicks bubble when disabled', function() {
const element = ReactTestUtils.renderIntoDocument(
<div onClick={onClick}>
<div />
</div>,
);
const child = ReactDOM.findDOMNode(element).firstChild;
ReactTestUtils.SimulateNative.click(child);
expect(onClick.mock.calls.length).toBe(1);
});
it('does not register a click when clicking a child of a disabled element', function() {
const element = ReactTestUtils.renderIntoDocument(
<button onClick={onClick} disabled={true}>
<span />
</button>,
);
const child = ReactDOM.findDOMNode(element).querySelector('span');
ReactTestUtils.SimulateNative.click(child);
expect(onClick.mock.calls.length).toBe(0);
});
it('triggers click events for children of disabled elements', function() {
const element = ReactTestUtils.renderIntoDocument(
<button disabled={true}>
<span onClick={onClick} />
</button>,
);
const child = ReactDOM.findDOMNode(element).querySelector('span');
ReactTestUtils.SimulateNative.click(child);
expect(onClick.mock.calls.length).toBe(1);
});
it('triggers parent captured click events when target is a child of a disabled elements', function() {
const element = ReactTestUtils.renderIntoDocument(
<div onClickCapture={onClick}>
<button disabled={true}>
<span />
</button>
</div>,
);
const child = ReactDOM.findDOMNode(element).querySelector('span');
ReactTestUtils.SimulateNative.click(child);
expect(onClick.mock.calls.length).toBe(1);
});
it('triggers captured click events for children of disabled elements', function() {
const element = ReactTestUtils.renderIntoDocument(
<button disabled={true}>
<span onClickCapture={onClick} />
</button>,
);
const child = ReactDOM.findDOMNode(element).querySelector('span');
ReactTestUtils.SimulateNative.click(child);
expect(onClick.mock.calls.length).toBe(1);
});
['button', 'input', 'select', 'textarea'].forEach(function(tagName) {
describe(tagName, function() {
it('should forward clicks when it starts out not disabled', () => {
const element = React.createElement(tagName, {
onClick: onClick,
});
expectClickThru(mounted(element));
});
it('should not forward clicks when it starts out disabled', () => {
const element = React.createElement(tagName, {
onClick: onClick,
disabled: true,
});
expectNoClickThru(mounted(element));
});
it('should forward clicks when it becomes not disabled', () => {
const container = document.createElement('div');
let element = ReactDOM.render(
React.createElement(tagName, {onClick: onClick, disabled: true}),
container,
);
element = ReactDOM.render(
React.createElement(tagName, {onClick: onClick}),
container,
);
expectClickThru(element);
});
it('should not forward clicks when it becomes disabled', () => {
const container = document.createElement('div');
let element = ReactDOM.render(
React.createElement(tagName, {onClick: onClick}),
container,
);
element = ReactDOM.render(
React.createElement(tagName, {onClick: onClick, disabled: true}),
container,
);
expectNoClickThru(element);
});
it('should work correctly if the listener is changed', () => {
const container = document.createElement('div');
let element = ReactDOM.render(
React.createElement(tagName, {onClick: onClick, disabled: true}),
container,
);
element = ReactDOM.render(
React.createElement(tagName, {onClick: onClick, disabled: false}),
container,
);
expectClickThru(element);
});
});
});
describe('interactive events, in async mode', () => {
beforeEach(() => {
jest.resetModules();
ReactFeatureFlags = require('shared/ReactFeatureFlags');
ReactFeatureFlags.enableAsyncSubtreeAPI = true;
ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false;
ReactFeatureFlags.enableCreateRoot = true;
ReactDOM = require('react-dom');
});
it('flushes pending interactive work before extracting event handler', () => {
const container = document.createElement('div');
const root = ReactDOM.createRoot(container);
document.body.appendChild(container);
let ops = [];
let button;
class Button extends React.Component {
state = {disabled: false};
onClick = () => {
// Perform some side-effect
ops.push('Side-effect');
// Disable the button
this.setState({disabled: true});
};
render() {
ops.push(
`render button: ${this.state.disabled ? 'disabled' : 'enabled'}`,
);
return (
<button
ref={el => (button = el)}
// Handler is removed after the first click
onClick={this.state.disabled ? null : this.onClick}
/>
);
}
}
// Initial mount
root.render(<Button />);
// Should not have flushed yet because it's async
expect(ops).toEqual([]);
expect(button).toBe(undefined);
// Flush async work
jest.runAllTimers();
expect(ops).toEqual(['render button: enabled']);
ops = [];
function click() {
button.dispatchEvent(
new MouseEvent('click', {bubbles: true, cancelable: true}),
);
}
// Click the button to trigger the side-effect
click();
expect(ops).toEqual([
// The handler fired
'Side-effect',
// but the component did not re-render yet, because it's async
]);
ops = [];
// Click the button again
click();
expect(ops).toEqual([
// Before handling this second click event, the previous interactive
// update is flushed
'render button: disabled',
// The event handler was removed from the button, so there's no second
// side-effect
]);
ops = [];
// The handler should not fire again no matter how many times we
// click the handler.
click();
click();
click();
click();
click();
jest.runAllTimers();
expect(ops).toEqual([]);
});
it('end result of many interactive updates is deterministic', () => {
const container = document.createElement('div');
const root = ReactDOM.createRoot(container);
document.body.appendChild(container);
let button;
class Button extends React.Component {
state = {count: 0};
render() {
return (
<button
ref={el => (button = el)}
onClick={() =>
// Intentionally not using the updater form here
this.setState({count: this.state.count + 1})
}>
Count: {this.state.count}
</button>
);
}
}
// Initial mount
root.render(<Button />);
// Should not have flushed yet because it's async
expect(button).toBe(undefined);
// Flush async work
jest.runAllTimers();
expect(button.textContent).toEqual('Count: 0');
function click() {
button.dispatchEvent(
new MouseEvent('click', {bubbles: true, cancelable: true}),
);
}
// Click the button a single time
click();
// The counter should not have updated yet because it's async
expect(button.textContent).toEqual('Count: 0');
// Click the button many more times
click();
click();
click();
click();
click();
click();
// Flush the remaining work
jest.runAllTimers();
// The counter should equal the total number of clicks
expect(button.textContent).toEqual('Count: 7');
});
it('flushes lowest pending interactive priority', () => {
const container = document.createElement('div');
document.body.appendChild(container);
let button;
class Button extends React.Component {
state = {lowPriCount: 0};
render() {
return (
<button
ref={el => (button = el)}
onClick={
// Intentionally not using the updater form here
() => this.setState({lowPriCount: this.state.lowPriCount + 1})
}>
High-pri count: {this.props.highPriCount}, Low-pri count:{' '}
{this.state.lowPriCount}
</button>
);
}
}
class Wrapper extends React.Component {
state = {highPriCount: 0};
render() {
return (
<div
onClick={
// Intentionally not using the updater form here
() => this.setState({highPriCount: this.state.highPriCount + 1})
}>
<React.unstable_AsyncMode>
<Button highPriCount={this.state.highPriCount} />
</React.unstable_AsyncMode>
</div>
);
}
}
// Initial mount
ReactDOM.render(<Wrapper />, container);
expect(button.textContent).toEqual('High-pri count: 0, Low-pri count: 0');
function click() {
button.dispatchEvent(
new MouseEvent('click', {bubbles: true, cancelable: true}),
);
}
// Click the button a single time
click();
// The high-pri counter should flush synchronously, but not the
// low-pri counter
expect(button.textContent).toEqual('High-pri count: 1, Low-pri count: 0');
// Click the button many more times
click();
click();
click();
click();
click();
click();
// Flush the remaining work
jest.runAllTimers();
// Both counters should equal the total number of clicks
expect(button.textContent).toEqual('High-pri count: 7, Low-pri count: 7');
});
});
describe('iOS bubbling click fix', function() {
// See http://www.quirksmode.org/blog/archives/2010/09/click_event_del.html
it('does not add a local click to interactive elements', function() {
const container = document.createElement('div');
ReactDOM.render(<button onClick={onClick} />, container);
const node = container.firstChild;
node.dispatchEvent(new MouseEvent('click'));
expect(onClick.mock.calls.length).toBe(0);
});
it('adds a local click listener to non-interactive elements', function() {
const container = document.createElement('div');
ReactDOM.render(<div onClick={onClick} />, container);
const node = container.firstChild;
node.dispatchEvent(new MouseEvent('click'));
expect(onClick.mock.calls.length).toBe(0);
});
});
});

View File

@ -1,196 +0,0 @@
/**
* Copyright (c) 2013-present, Facebook, Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @emails react-core
*/
'use strict';
describe('SimpleEventPlugin', function() {
let React;
let ReactDOM;
let ReactTestUtils;
let onClick;
function expectClickThru(element) {
ReactTestUtils.SimulateNative.click(ReactDOM.findDOMNode(element));
expect(onClick.mock.calls.length).toBe(1);
}
function expectNoClickThru(element) {
ReactTestUtils.SimulateNative.click(ReactDOM.findDOMNode(element));
expect(onClick.mock.calls.length).toBe(0);
}
function mounted(element) {
element = ReactTestUtils.renderIntoDocument(element);
return element;
}
beforeEach(function() {
React = require('react');
ReactDOM = require('react-dom');
ReactTestUtils = require('react-dom/test-utils');
onClick = jest.fn();
});
it('A non-interactive tags click when disabled', function() {
const element = <div onClick={onClick} />;
expectClickThru(mounted(element));
});
it('A non-interactive tags clicks bubble when disabled', function() {
const element = ReactTestUtils.renderIntoDocument(
<div onClick={onClick}>
<div />
</div>,
);
const child = ReactDOM.findDOMNode(element).firstChild;
ReactTestUtils.SimulateNative.click(child);
expect(onClick.mock.calls.length).toBe(1);
});
it('does not register a click when clicking a child of a disabled element', function() {
const element = ReactTestUtils.renderIntoDocument(
<button onClick={onClick} disabled={true}>
<span />
</button>,
);
const child = ReactDOM.findDOMNode(element).querySelector('span');
ReactTestUtils.SimulateNative.click(child);
expect(onClick.mock.calls.length).toBe(0);
});
it('triggers click events for children of disabled elements', function() {
const element = ReactTestUtils.renderIntoDocument(
<button disabled={true}>
<span onClick={onClick} />
</button>,
);
const child = ReactDOM.findDOMNode(element).querySelector('span');
ReactTestUtils.SimulateNative.click(child);
expect(onClick.mock.calls.length).toBe(1);
});
it('triggers parent captured click events when target is a child of a disabled elements', function() {
const element = ReactTestUtils.renderIntoDocument(
<div onClickCapture={onClick}>
<button disabled={true}>
<span />
</button>
</div>,
);
const child = ReactDOM.findDOMNode(element).querySelector('span');
ReactTestUtils.SimulateNative.click(child);
expect(onClick.mock.calls.length).toBe(1);
});
it('triggers captured click events for children of disabled elements', function() {
const element = ReactTestUtils.renderIntoDocument(
<button disabled={true}>
<span onClickCapture={onClick} />
</button>,
);
const child = ReactDOM.findDOMNode(element).querySelector('span');
ReactTestUtils.SimulateNative.click(child);
expect(onClick.mock.calls.length).toBe(1);
});
['button', 'input', 'select', 'textarea'].forEach(function(tagName) {
describe(tagName, function() {
it('should forward clicks when it starts out not disabled', () => {
const element = React.createElement(tagName, {
onClick: onClick,
});
expectClickThru(mounted(element));
});
it('should not forward clicks when it starts out disabled', () => {
const element = React.createElement(tagName, {
onClick: onClick,
disabled: true,
});
expectNoClickThru(mounted(element));
});
it('should forward clicks when it becomes not disabled', () => {
const container = document.createElement('div');
let element = ReactDOM.render(
React.createElement(tagName, {onClick: onClick, disabled: true}),
container,
);
element = ReactDOM.render(
React.createElement(tagName, {onClick: onClick}),
container,
);
expectClickThru(element);
});
it('should not forward clicks when it becomes disabled', () => {
const container = document.createElement('div');
let element = ReactDOM.render(
React.createElement(tagName, {onClick: onClick}),
container,
);
element = ReactDOM.render(
React.createElement(tagName, {onClick: onClick, disabled: true}),
container,
);
expectNoClickThru(element);
});
it('should work correctly if the listener is changed', () => {
const container = document.createElement('div');
let element = ReactDOM.render(
React.createElement(tagName, {onClick: onClick, disabled: true}),
container,
);
element = ReactDOM.render(
React.createElement(tagName, {onClick: onClick, disabled: false}),
container,
);
expectClickThru(element);
});
});
});
describe('iOS bubbling click fix', function() {
// See http://www.quirksmode.org/blog/archives/2010/09/click_event_del.html
it('does not add a local click to interactive elements', function() {
const container = document.createElement('div');
ReactDOM.render(<button onClick={onClick} />, container);
const node = container.firstChild;
node.dispatchEvent(new MouseEvent('click'));
expect(onClick.mock.calls.length).toBe(0);
});
it('adds a local click listener to non-interactive elements', function() {
const container = document.createElement('div');
ReactDOM.render(<div onClick={onClick} />, container);
const node = container.firstChild;
node.dispatchEvent(new MouseEvent('click'));
expect(onClick.mock.calls.length).toBe(0);
});
});
});

View File

@ -388,6 +388,7 @@ function makeSimulator(eventType) {
ReactControlledComponent.enqueueStateRestore(domNode);
EventPluginHub.runEventsInBatch(event, true);
});
ReactControlledComponent.restoreStateIfNeeded();
};
}

View File

@ -32,9 +32,7 @@ import takeSnapshot from './takeSnapshot';
injectFindHostInstanceFabric(ReactFabricRenderer.findHostInstance);
ReactGenericBatching.injection.injectFiberBatchedUpdates(
ReactFabricRenderer.batchedUpdates,
);
ReactGenericBatching.injection.injectRenderer(ReactFabricRenderer);
const roots = new Map();

View File

@ -16,7 +16,6 @@ import type {
} from './ReactNativeTypes';
import {mountSafeCallback, warnForStyleProps} from './NativeMethodsMixinUtils';
import * as ReactGenericBatching from 'events/ReactGenericBatching';
import * as ReactNativeAttributePayload from './ReactNativeAttributePayload';
import * as ReactNativeFrameScheduling from './ReactNativeFrameScheduling';
import * as ReactNativeViewConfigRegistry from './ReactNativeViewConfigRegistry';
@ -314,8 +313,4 @@ const ReactFabricRenderer = ReactFiberReconciler({
},
});
ReactGenericBatching.injection.injectFiberBatchedUpdates(
ReactFabricRenderer.batchedUpdates,
);
export default ReactFabricRenderer;

View File

@ -34,9 +34,7 @@ import takeSnapshot from './takeSnapshot';
injectFindHostInstance(ReactNativeFiberRenderer.findHostInstance);
ReactGenericBatching.injection.injectFiberBatchedUpdates(
ReactNativeFiberRenderer.batchedUpdates,
);
ReactGenericBatching.injection.injectRenderer(ReactNativeFiberRenderer);
const roots = new Map();

View File

@ -463,6 +463,8 @@ const ReactNoop = {
unbatchedUpdates: NoopRenderer.unbatchedUpdates,
interactiveUpdates: NoopRenderer.interactiveUpdates,
flushSync(fn: () => mixed) {
yieldedValues = [];
NoopRenderer.flushSync(fn);

View File

@ -258,6 +258,7 @@ export type Reconciler<C, I, TI> = {
flushSync<A>(fn: () => A): A,
flushControlled(fn: () => mixed): void,
deferredUpdates<A>(fn: () => A): A,
interactiveUpdates<A>(fn: () => A): A,
injectIntoDevTools(devToolsConfig: DevToolsConfig<I, TI>): boolean,
computeUniqueAsyncExpiration(): ExpirationTime,
@ -303,6 +304,9 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
flushSync,
flushControlled,
deferredUpdates,
syncUpdates,
interactiveUpdates,
flushInteractiveUpdates,
} = ReactFiberScheduler(config);
function scheduleRootUpdate(
@ -433,10 +437,16 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
deferredUpdates,
flushSync,
syncUpdates,
interactiveUpdates,
flushInteractiveUpdates,
flushControlled,
flushSync,
getPublicRootInstance(
container: OpaqueRoot,
): React$Component<any, any> | PI | null {

View File

@ -704,23 +704,16 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
return next;
}
function workLoop(expirationTime: ExpirationTime) {
function workLoop(isAsync) {
if (capturedErrors !== null) {
// If there are unhandled errors, switch to the slow work loop.
// TODO: How to avoid this check in the fast path? Maybe the renderer
// could keep track of which roots have unhandled errors and call a
// forked version of renderRoot.
slowWorkLoopThatChecksForFailedWork(expirationTime);
slowWorkLoopThatChecksForFailedWork(isAsync);
return;
}
if (
nextRenderExpirationTime === NoWork ||
nextRenderExpirationTime > expirationTime
) {
return;
}
if (nextRenderExpirationTime <= mostRecentCurrentTime) {
if (!isAsync) {
// Flush all expired work.
while (nextUnitOfWork !== null) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
@ -733,15 +726,8 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
}
}
function slowWorkLoopThatChecksForFailedWork(expirationTime: ExpirationTime) {
if (
nextRenderExpirationTime === NoWork ||
nextRenderExpirationTime > expirationTime
) {
return;
}
if (nextRenderExpirationTime <= mostRecentCurrentTime) {
function slowWorkLoopThatChecksForFailedWork(isAsync) {
if (!isAsync) {
// Flush all expired work.
while (nextUnitOfWork !== null) {
if (hasCapturedError(nextUnitOfWork)) {
@ -768,7 +754,7 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
root: FiberRoot,
failedWork: Fiber,
boundary: Fiber,
expirationTime: ExpirationTime,
isAsync: boolean,
) {
// We're going to restart the error boundary that captured the error.
// 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>(
nextUnitOfWork = performFailedUnitOfWork(boundary);
// Continue working.
workLoop(expirationTime);
workLoop(isAsync);
}
function renderRoot(
root: FiberRoot,
expirationTime: ExpirationTime,
isAsync: boolean,
): Fiber | null {
invariant(
!isWorking,
@ -824,14 +811,14 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
let didError = false;
let error = null;
if (__DEV__) {
invokeGuardedCallback(null, workLoop, null, expirationTime);
invokeGuardedCallback(null, workLoop, null, isAsync);
if (hasCaughtError()) {
didError = true;
error = clearCaughtError();
}
} else {
try {
workLoop(expirationTime);
workLoop(isAsync);
} catch (e) {
didError = true;
error = e;
@ -879,7 +866,7 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
root,
failedWork,
boundary,
expirationTime,
isAsync,
);
if (hasCaughtError()) {
didError = true;
@ -888,7 +875,7 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
}
} else {
try {
renderRootCatchBlock(root, failedWork, boundary, expirationTime);
renderRootCatchBlock(root, failedWork, boundary, isAsync);
error = null;
} catch (e) {
didError = true;
@ -1169,6 +1156,14 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
return computeExpirationBucket(currentTime, expirationMs, bucketSizeMs);
}
function computeInteractiveExpiration() {
// Should complete within ~500ms. 600ms max.
const currentTime = recalculateCurrentTime();
const expirationMs = 500;
const bucketSizeMs = 100;
return computeExpirationBucket(currentTime, expirationMs, bucketSizeMs);
}
// Creates a unique async expiration time.
function computeUniqueAsyncExpiration(): ExpirationTime {
let result = computeAsyncExpiration();
@ -1201,13 +1196,29 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
// No explicit expiration context was set, and we're not currently
// performing work. Calculate a new expiration time.
if (fiber.mode & AsyncMode) {
// This is an async update
expirationTime = computeAsyncExpiration();
if (isBatchingInteractiveUpdates) {
// This is an interactive update
expirationTime = computeInteractiveExpiration();
} else {
// This is an async update
expirationTime = computeAsyncExpiration();
}
} else {
// This is a sync update
expirationTime = Sync;
}
}
if (isBatchingInteractiveUpdates) {
// This is an interactive update. Keep track of the lowest pending
// interactive expiration time. This allows us to synchronously flush
// all interactive updates when needed.
if (
lowestPendingInteractiveExpirationTime === NoWork ||
expirationTime > lowestPendingInteractiveExpirationTime
) {
lowestPendingInteractiveExpirationTime = expirationTime;
}
}
return expirationTime;
}
@ -1309,11 +1320,17 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
}
}
function syncUpdates<A>(fn: () => A): A {
function syncUpdates<A, B, C0, D, R>(
fn: (A, B, C0, D) => R,
a: A,
b: B,
c: C0,
d: D,
): R {
const previousExpirationContext = expirationContext;
expirationContext = Sync;
try {
return fn();
return fn(a, b, c, d);
} finally {
expirationContext = previousExpirationContext;
}
@ -1331,6 +1348,7 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
let isRendering: boolean = false;
let nextFlushedRoot: FiberRoot | null = null;
let nextFlushedExpirationTime: ExpirationTime = NoWork;
let lowestPendingInteractiveExpirationTime: ExpirationTime = NoWork;
let deadlineDidExpire: boolean = false;
let hasUnhandledError: boolean = false;
let unhandledError: mixed | null = null;
@ -1338,6 +1356,7 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
let isBatchingUpdates: boolean = false;
let isUnbatchingUpdates: boolean = false;
let isBatchingInteractiveUpdates: boolean = false;
let completedBatches: Array<Batch> | null = null;
@ -1417,20 +1436,20 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
}
if (isBatchingUpdates) {
// Flush work at the end of the batch.
if (isUnbatchingUpdates) {
// Flush work at the end of the batch.
// ...unless we're inside unbatchedUpdates, in which case we should
// flush it now.
nextFlushedRoot = root;
nextFlushedExpirationTime = Sync;
performWorkOnRoot(root, Sync, recalculateCurrentTime());
performWorkOnRoot(root, Sync, false);
}
return;
}
// TODO: Get rid of Sync and use current time?
if (expirationTime === Sync) {
performWork(Sync, null);
performSyncWork();
} else {
scheduleCallbackWithExpiration(expirationTime);
}
@ -1513,10 +1532,18 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
}
function performAsyncWork(dl) {
performWork(NoWork, dl);
performWork(NoWork, true, dl);
}
function performWork(minExpirationTime: ExpirationTime, dl: Deadline | null) {
function performSyncWork() {
performWork(Sync, false, null);
}
function performWork(
minExpirationTime: ExpirationTime,
isAsync: boolean,
dl: Deadline | null,
) {
deadline = dl;
// 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>(
stopRequestCallbackTimer(didExpire);
}
while (
nextFlushedRoot !== null &&
nextFlushedExpirationTime !== NoWork &&
(minExpirationTime === NoWork ||
nextFlushedExpirationTime <= minExpirationTime) &&
!deadlineDidExpire
) {
performWorkOnRoot(
nextFlushedRoot,
nextFlushedExpirationTime,
recalculateCurrentTime(),
);
// Find the next highest priority work.
findHighestPriorityRoot();
if (isAsync) {
while (
nextFlushedRoot !== null &&
nextFlushedExpirationTime !== NoWork &&
(minExpirationTime === NoWork ||
minExpirationTime >= nextFlushedExpirationTime) &&
(!deadlineDidExpire ||
recalculateCurrentTime() >= nextFlushedExpirationTime)
) {
performWorkOnRoot(
nextFlushedRoot,
nextFlushedExpirationTime,
!deadlineDidExpire,
);
findHighestPriorityRoot();
}
} else {
while (
nextFlushedRoot !== null &&
nextFlushedExpirationTime !== NoWork &&
(minExpirationTime === NoWork ||
minExpirationTime >= nextFlushedExpirationTime)
) {
performWorkOnRoot(nextFlushedRoot, nextFlushedExpirationTime, false);
findHighestPriorityRoot();
}
}
// 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>(
// Perform work on root as if the given expiration time is the current time.
// This has the effect of synchronously flushing all work up to and
// including the given time.
performWorkOnRoot(root, expirationTime, expirationTime);
performWorkOnRoot(root, expirationTime, false);
finishRendering();
}
@ -1606,7 +1645,7 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
function performWorkOnRoot(
root: FiberRoot,
expirationTime: ExpirationTime,
currentTime: ExpirationTime,
isAsync: boolean,
) {
invariant(
!isRendering,
@ -1617,7 +1656,7 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
isRendering = true;
// Check if this is async work or sync/expired work.
if (expirationTime <= currentTime) {
if (!isAsync) {
// Flush sync work.
let finishedWork = root.finishedWork;
if (finishedWork !== null) {
@ -1625,7 +1664,7 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
completeRoot(root, finishedWork, expirationTime);
} else {
root.finishedWork = null;
finishedWork = renderRoot(root, expirationTime);
finishedWork = renderRoot(root, expirationTime, false);
if (finishedWork !== null) {
// We've completed the root. Commit it.
completeRoot(root, finishedWork, expirationTime);
@ -1639,7 +1678,7 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
completeRoot(root, finishedWork, expirationTime);
} else {
root.finishedWork = null;
finishedWork = renderRoot(root, expirationTime);
finishedWork = renderRoot(root, expirationTime, true);
if (finishedWork !== null) {
// We've completed the root. Check the deadline one more time
// before committing.
@ -1727,40 +1766,62 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
} finally {
isBatchingUpdates = previousIsBatchingUpdates;
if (!isBatchingUpdates && !isRendering) {
performWork(Sync, null);
performSyncWork();
}
}
}
// TODO: Batching should be implemented at the renderer level, not inside
// the reconciler.
function unbatchedUpdates<A>(fn: () => A): A {
function unbatchedUpdates<A, R>(fn: (a: A) => R, a: A): R {
if (isBatchingUpdates && !isUnbatchingUpdates) {
isUnbatchingUpdates = true;
try {
return fn();
return fn(a);
} finally {
isUnbatchingUpdates = false;
}
}
return fn();
return fn(a);
}
// TODO: Batching should be implemented at the renderer level, not within
// the reconciler.
function flushSync<A>(fn: () => A): A {
function flushSync<A, R>(fn: (a: A) => R, a: A): R {
invariant(
!isRendering,
'flushSync was called from inside a lifecycle method. It cannot be ' +
'called when React is already rendering.',
);
const previousIsBatchingUpdates = isBatchingUpdates;
isBatchingUpdates = true;
try {
return syncUpdates(fn);
return syncUpdates(fn, a);
} finally {
isBatchingUpdates = previousIsBatchingUpdates;
invariant(
!isRendering,
'flushSync was called from inside a lifecycle method. It cannot be ' +
'called when React is already rendering.',
);
performWork(Sync, null);
performSyncWork();
}
}
function interactiveUpdates<A, B, R>(fn: (A, B) => R, a: A, b: B): R {
if (isBatchingInteractiveUpdates) {
return fn(a, b);
}
const previousIsBatchingInteractiveUpdates = isBatchingInteractiveUpdates;
isBatchingInteractiveUpdates = true;
try {
return fn(a, b);
} finally {
isBatchingInteractiveUpdates = previousIsBatchingInteractiveUpdates;
}
}
function flushInteractiveUpdates() {
if (!isRendering && lowestPendingInteractiveExpirationTime !== NoWork) {
// Synchronously flush pending interactive updates.
performWork(lowestPendingInteractiveExpirationTime, false, null);
lowestPendingInteractiveExpirationTime = NoWork;
}
}
@ -1772,7 +1833,7 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
} finally {
isBatchingUpdates = previousIsBatchingUpdates;
if (!isBatchingUpdates && !isRendering) {
performWork(Sync, null);
performWork(Sync, false, null);
}
}
}
@ -1787,6 +1848,9 @@ export default function<T, P, I, TI, HI, PI, C, CC, CX, PL>(
flushSync,
flushControlled,
deferredUpdates,
syncUpdates,
interactiveUpdates,
flushInteractiveUpdates,
computeUniqueAsyncExpiration,
};
}