-
Notifications
You must be signed in to change notification settings - Fork 8.3k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[Security Solution][Lists] More composable hooks/utilities (#70372)
* 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
Showing
12 changed files
with
268 additions
and
177 deletions.
There are no files selected for viewing
98 changes: 98 additions & 0 deletions
98
x-pack/plugins/lists/public/common/hooks/use_async.test.ts
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,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); | ||
}); | ||
}); |
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,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
93
x-pack/plugins/lists/public/common/hooks/use_async_task.test.ts
This file was deleted.
Oops, something went wrong.
48 changes: 0 additions & 48 deletions
48
x-pack/plugins/lists/public/common/hooks/use_async_task.ts
This file was deleted.
Oops, something went wrong.
24 changes: 24 additions & 0 deletions
24
x-pack/plugins/lists/public/common/hooks/use_is_mounted.test.ts
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,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
28
x-pack/plugins/lists/public/common/hooks/use_is_mounted.ts
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,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
29
x-pack/plugins/lists/public/common/with_optional_signal.test.ts
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,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 }); | ||
}); | ||
}); |
Oops, something went wrong.