Skip to content

Commit

Permalink
[Security Solution][Lists] More composable hooks/utilities (#70372)
Browse files Browse the repository at this point in the history
* Add wrapper function to make an AbortSignal arg optional

Components commonly do not care about aborting a request, but are
required to pass `{ signal: new AbortController().signal }` anyway. This
addresses that use case.

* Adds hook for retrieving the component's mount status

This is useful for dealing with asynchronous tasks that may complete
after the invoking component has been unmounted. Using this hook,
callbacks can determine whether they're currently unmounted, i.e.
whether it's safe to set state or not.

* Add our own implemetation of useAsync

This does not suffer from the Typescript issues that the react-use
implementation had, and is generally a cleaner hook than useAsyncTask as
it makes no assumptions about the underlying function.

* Update exported Lists API hooks to use useAsync and withOptionalSignal

Removes the now-unused useAsyncTask as well.

* Add some JSDoc for our new functions
  • Loading branch information
rylnd committed Jul 1, 2020
1 parent bc802c3 commit d8d24be
Show file tree
Hide file tree
Showing 12 changed files with 268 additions and 177 deletions.
98 changes: 98 additions & 0 deletions x-pack/plugins/lists/public/common/hooks/use_async.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import { act, renderHook } from '@testing-library/react-hooks';

import { useAsync } from './use_async';

interface TestArgs {
n: number;
s: string;
}

type TestReturn = Promise<unknown>;

describe('useAsync', () => {
let fn: jest.Mock<TestReturn, TestArgs[]>;
let args: TestArgs;

beforeEach(() => {
args = { n: 1, s: 's' };
fn = jest.fn().mockResolvedValue(false);
});

it('does not invoke fn if start was not called', () => {
renderHook(() => useAsync(fn));
expect(fn).not.toHaveBeenCalled();
});

it('invokes the function when start is called', async () => {
const { result, waitForNextUpdate } = renderHook(() => useAsync(fn));

act(() => {
result.current.start(args);
});
await waitForNextUpdate();

expect(fn).toHaveBeenCalled();
});

it('invokes the function with start args', async () => {
const { result, waitForNextUpdate } = renderHook(() => useAsync(fn));
const expectedArgs = { ...args };

act(() => {
result.current.start(args);
});
await waitForNextUpdate();

expect(fn).toHaveBeenCalledWith(expectedArgs);
});

it('populates result with the resolved value of the fn', async () => {
const { result, waitForNextUpdate } = renderHook(() => useAsync(fn));
fn.mockResolvedValue({ resolved: 'value' });

act(() => {
result.current.start(args);
});
await waitForNextUpdate();

expect(result.current.result).toEqual({ resolved: 'value' });
expect(result.current.error).toBeUndefined();
});

it('populates error if function rejects', async () => {
fn.mockRejectedValue(new Error('whoops'));
const { result, waitForNextUpdate } = renderHook(() => useAsync(fn));

act(() => {
result.current.start(args);
});
await waitForNextUpdate();

expect(result.current.result).toBeUndefined();
expect(result.current.error).toEqual(new Error('whoops'));
});

it('populates the loading state while the function is pending', async () => {
let resolve: () => void;
fn.mockImplementation(() => new Promise((_resolve) => (resolve = _resolve)));

const { result, waitForNextUpdate } = renderHook(() => useAsync(fn));

act(() => {
result.current.start(args);
});

expect(result.current.loading).toBe(true);

act(() => resolve());
await waitForNextUpdate();

expect(result.current.loading).toBe(false);
});
});
49 changes: 49 additions & 0 deletions x-pack/plugins/lists/public/common/hooks/use_async.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import { useCallback, useState } from 'react';

import { useIsMounted } from './use_is_mounted';

export interface Async<Args extends unknown[], Result> {
loading: boolean;
error: unknown | undefined;
result: Result | undefined;
start: (...args: Args) => void;
}

/**
*
* @param fn Async function
*
* @returns An {@link AsyncTask} containing the underlying task's state along with a start callback
*/
export const useAsync = <Args extends unknown[], Result>(
fn: (...args: Args) => Promise<Result>
): Async<Args, Result> => {
const isMounted = useIsMounted();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<unknown | undefined>();
const [result, setResult] = useState<Result | undefined>();

const start = useCallback(
(...args: Args) => {
setLoading(true);
fn(...args)
.then((r) => isMounted() && setResult(r))
.catch((e) => isMounted() && setError(e))
.finally(() => isMounted() && setLoading(false));
},
[fn, isMounted]
);

return {
error,
loading,
result,
start,
};
};
93 changes: 0 additions & 93 deletions x-pack/plugins/lists/public/common/hooks/use_async_task.test.ts

This file was deleted.

48 changes: 0 additions & 48 deletions x-pack/plugins/lists/public/common/hooks/use_async_task.ts

This file was deleted.

24 changes: 24 additions & 0 deletions x-pack/plugins/lists/public/common/hooks/use_is_mounted.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import { renderHook } from '@testing-library/react-hooks';

import { useIsMounted } from './use_is_mounted';

describe('useIsMounted', () => {
it('evaluates to true when mounted', () => {
const { result } = renderHook(() => useIsMounted());

expect(result.current()).toEqual(true);
});

it('evaluates to false when unmounted', () => {
const { result, unmount } = renderHook(() => useIsMounted());

unmount();
expect(result.current()).toEqual(false);
});
});
28 changes: 28 additions & 0 deletions x-pack/plugins/lists/public/common/hooks/use_is_mounted.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import { useCallback, useEffect, useRef } from 'react';

type GetIsMounted = () => boolean;

/**
*
* @returns A {@link GetIsMounted} getter function returning whether the component is currently mounted
*/
export const useIsMounted = (): GetIsMounted => {
const isMounted = useRef(false);
const getIsMounted: GetIsMounted = useCallback(() => isMounted.current, []);
const handleCleanup = useCallback(() => {
isMounted.current = false;
}, []);

useEffect(() => {
isMounted.current = true;
return handleCleanup;
}, [handleCleanup]);

return getIsMounted;
};
29 changes: 29 additions & 0 deletions x-pack/plugins/lists/public/common/with_optional_signal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import { withOptionalSignal } from './with_optional_signal';

type TestFn = ({ number, signal }: { number: number; signal: AbortSignal }) => boolean;

describe('withOptionalSignal', () => {
it('does not require a signal on the returned function', () => {
const fn = jest.fn().mockReturnValue('hello') as TestFn;

const wrappedFn = withOptionalSignal(fn);

expect(wrappedFn({ number: 1 })).toEqual('hello');
});

it('will pass a given signal to the wrapped function', () => {
const fn = jest.fn().mockReturnValue('hello') as TestFn;
const { signal } = new AbortController();

const wrappedFn = withOptionalSignal(fn);

wrappedFn({ number: 1, signal });
expect(fn).toHaveBeenCalledWith({ number: 1, signal });
});
});
Loading

0 comments on commit d8d24be

Please sign in to comment.