From 8fa366b344f79c5e08e16c3614c2b4cedcc9226d Mon Sep 17 00:00:00 2001 From: galiprandi <20272796+galiprandi@users.noreply.github.com> Date: Mon, 15 Jun 2026 04:25:26 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Quality:=20documentation=20and=20te?= =?UTF-8?q?st=20coverage=20-=20AsyncBlock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Enhanced JSDoc with detailed parameter documentation and a comprehensive usage example. - Added test cases for timeout clearing, component unmounting, and abort signals. - Improved test environment cleanup by centralizing `vi.useRealTimers()` in `afterEach`. - Reached near-complete branch coverage for the component. --- lib/components/AsyncBlock/index.test.tsx | 123 ++++++++++++++++++++++- lib/components/AsyncBlock/index.tsx | 42 ++++++-- 2 files changed, 152 insertions(+), 13 deletions(-) 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 = ({