diff --git a/lib/components/AsyncBlock/index.test.tsx b/lib/components/AsyncBlock/index.test.tsx index 24f05ff..5c77a47 100644 --- a/lib/components/AsyncBlock/index.test.tsx +++ b/lib/components/AsyncBlock/index.test.tsx @@ -14,6 +14,7 @@ describe('', () => { afterEach(() => { vi.restoreAllMocks() + vi.useRealTimers() }) it('should render pending state', () => { @@ -219,7 +220,6 @@ describe('', () => { }) expect(onError).not.toHaveBeenCalled() - vi.useRealTimers() }) it('should call reload when pending function is called', async () => { @@ -270,7 +270,6 @@ describe('', () => { }) expect(onError).not.toHaveBeenCalled() - vi.useRealTimers() }) it('should not update state if aborted due to dependency change (race condition)', async () => { @@ -357,4 +356,124 @@ describe('', () => { // It should render nothing expect(container.firstChild).toBeNull() }) + + it('should clear timeout if promise resolves before timeout', async () => { + vi.useFakeTimers() + const spy = vi.spyOn(global, 'clearTimeout') + + let resolvePromise: (val: string) => void + const promise = new Promise((resolve) => { + resolvePromise = resolve + }) + + render( + promise} + pending="Loading" + success={(data) =>
{data}
} + timeOut={1000} + />, + ) + + await act(async () => { + resolvePromise!('Success') + }) + + expect(spy).toHaveBeenCalled() + spy.mockRestore() + }) + + it('should clear timeout if promise rejects before timeout', async () => { + vi.useFakeTimers() + const spy = vi.spyOn(global, 'clearTimeout') + + let rejectPromise: (reason?: unknown) => void + const promise = new Promise((_, reject) => { + rejectPromise = reject + }) + + render( + promise} + pending="Loading" + success={() => 'Success'} + error={() => 'Error'} + timeOut={1000} + />, + ) + + await act(async () => { + rejectPromise!(new Error('Failed')) + }) + + expect(spy).toHaveBeenCalled() + spy.mockRestore() + }) + + it('should not call onError if unmounted before promise rejects', async () => { + const onError = vi.fn() + let rejectPromise: (reason?: unknown) => void + const promise = new Promise((_, reject) => { + rejectPromise = reject + }) + + const { unmount } = render( + promise} + pending="Loading" + success={() => 'Success'} + error={() => 'Error'} + onError={onError} + />, + ) + + unmount() + + await act(async () => { + rejectPromise!(new Error('Failed')) + }) + + expect(onError).not.toHaveBeenCalled() + }) + + it('should not call onError if aborted due to dependency change', async () => { + let rejectFirstPromise: (reason?: unknown) => void + const firstPromise = new Promise((_, reject) => { + rejectFirstPromise = reject + }) + + const promiseFn = vi + .fn() + .mockReturnValueOnce(firstPromise) + .mockReturnValue(Promise.resolve()) + const onError = vi.fn() + + const { rerender } = render( + 'Success'} + error={() => 'Error'} + deps={[1]} + onError={onError} + />, + ) + + rerender( + 'Success'} + error={() => 'Error'} + deps={[2]} + onError={onError} + />, + ) + + await act(async () => { + rejectFirstPromise!(new Error('Aborted')) + }) + + expect(onError).not.toHaveBeenCalled() + }) }) diff --git a/lib/components/AsyncBlock/index.tsx b/lib/components/AsyncBlock/index.tsx index 85b6482..75f4802 100644 --- a/lib/components/AsyncBlock/index.tsx +++ b/lib/components/AsyncBlock/index.tsx @@ -14,20 +14,40 @@ import { * dependency-based re-execution, and manual reloads. * * @template T - The type of data returned by the async operation. - * @param props - Props to control the behavior and rendering of the async operation. - * @returns Rendered result based on async state. + * @param {AsyncBlockProps} props - Props to control the behavior and rendering of the async operation. + * @returns {JSX.Element | null} Rendered result based on async state. * * @example - * * ```tsx - * fetch('/api/user', { signal }).then(res => res.json())} - * pending={

Loading...

} - * success={(data) => } - * error={(err) =>

Error: {err.message}

} - * timeOut={5000} - * deps={[userId]} - * /> + * import { AsyncBlock } from '@galiprandi/react-tools'; + * + * const UserProfileWrapper = ({ userId }) => ( + * fetch(`/api/user/${userId}`, { signal }).then(res => res.json())} + * pending={(reload) => ( + *
+ *

Loading user...

+ * + *
+ * )} + * success={(user, reload) => ( + *
+ *

{user.name}

+ * + *
+ * )} + * error={(err, reload) => ( + *
+ *

Error: {(err as Error).message}

+ * + *
+ * )} + * timeOut={5000} + * deps={[userId]} + * onSuccess={(data) => console.log('Loaded:', data)} + * onError={(err) => console.error('Failed:', err)} + * /> + * ); * ``` */ export const AsyncBlock = ({