[change] Add task queue for InteractionManager

Close #2399
This commit is contained in:
ntdiary
2022-11-12 15:57:56 +08:00
committed by Nicolas Gallagher
parent 618660484f
commit 47d77ac256
5 changed files with 713 additions and 26 deletions

View File

@@ -95,11 +95,8 @@ const { StyleSheet, Pressable } = require('react-native');
↓ ↓ ↓ ↓ ↓ ↓
const ReactNative = require('react-native-web/dist/index');
const View = require('react-native-web/dist/exports/View').default;
const StyleSheet = require('react-native-web/dist/exports/StyleSheet').default;
const Pressable = require('react-native-web/dist/exports/Pressable').default;
@@ -114,12 +111,9 @@ const { StyleSheet, Pressable } = require('react-native');
↓ ↓ ↓ ↓ ↓ ↓
const ReactNative = require('react-native-web/dist/cjs/index');
const View = require('react-native-web/dist/cjs/exports/View').default;
const StyleSheet =
require('react-native-web/dist/cjs/exports/StyleSheet').default;
const Pressable =
require('react-native-web/dist/cjs/exports/Pressable').default;
@@ -135,16 +129,11 @@ const { StyleSheet, View, Pressable, processColor } = require('react-native-web'
↓ ↓ ↓ ↓ ↓ ↓
const ReactNative = require('react-native-web/dist/index');
const unstable_createElement =
require('react-native-web/dist/exports/createElement').default;
const StyleSheet = require('react-native-web/dist/exports/StyleSheet').default;
const View = require('react-native-web/dist/exports/View').default;
const Pressable = require('react-native-web/dist/exports/Pressable').default;
const processColor =
require('react-native-web/dist/exports/processColor').default;

View File

@@ -0,0 +1,115 @@
/**
* Copyright (c) Nicolas Gallagher.
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import invariant from 'fbjs/lib/invariant';
type SimpleTask = {|
name: string,
run: () => void
|};
type PromiseTask = {|
name: string,
gen: () => Promise<void>
|};
export type Task = SimpleTask | PromiseTask | (() => void);
class TaskQueue {
constructor({ onMoreTasks }: { onMoreTasks: () => void, ... }) {
this._onMoreTasks = onMoreTasks;
this._queueStack = [{ tasks: [], popable: true }];
}
enqueue(task: Task): void {
this._getCurrentQueue().push(task);
}
enqueueTasks(tasks: Array<Task>): void {
tasks.forEach((task) => this.enqueue(task));
}
cancelTasks(tasksToCancel: Array<Task>): void {
this._queueStack = this._queueStack
.map((queue) => ({
...queue,
tasks: queue.tasks.filter((task) => tasksToCancel.indexOf(task) === -1)
}))
.filter((queue, idx) => queue.tasks.length > 0 || idx === 0);
}
hasTasksToProcess(): boolean {
return this._getCurrentQueue().length > 0;
}
/**
* Executes the next task in the queue.
*/
processNext(): void {
const queue = this._getCurrentQueue();
if (queue.length) {
const task = queue.shift();
try {
if (typeof task === 'object' && task.gen) {
this._genPromise(task);
} else if (typeof task === 'object' && task.run) {
task.run();
} else {
invariant(
typeof task === 'function',
'Expected Function, SimpleTask, or PromiseTask, but got:\n' +
JSON.stringify(task, null, 2)
);
task();
}
} catch (e) {
e.message =
'TaskQueue: Error with task ' + (task.name || '') + ': ' + e.message;
throw e;
}
}
}
_queueStack: Array<{
tasks: Array<Task>,
popable: boolean,
...
}>;
_onMoreTasks: () => void;
_getCurrentQueue(): Array<Task> {
const stackIdx = this._queueStack.length - 1;
const queue = this._queueStack[stackIdx];
if (queue.popable && queue.tasks.length === 0 && stackIdx > 0) {
this._queueStack.pop();
return this._getCurrentQueue();
} else {
return queue.tasks;
}
}
_genPromise(task: PromiseTask) {
const length = this._queueStack.push({ tasks: [], popable: false });
const stackIdx = length - 1;
const stackItem = this._queueStack[stackIdx];
task
.gen()
.then(() => {
stackItem.popable = true;
this.hasTasksToProcess() && this._onMoreTasks();
})
.catch((ex) => {
setTimeout(() => {
ex.message = `TaskQueue: Error resolving Promise in task ${task.name}: ${ex.message}`;
throw ex;
}, 0);
});
}
}
export default TaskQueue;

View File

@@ -0,0 +1,185 @@
/**
* Copyright (c) Nicolas Gallagher.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
function expectToBeCalledOnce(fn) {
expect(fn.mock.calls.length).toBe(1);
}
function clearTaskQueue(taskQueue) {
do {
jest.runAllTimers();
taskQueue.processNext();
jest.runAllTimers();
} while (taskQueue.hasTasksToProcess());
}
describe('TaskQueue', () => {
let taskQueue;
let onMoreTasks;
let sequenceId;
function createSequenceTask(expectedSequenceId) {
return jest.fn(() => {
expect(++sequenceId).toBe(expectedSequenceId);
});
}
beforeEach(() => {
jest.resetModules();
onMoreTasks = jest.fn();
const TaskQueue = require('../TaskQueue');
taskQueue = new TaskQueue({ onMoreTasks });
sequenceId = 0;
});
it('should run a basic task', () => {
const task1 = createSequenceTask(1);
taskQueue.enqueue({ run: task1, name: 'run1' });
expect(taskQueue.hasTasksToProcess()).toBe(true);
taskQueue.processNext();
expectToBeCalledOnce(task1);
});
it('should handle blocking promise task', () => {
onMoreTasks.mockImplementation(() => {
taskQueue.processNext();
jest.runAllTimers();
});
const task1 = jest.fn(() => {
return new Promise((resolve) => {
setTimeout(() => {
expect(++sequenceId).toBe(1);
resolve();
}, 1);
});
});
const task2 = createSequenceTask(2);
taskQueue.enqueue({ gen: task1, name: 'gen1' });
taskQueue.enqueue({ run: task2, name: 'run2' });
taskQueue.processNext();
expectToBeCalledOnce(task1);
expect(task2).not.toBeCalled();
expect(onMoreTasks).not.toBeCalled();
expect(taskQueue.hasTasksToProcess()).toBe(false);
clearTaskQueue(taskQueue);
return new Promise((resolve) => {
setTimeout(() => {
resolve();
});
}).then(() => {
expectToBeCalledOnce(onMoreTasks);
expectToBeCalledOnce(task2);
});
});
it('should handle nested simple tasks', () => {
const task1 = jest.fn(() => {
expect(++sequenceId).toBe(1);
taskQueue.enqueue({ run: task3, name: 'run3' });
});
const task2 = createSequenceTask(2);
const task3 = createSequenceTask(3);
taskQueue.enqueue({ run: task1, name: 'run1' });
taskQueue.enqueue({ run: task2, name: 'run2' }); // not blocked by task 1
clearTaskQueue(taskQueue);
expectToBeCalledOnce(task1);
expectToBeCalledOnce(task2);
expectToBeCalledOnce(task3);
});
it('should handle nested promises', () => {
onMoreTasks.mockImplementation(() => {
taskQueue.processNext();
jest.runAllTimers();
});
const task1 = jest.fn(() => {
return new Promise((resolve) => {
setTimeout(() => {
expect(++sequenceId).toBe(1);
taskQueue.enqueue({ gen: task2, name: 'gen2' });
taskQueue.enqueue({ run: resolve, name: 'resolve1' });
}, 1);
});
});
const task2 = jest.fn(() => {
return new Promise((resolve) => {
setTimeout(() => {
expect(++sequenceId).toBe(2);
taskQueue.enqueue({ run: task3, name: 'run3' });
taskQueue.enqueue({ run: resolve, name: 'resolve2' });
}, 1);
});
});
const task3 = createSequenceTask(3);
const task4 = createSequenceTask(4);
taskQueue.enqueue({ gen: task1, name: 'gen1' });
taskQueue.enqueue({ run: task4, name: 'run4' }); // blocked by task 1 promise
clearTaskQueue(taskQueue);
return new Promise((resolve) => {
setTimeout(() => {
resolve();
});
}).then(() => {
expectToBeCalledOnce(task1);
expectToBeCalledOnce(task2);
expectToBeCalledOnce(task3);
expectToBeCalledOnce(task4);
});
});
it('should be able to cancel tasks', () => {
const task1 = jest.fn();
const task2 = createSequenceTask(1);
const task3 = jest.fn();
const task4 = createSequenceTask(2);
taskQueue.enqueue(task1);
taskQueue.enqueue(task2);
taskQueue.enqueue(task3);
taskQueue.enqueue(task4);
taskQueue.cancelTasks([task1, task3]);
clearTaskQueue(taskQueue);
expect(task1).not.toBeCalled();
expect(task3).not.toBeCalled();
expectToBeCalledOnce(task2);
expectToBeCalledOnce(task4);
expect(taskQueue.hasTasksToProcess()).toBe(false);
});
it('should not crash when last task is cancelled', () => {
const task1 = jest.fn();
taskQueue.enqueue(task1);
taskQueue.cancelTasks([task1]);
clearTaskQueue(taskQueue);
expect(task1).not.toBeCalled();
expect(taskQueue.hasTasksToProcess()).toBe(false);
});
it('should not crash when task is cancelled between being started and resolved', () => {
const task1 = jest.fn(() => {
return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, 1);
});
});
taskQueue.enqueue({ gen: task1, name: 'gen1' });
taskQueue.processNext();
taskQueue.cancelTasks([task1]);
jest.runAllTimers();
});
});

View File

@@ -0,0 +1,323 @@
/**
* Copyright (c) Nicolas Gallagher.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
function expectToBeCalledOnce(fn) {
expect(fn.mock.calls.length).toBe(1);
}
describe('InteractionManager', () => {
let InteractionManager;
let interactionStart;
let interactionComplete;
beforeEach(() => {
jest.resetModules();
InteractionManager = require('..');
interactionStart = jest.fn();
interactionComplete = jest.fn();
InteractionManager.addListener(
InteractionManager.Events.interactionStart,
interactionStart
);
InteractionManager.addListener(
InteractionManager.Events.interactionComplete,
interactionComplete
);
});
it('throws when clearing an undefined handle', () => {
expect(() => InteractionManager.clearInteractionHandle()).toThrow();
});
it('notifies asynchronously when interaction starts', () => {
InteractionManager.createInteractionHandle();
expect(interactionStart).not.toBeCalled();
jest.runAllTimers();
expect(interactionStart).toBeCalled();
expect(interactionComplete).not.toBeCalled();
});
it('notifies asynchronously when interaction stops', () => {
const handle = InteractionManager.createInteractionHandle();
jest.runAllTimers();
interactionStart.mockClear();
InteractionManager.clearInteractionHandle(handle);
expect(interactionComplete).not.toBeCalled();
jest.runAllTimers();
expect(interactionStart).not.toBeCalled();
expect(interactionComplete).toBeCalled();
});
it('does not notify when started & stoped in same event loop', () => {
const handle = InteractionManager.createInteractionHandle();
InteractionManager.clearInteractionHandle(handle);
jest.runAllTimers();
expect(interactionStart).not.toBeCalled();
expect(interactionComplete).not.toBeCalled();
});
it('does not notify when going from two -> one active interactions', () => {
InteractionManager.createInteractionHandle();
const handle = InteractionManager.createInteractionHandle();
jest.runAllTimers();
interactionStart.mockClear();
interactionComplete.mockClear();
InteractionManager.clearInteractionHandle(handle);
jest.runAllTimers();
expect(interactionStart).not.toBeCalled();
expect(interactionComplete).not.toBeCalled();
});
it('run tasks asynchronously when there are interactions', () => {
const task = jest.fn();
InteractionManager.runAfterInteractions(task);
expect(task).not.toBeCalled();
jest.runAllTimers();
expect(task).toBeCalled();
});
it('runs tasks when interactions complete', () => {
const task = jest.fn();
const handle = InteractionManager.createInteractionHandle();
InteractionManager.runAfterInteractions(task);
jest.runAllTimers();
InteractionManager.clearInteractionHandle(handle);
expect(task).not.toBeCalled();
jest.runAllTimers();
expect(task).toBeCalled();
});
it('does not run tasks twice', () => {
const task1 = jest.fn();
const task2 = jest.fn();
InteractionManager.runAfterInteractions(task1);
jest.runAllTimers();
InteractionManager.runAfterInteractions(task2);
jest.runAllTimers();
expectToBeCalledOnce(task1);
});
it('runs tasks added while processing previous tasks', () => {
const task1 = jest.fn(() => {
InteractionManager.runAfterInteractions(task2);
});
const task2 = jest.fn();
InteractionManager.runAfterInteractions(task1);
expect(task2).not.toBeCalled();
jest.runAllTimers();
expect(task1).toBeCalled();
expect(task2).toBeCalled();
});
it('allows tasks to be cancelled', () => {
const task1 = jest.fn();
const task2 = jest.fn();
const promise1 = InteractionManager.runAfterInteractions(task1);
InteractionManager.runAfterInteractions(task2);
expect(task1).not.toBeCalled();
expect(task2).not.toBeCalled();
promise1.cancel();
jest.runAllTimers();
expect(task1).not.toBeCalled();
expect(task2).toBeCalled();
});
it('should support promise variant', () => {
expect.assertions(1);
const task = jest.fn();
const promise = InteractionManager.runAfterInteractions()
.done(task)
.then(() => {
expect(task).toBeCalled();
});
jest.runAllTimers();
return promise;
});
});
describe('promise tasks', () => {
let InteractionManager;
let sequenceId;
function createSequenceTask(expectedSequenceId) {
return jest.fn(() => {
expect(++sequenceId).toBe(expectedSequenceId);
});
}
beforeEach(() => {
jest.resetModules();
InteractionManager = require('..');
sequenceId = 0;
});
it('should run a basic promise task', () => {
const task1 = jest.fn(() => {
expect(++sequenceId).toBe(1);
return new Promise((resolve) => resolve());
});
InteractionManager.runAfterInteractions({ gen: task1, name: 'gen1' });
jest.runAllTimers();
expectToBeCalledOnce(task1);
});
it('should handle nested promises', () => {
const task1 = jest.fn(() => {
expect(++sequenceId).toBe(1);
return new Promise((resolve) => {
InteractionManager.runAfterInteractions({
gen: task2,
name: 'gen2'
}).then(resolve);
});
});
const task2 = jest.fn(() => {
expect(++sequenceId).toBe(2);
return new Promise((resolve) => resolve());
});
InteractionManager.runAfterInteractions({ gen: task1, name: 'gen1' });
jest.runAllTimers();
expectToBeCalledOnce(task1);
expectToBeCalledOnce(task2);
});
it('should pause promise tasks during interactions then resume', () => {
const task1 = createSequenceTask(1);
const task2 = jest.fn(() => {
expect(++sequenceId).toBe(2);
return new Promise((resolve) => {
setTimeout(() => {
InteractionManager.runAfterInteractions(task3).then(resolve);
}, 1);
});
});
const task3 = createSequenceTask(3);
InteractionManager.runAfterInteractions(task1);
InteractionManager.runAfterInteractions({ gen: task2, name: 'gen2' });
jest.runOnlyPendingTimers();
expectToBeCalledOnce(task1);
expectToBeCalledOnce(task2);
const handle = InteractionManager.createInteractionHandle();
jest.runAllTimers();
jest.runAllTimers(); // Just to be sure...
expect(task3).not.toBeCalled();
InteractionManager.clearInteractionHandle(handle);
jest.runAllTimers();
expectToBeCalledOnce(task3);
});
it('should execute tasks in loop within deadline', () => {
InteractionManager.setDeadline(100);
const task1 = createSequenceTask(1);
const task2 = createSequenceTask(2);
InteractionManager.runAfterInteractions(task1);
InteractionManager.runAfterInteractions(task2);
jest.runOnlyPendingTimers();
expectToBeCalledOnce(task1);
expectToBeCalledOnce(task2);
});
it('should execute tasks one at a time if deadline exceeded', () => {
InteractionManager.setDeadline(100);
const task1 = jest.fn(() => {
expect(++sequenceId).toBe(1);
jest.setSystemTime(Date.now() + 200);
});
const task2 = createSequenceTask(2);
InteractionManager.runAfterInteractions(task1);
InteractionManager.runAfterInteractions(task2);
jest.runOnlyPendingTimers();
expectToBeCalledOnce(task1);
expect(task2).not.toBeCalled();
jest.runOnlyPendingTimers();
expectToBeCalledOnce(task2);
});
const bigAsyncTest = (resolveTest) => {
const task1 = createSequenceTask(1);
const task2 = jest.fn(() => {
expect(++sequenceId).toBe(2);
return new Promise((resolve) => {
InteractionManager.runAfterInteractions(task3);
setTimeout(() => {
InteractionManager.runAfterInteractions({
gen: task4,
name: 'gen4'
})
.then(resolve)
.then(() => {
// Explicit exhaustion of the task queue is required
jest.runAllTimers();
});
}, 1);
});
});
const task3 = createSequenceTask(3);
const task4 = jest.fn(() => {
expect(++sequenceId).toBe(4);
return new Promise((resolve) => {
InteractionManager.runAfterInteractions(task5)
.then(resolve)
.then(() => {
// Explicit exhaustion of the task queue is required
jest.runAllTimers();
});
});
});
const task5 = createSequenceTask(5);
const task6 = createSequenceTask(6);
InteractionManager.runAfterInteractions(task1);
InteractionManager.runAfterInteractions({ gen: task2, name: 'gen2' });
InteractionManager.runAfterInteractions(task6).then(() => {
expectToBeCalledOnce(task1);
expectToBeCalledOnce(task2);
expectToBeCalledOnce(task3);
expectToBeCalledOnce(task4);
expectToBeCalledOnce(task5);
expectToBeCalledOnce(task6);
resolveTest();
});
jest.runAllTimers();
};
it('resolves async tasks recursively before other queued tasks', () => {
return new Promise(bigAsyncTest);
});
it('should also work with a deadline', () => {
InteractionManager.setDeadline(100);
const task = jest.fn(() => {
jest.setSystemTime(Date.now() + 200);
});
InteractionManager.runAfterInteractions(task);
return new Promise(bigAsyncTest);
});
});

View File

@@ -9,9 +9,16 @@
*/
import invariant from 'fbjs/lib/invariant';
import requestIdleCallback, {
cancelIdleCallback
} from '../../modules/requestIdleCallback';
import type { Task } from './TaskQueue';
import TaskQueue from './TaskQueue';
import type { EventSubscription } from '../../vendor/react-native/vendor/emitter/EventEmitter';
import EventEmitter from '../../vendor/react-native/vendor/emitter/EventEmitter';
import requestIdleCallback from '../../modules/requestIdleCallback';
const _emitter = new EventEmitter<{
interactionComplete: [],
interactionStart: []
}>();
const InteractionManager = {
Events: {
@@ -22,27 +29,28 @@ const InteractionManager = {
/**
* Schedule a function to run after all interactions have completed.
*/
runAfterInteractions(task: ?Function): {
runAfterInteractions(task: ?Task): {
then: Function,
done: Function,
cancel: Function
} {
let handle;
const tasks: Array<Task> = [];
const promise = new Promise((resolve) => {
handle = requestIdleCallback(() => {
if (task) {
resolve(task());
} else {
resolve();
}
_scheduleUpdate();
if (task) {
tasks.push(task);
}
tasks.push({
run: resolve,
name: 'resolve ' + ((task && task.name) || '?')
});
_taskQueue.enqueueTasks(tasks);
});
return {
then: promise.then.bind(promise),
done: promise.then.bind(promise),
cancel: () => {
cancelIdleCallback(handle);
_taskQueue.cancelTasks(tasks);
}
};
},
@@ -51,7 +59,10 @@ const InteractionManager = {
* Notify manager that an interaction has started.
*/
createInteractionHandle(): number {
return 1;
_scheduleUpdate();
const handle = ++_inc;
_addInteractionSet.add(handle);
return handle;
},
/**
@@ -59,9 +70,73 @@ const InteractionManager = {
*/
clearInteractionHandle(handle: number) {
invariant(!!handle, 'Must provide a handle to clear.');
_scheduleUpdate();
_addInteractionSet.delete(handle);
_deleteInteractionSet.add(handle);
},
addListener: () => {}
addListener: (_emitter.addListener.bind(_emitter): EventSubscription),
/**
*
* @param deadline
*/
setDeadline(deadline: number) {
_deadline = deadline;
}
};
const _interactionSet = new Set();
const _addInteractionSet = new Set();
const _deleteInteractionSet = new Set();
const _taskQueue = new TaskQueue({ onMoreTasks: _scheduleUpdate });
let _nextUpdateHandle: TimeoutID | number = 0;
let _inc = 0;
let _deadline = -1;
/**
* Schedule an asynchronous update to the interaction state.
*/
function _scheduleUpdate() {
if (!_nextUpdateHandle) {
if (_deadline > 0) {
_nextUpdateHandle = setTimeout(_processUpdate);
} else {
_nextUpdateHandle = requestIdleCallback(_processUpdate);
}
}
}
/**
* Notify listeners, process queue, etc
*/
function _processUpdate() {
_nextUpdateHandle = 0;
const interactionCount = _interactionSet.size;
_addInteractionSet.forEach((handle) => _interactionSet.add(handle));
_deleteInteractionSet.forEach((handle) => _interactionSet.delete(handle));
const nextInteractionCount = _interactionSet.size;
if (interactionCount !== 0 && nextInteractionCount === 0) {
_emitter.emit(InteractionManager.Events.interactionComplete);
} else if (interactionCount === 0 && nextInteractionCount !== 0) {
_emitter.emit(InteractionManager.Events.interactionStart);
}
if (nextInteractionCount === 0) {
// It seems that we can't know the running time of the current event loop,
// we can only calculate the running time of the current task queue.
const begin = Date.now();
while (_taskQueue.hasTasksToProcess()) {
_taskQueue.processNext();
if (_deadline > 0 && Date.now() - begin >= _deadline) {
_scheduleUpdate();
break;
}
}
}
_addInteractionSet.clear();
_deleteInteractionSet.clear();
}
export default InteractionManager;