Skip to content

Commit

Permalink
Removed useAsync lib and replaced it by own callback function
Browse files Browse the repository at this point in the history
  • Loading branch information
Clemens85 committed Dec 25, 2023
1 parent c949fa8 commit 77689bc
Show file tree
Hide file tree
Showing 11 changed files with 400 additions and 57 deletions.
15 changes: 0 additions & 15 deletions runningdinner-client/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion runningdinner-client/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
"@reduxjs/toolkit": "1.9.5",
"redux-thunk": "2.4.2",
"react-geocode": "0.2.3",
"react-async-hook": "4.0.0",
"redux": "4.2.1",
"i18next-browser-languagedetector": "7.1.0",
"@tanstack/react-query": "5.13.4"
Expand Down
2 changes: 2 additions & 0 deletions runningdinner-client/shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,5 @@ export * from "./landing";
export * from './afterpartylocation';
export * from './query';

export * from './useAsyncCallback.js';

346 changes: 346 additions & 0 deletions runningdinner-client/shared/src/useAsyncCallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,346 @@
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';


// See https://gist.github.com/gaearon/e7d97cdf38a2907924ea12e4ebdf3c85
const useIsomorphicLayoutEffect =
typeof window !== 'undefined' &&
typeof window.document !== 'undefined' &&
typeof window.document.createElement !== 'undefined'
? useLayoutEffect
: useEffect;

// Assign current value to a ref and returns a stable getter to get the latest value.
// This way we are sure to always get latest value provided to hook and
// avoid weird issues due to closures capturing stale values...
// See https://github.com/facebook/react/issues/16956
// See https://overreacted.io/making-setinterval-declarative-with-react-hooks/
const useGetter = <T>(t: T) => {
const ref = useRef(t);
useIsomorphicLayoutEffect(() => {
ref.current = t;
});
return useCallback(() => ref.current, [ref]);
};


// keep compat with TS < 3.5
type LegacyOmit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;

// Some options are not allowed for useAsyncCallback
export type UseAsyncCallbackOptions<R> =
| LegacyOmit<
Partial<UseAsyncOptionsNormalized<R>>,
'executeOnMount' | 'executeOnUpdate' | 'initialState'
>
| undefined
| null;

type UnknownResult = unknown;

// Convenient to avoid declaring the type of args, which may help reduce type boilerplate
//type UnknownArgs = unknown[];
// TODO unfortunately it seems required for now if we want default param to work...
// See https://twitter.com/sebastienlorber/status/1170003594894106624
type UnknownArgs = any[];

export type AsyncStateStatus =
| 'not-requested'
| 'loading'
| 'success'
| 'error';

export type AsyncState<R> = {
status: AsyncStateStatus;
loading: boolean;
error: Error | undefined;
result: R | undefined;
};
type SetLoading<R> = (asyncState: AsyncState<R>) => AsyncState<R>;
type SetResult<R> = (result: R, asyncState: AsyncState<R>) => AsyncState<R>;
type SetError<R> = (error: Error, asyncState: AsyncState<R>) => AsyncState<R>;

type MaybePromise<T> = Promise<T> | T;

export type UseAsyncReturn<
R = UnknownResult,
Args extends any[] = UnknownArgs
> = AsyncState<R> & {
set: (value: AsyncState<R>) => void;
merge: (value: Partial<AsyncState<R>>) => void;
reset: () => void;
execute: (...args: Args) => Promise<R>;
currentPromise: Promise<R> | null;
currentParams: Args | null;
};

type PromiseCallbackOptions = {
// Permit to know if the success/error belongs to the last async call
isCurrent: () => boolean;
};

const InitialAsyncState: AsyncState<any> = {
status: 'not-requested',
loading: false,
result: undefined,
error: undefined,
};

const InitialAsyncLoadingState: AsyncState<any> = {
status: 'loading',
loading: true,
result: undefined,
error: undefined,
};

// eslint-disable-next-line
const defaultSetLoading: SetLoading<any> = _asyncState =>
InitialAsyncLoadingState;

// eslint-disable-next-line
const defaultSetResult: SetResult<any> = (result, _asyncState) => ({
status: 'success',
loading: false,
result: result,
error: undefined,
});

// eslint-disable-next-line
const defaultSetError: SetError<any> = (error, _asyncState) => ({
status: 'error',
loading: false,
result: undefined,
error: error,
});

export type UseAsyncOptionsNormalized<R> = {
initialState: (options?: UseAsyncOptionsNormalized<R>) => AsyncState<R>;
executeOnMount: boolean;
executeOnUpdate: boolean;
setLoading: SetLoading<R>;
setResult: SetResult<R>;
setError: SetError<R>;
onSuccess: (r: R, options: PromiseCallbackOptions) => void;
onError: (e: Error, options: PromiseCallbackOptions) => void;
};

const noop = () => {};

export type UseAsyncOptions<R> =
| Partial<UseAsyncOptionsNormalized<R>>
| undefined
| null;

const DefaultOptions: UseAsyncOptionsNormalized<any> = {
initialState: options =>
options && options.executeOnMount
? InitialAsyncLoadingState
: InitialAsyncState,
executeOnMount: true,
executeOnUpdate: true,
setLoading: defaultSetLoading,
setResult: defaultSetResult,
setError: defaultSetError,
onSuccess: noop,
onError: noop,
};


const normalizeOptions = <R>(
options: UseAsyncOptions<R>
): UseAsyncOptionsNormalized<R> => ({
...DefaultOptions,
...options,
});

const useIsMounted = (): (() => boolean) => {
const ref = useRef<boolean>(false);
useEffect(() => {
ref.current = true;
return () => {
ref.current = false;
};
}, []);
return () => ref.current;
};

type UseCurrentPromiseReturn<R> = {
set: (promise: Promise<R>) => void;
get: () => Promise<R> | null;
is: (promise: Promise<R>) => boolean;
};
const useCurrentPromise = <R>(): UseCurrentPromiseReturn<R> => {
const ref = useRef<Promise<R> | null>(null);
return {
set: promise => (ref.current = promise),
get: () => ref.current,
is: promise => ref.current === promise,
};
};

type UseAsyncStateResult<R> = {
value: AsyncState<R>;
set: Dispatch<SetStateAction<AsyncState<R>>>;
merge: (value: Partial<AsyncState<R>>) => void;
reset: () => void;
setLoading: () => void;
setResult: (r: R) => void;
setError: (e: Error) => void;
};

// eslint-disable-next-line
const useAsyncState = <R extends {}>(
options: UseAsyncOptionsNormalized<R>
): UseAsyncStateResult<R> => {
const [value, setValue] = useState<AsyncState<R>>(() =>
options.initialState(options)
);

const reset = useCallback(() => setValue(options.initialState(options)), [
setValue,
options,
]);

const setLoading = useCallback(() => setValue(options.setLoading(value)), [
value,
setValue,
]);
const setResult = useCallback(
(result: R) => setValue(options.setResult(result, value)),
[value, setValue]
);

const setError = useCallback(
(error: Error) => setValue(options.setError(error, value)),
[value, setValue]
);

const merge = useCallback(
(state: Partial<AsyncState<R>>) =>
setValue({
...value,
...state,
}),
[value, setValue]
);

return {
value,
set: setValue,
merge,
reset,
setLoading,
setResult,
setError,
};
};

// Accepting sync function is convenient for useAsyncCallback
const useAsyncInternal = <R = UnknownResult, Args extends any[] = UnknownArgs>(
asyncFunction: (...args: Args) => MaybePromise<R>,
params: Args,
options?: UseAsyncOptions<R>
): UseAsyncReturn<R, Args> => {
// Fallback missing params, only for JS users forgetting the deps array, to prevent infinite loops
// https://github.com/slorber/react-async-hook/issues/27
// @ts-ignore
!params && (params = []);

const normalizedOptions = normalizeOptions<R>(options);

const [currentParams, setCurrentParams] = useState<Args | null>(null);

// @ts-ignore
const AsyncState = useAsyncState<R>(normalizedOptions);

const isMounted = useIsMounted();
const CurrentPromise = useCurrentPromise<R>();

// We only want to handle the promise result/error
// if it is the last operation and the comp is still mounted
const shouldHandlePromise = (p: Promise<R>) =>
isMounted() && CurrentPromise.is(p);

const executeAsyncOperation = (...args: Args): Promise<R> => {
// async ensures errors thrown synchronously are caught (ie, bug when formatting api payloads)
// async ensures promise-like and synchronous functions are handled correctly too
// see https://github.com/slorber/react-async-hook/issues/24
const promise: Promise<R> = (async () => asyncFunction(...args))();
setCurrentParams(args);
CurrentPromise.set(promise);
AsyncState.setLoading();
promise.then(
result => {
if (shouldHandlePromise(promise)) {
AsyncState.setResult(result);
}
normalizedOptions.onSuccess(result, {
isCurrent: () => CurrentPromise.is(promise),
});
},
error => {
if (shouldHandlePromise(promise)) {
AsyncState.setError(error);
}
normalizedOptions.onError(error, {
isCurrent: () => CurrentPromise.is(promise),
});
}
);
return promise;
};

const getLatestExecuteAsyncOperation = useGetter(executeAsyncOperation);

const executeAsyncOperationMemo: (...args: Args) => Promise<R> = useCallback(
(...args) => getLatestExecuteAsyncOperation()(...args),
[getLatestExecuteAsyncOperation]
);

// Keep this outside useEffect, because inside isMounted()
// will be true as the component is already mounted when it's run
const isMounting = !isMounted();
useEffect(() => {
const execute = () => getLatestExecuteAsyncOperation()(...params);
isMounting && normalizedOptions.executeOnMount && execute();
!isMounting && normalizedOptions.executeOnUpdate && execute();
}, params);

return {
...AsyncState.value,
set: AsyncState.set,
merge: AsyncState.merge,
reset: AsyncState.reset,
execute: executeAsyncOperationMemo,
currentPromise: CurrentPromise.get(),
currentParams,
};
};


export const useAsyncCallback = <
R = UnknownResult,
Args extends any[] = UnknownArgs
>(
asyncFunction: (...args: Args) => MaybePromise<R>,
options?: UseAsyncCallbackOptions<R>
): UseAsyncReturn<R, Args> => {
return useAsyncInternal(
asyncFunction,
// Hacky but in such case we don't need the params,
// because async function is only executed manually
[] as any,
{
...options,
executeOnMount: false,
executeOnUpdate: false,
}
);
};
Loading

0 comments on commit 77689bc

Please sign in to comment.