Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

EXPERIMENTAL: Add a utility for running async visual tasks #841

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions src/shared/hooks/useAsyncVisualTask.ts
Original file line number Diff line number Diff line change
@@ -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;
119 changes: 119 additions & 0 deletions src/shared/utils/runAsyncVisualTask.ts
Original file line number Diff line number Diff line change
@@ -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 = <T>(params: {
task: Promise<T> | Observable<T>; // 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;
159 changes: 159 additions & 0 deletions src/shared/utils/tests/runAsyncVisualTask.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});