-
Notifications
You must be signed in to change notification settings - Fork 1.8k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[change] Add task queue for InteractionManager
Close #2399
- Loading branch information
Showing
5 changed files
with
713 additions
and
26 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
115 changes: 115 additions & 0 deletions
115
packages/react-native-web/src/exports/InteractionManager/TaskQueue.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; |
185 changes: 185 additions & 0 deletions
185
packages/react-native-web/src/exports/InteractionManager/__tests__/TaskQueue-test.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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(); | ||
}); | ||
}); |
Oops, something went wrong.