diff --git a/packages/babel-plugin-react-native-web/src/__tests__/__snapshots__/index-test.js.snap b/packages/babel-plugin-react-native-web/src/__tests__/__snapshots__/index-test.js.snap index 670b06b52..62166c18f 100644 --- a/packages/babel-plugin-react-native-web/src/__tests__/__snapshots__/index-test.js.snap +++ b/packages/babel-plugin-react-native-web/src/__tests__/__snapshots__/index-test.js.snap @@ -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; diff --git a/packages/react-native-web/src/exports/InteractionManager/TaskQueue.js b/packages/react-native-web/src/exports/InteractionManager/TaskQueue.js new file mode 100644 index 000000000..7381d52af --- /dev/null +++ b/packages/react-native-web/src/exports/InteractionManager/TaskQueue.js @@ -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 +|}; +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): void { + tasks.forEach((task) => this.enqueue(task)); + } + + cancelTasks(tasksToCancel: Array): 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, + popable: boolean, + ... + }>; + _onMoreTasks: () => void; + + _getCurrentQueue(): Array { + 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; diff --git a/packages/react-native-web/src/exports/InteractionManager/__tests__/TaskQueue-test.js b/packages/react-native-web/src/exports/InteractionManager/__tests__/TaskQueue-test.js new file mode 100644 index 000000000..8baf5dd42 --- /dev/null +++ b/packages/react-native-web/src/exports/InteractionManager/__tests__/TaskQueue-test.js @@ -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(); + }); +}); diff --git a/packages/react-native-web/src/exports/InteractionManager/__tests__/index-test.js b/packages/react-native-web/src/exports/InteractionManager/__tests__/index-test.js new file mode 100644 index 000000000..cb3b31f8d --- /dev/null +++ b/packages/react-native-web/src/exports/InteractionManager/__tests__/index-test.js @@ -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); + }); +}); diff --git a/packages/react-native-web/src/exports/InteractionManager/index.js b/packages/react-native-web/src/exports/InteractionManager/index.js index 8cda9503b..d6a522f3c 100644 --- a/packages/react-native-web/src/exports/InteractionManager/index.js +++ b/packages/react-native-web/src/exports/InteractionManager/index.js @@ -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 = []; 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;