diff --git a/src/shared/hooks/useAsyncVisualTask.ts b/src/shared/hooks/useAsyncVisualTask.ts new file mode 100644 index 0000000000..ef44a505fe --- /dev/null +++ b/src/shared/hooks/useAsyncVisualTask.ts @@ -0,0 +1,24 @@ +/** + * See the NOTICE file distributed with this work for additional information + * regarding copyright ownership. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// TODO: import the runAsyncVisualTask utility, and use it in the hook +const useAsyncVisualTask = () => { + return { + // run: runAsyncVisualTask + }; +}; + +export default useAsyncVisualTask; diff --git a/src/shared/utils/runAsyncVisualTask.ts b/src/shared/utils/runAsyncVisualTask.ts new file mode 100644 index 0000000000..9dc6cceafd --- /dev/null +++ b/src/shared/utils/runAsyncVisualTask.ts @@ -0,0 +1,119 @@ +/** + * See the NOTICE file distributed with this work for additional information + * regarding copyright ownership. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + of, + from, + race, + tap, + timer, + map, + mergeMap, + combineLatest, + startWith, + catchError, + shareReplay, + distinctUntilChanged, + type Observable +} from 'rxjs'; + +/** + * This is a function for handling asynchronous tasks that are accompanied + * by a change in the UI indicating that they are running (e.g. a spinner). + * + * It is impossible to predict how long a task may take — it may finish almost immediately, + * or may run for a long time. The purpose of the runAsyncVisualTask utility function + * is to prevent unwelcome flickering on the screen caused by the loading indicator + * briefly appearing and disappearing when the task finishes too quickly + * after the indicator is displayed. + * + * The runAsyncVisualTask function wraps an asynchronous task and does the following: + * - if the task finishes very quickly (faster than the interval set by the ignoreTime option), + * then no signal for showing the loading indicator will be sent + * - if the task takes longer than the ignoreTime period to complete, then the function + * sends a signal to show the loading indicator, and waits for at least the length of time + * defined by the minimumRunningTime option before sending a different signal + * - thus, if the task completes after the ignoreTime interval, but before the minimumRunningTime expires, + * then its completion will be reported only after the minimumRunningTime interval runs out + * + * The implementation was inspired by https://stackblitz.com/edit/rxjs-spinner-flickering?file=index.ts + */ + +const runAsyncVisualTask = (params: { + task: Promise | Observable; // using observable option for testability + ignoreTime?: number; + minimumRunningTime?: number; + onComplete?: (arg: T) => unknown; + onError?: (arg: unknown) => unknown; +}) => { + const { ignoreTime = 100, minimumRunningTime = 1000 } = params; + + const task$ = from(params.task).pipe( + shareReplay(1), // make sure that the task won't need to run from the start when it is consumed by withRegisteredTask + tap((result) => { + params.onComplete?.(result); + }), + map((result) => ({ + status: 'success', + result + })), + catchError((error) => { + params.onError?.(error); + + return of({ + status: 'error', + error + }); + }) + ); + + const ignoreTimer$ = timer(ignoreTime).pipe(map(() => 'registered')); + const minimumRunningTimer$ = timer(minimumRunningTime).pipe(startWith(null)); + + const withRegisteredTask = (task: typeof task$) => { + return combineLatest([ + minimumRunningTimer$, + task.pipe(startWith({ status: 'loading', result: null })) + ]).pipe( + map(([time, result]) => { + if (time === null) { + return { status: 'loading', result: null }; + } else { + return result; + } + }), + distinctUntilChanged((prev, curr) => { + return prev.status === curr.status; + }) + ); + }; + + return race(task$, ignoreTimer$).pipe( + mergeMap((winner) => { + if (typeof winner === 'string') { + // Meaning that the ignoreTimer$ stream has completed, + // and that we should register the task + return withRegisteredTask(task$); + } else { + // The task completed before the ignoreTimer$ stream + // No need to report it to the user + return of(winner); + } + }) + ); +}; + +export default runAsyncVisualTask; diff --git a/src/shared/utils/tests/runAsyncVisualTask.test.ts b/src/shared/utils/tests/runAsyncVisualTask.test.ts new file mode 100644 index 0000000000..dfbe44f6bf --- /dev/null +++ b/src/shared/utils/tests/runAsyncVisualTask.test.ts @@ -0,0 +1,159 @@ +/** + * See the NOTICE file distributed with this work for additional information + * regarding copyright ownership. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { timer, map, mergeMap, throwError } from 'rxjs'; + +import runAsyncVisualTask from '../runAsyncVisualTask'; + +jest.useFakeTimers(); + +// The tests below are using an observable for a mock task, +// because observables, as opposed to promises, run synchronously when their timers are mocked + +describe('runAsyncVisualTask', () => { + const ignoreInterval = 100; + const minimumRunningTime = 500; + const result = 42; // obviously + + it('allows a quick task to finish without registering the loading state', () => { + const taskDuration = 90; // less than ignoreInterval + const testTask = timer(taskDuration).pipe(map(() => result)); + + const subscriber = jest.fn(); + const onComplete = jest.fn(); + + runAsyncVisualTask({ + task: testTask, + ignoreTime: ignoreInterval, + minimumRunningTime + }).subscribe({ + next: subscriber, + complete: onComplete + }); + + jest.advanceTimersByTime(1000); + + expect(subscriber).toHaveBeenCalledTimes(1); // only one value comes out of the observable + expect(subscriber.mock.calls.at(-1)[0]).toEqual({ + status: 'success', + result + }); + expect(onComplete).toHaveBeenCalled(); // just making sure that the stream completes after the task is finished + }); + + it('maintains the loading state if task completes too soon', () => { + const taskDuration = 110; // a bit more than ignoreInterval, but less than ignoreInterval + minimumRunningTime + const testTask = timer(taskDuration).pipe(map(() => result)); + + const subscriber = jest.fn(); + + runAsyncVisualTask({ + task: testTask, + ignoreTime: ignoreInterval, + minimumRunningTime + }).subscribe(subscriber); + + jest.advanceTimersByTime(taskDuration); + + expect(subscriber).toHaveBeenCalledTimes(1); + expect(subscriber.mock.calls.at(-1)[0]).toEqual({ + status: 'loading', + result: null + }); + + jest.advanceTimersByTime(minimumRunningTime); + + expect(subscriber).toHaveBeenCalledTimes(2); + expect(subscriber.mock.calls.at(-1)[0]).toEqual({ + status: 'success', + result + }); + }); + + it('runs a slow task to completion', () => { + const taskDuration = 2000; // more than ignoreInterval + minimumRunningTime + const testTask = timer(taskDuration).pipe(map(() => result)); + + const subscriber = jest.fn(); + + runAsyncVisualTask({ + task: testTask, + ignoreTime: ignoreInterval, + minimumRunningTime + }).subscribe(subscriber); + + jest.advanceTimersByTime(taskDuration - 10); + + expect(subscriber).toHaveBeenCalledTimes(1); + expect(subscriber.mock.calls.at(-1)[0]).toEqual({ + status: 'loading', + result: null + }); + + jest.runAllTimers(); + + expect(subscriber).toHaveBeenCalledTimes(2); + expect(subscriber.mock.calls.at(-1)[0]).toEqual({ + status: 'success', + result + }); + }); + + it('calls onSuccess callback if the task succeeds', () => { + const taskDuration = 2000; + const testTask = timer(taskDuration).pipe(map(() => result)); + + const successHandler = jest.fn(); + const errorHandler = jest.fn(); + + runAsyncVisualTask({ + task: testTask, + ignoreTime: ignoreInterval, + minimumRunningTime, + onComplete: successHandler, + onError: errorHandler + }).subscribe(jest.fn()); + + jest.runAllTimers(); + + expect(successHandler.mock.calls.at(-1)[0]).toEqual(result); + expect(errorHandler).not.toHaveBeenCalled(); + }); + + it('calls onError callback if the task fails', () => { + const taskDuration = 2000; + const error = new Error('oops'); + const testTask = timer(taskDuration).pipe( + mergeMap(() => throwError(error)) + ); + + const successHandler = jest.fn(); + const errorHandler = jest.fn(); + + runAsyncVisualTask({ + task: testTask, + ignoreTime: ignoreInterval, + minimumRunningTime, + onComplete: successHandler, + onError: errorHandler + }).subscribe(jest.fn()); + + jest.runAllTimers(); + + expect(errorHandler.mock.calls.at(-1)[0]).toEqual(error); + expect(successHandler).not.toHaveBeenCalled(); + }); +});