Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .axioma/quality.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,7 @@
## 2026-06-07 - [Explicit AbortError State Management]
**Learning:** When wrapping browser-native asynchronous APIs (like the Built-in AI APIs), it's critical to explicitly handle the `AbortError` in `catch` blocks by resetting the hook's status to `'idle'`. This ensures that if an operation is cancelled (e.g., via a signal or component unmount), the UI state doesn't remain "stuck" in a loading or active state, improving the robustness of state management across the application.
**Action:** Always transition the status to `'idle'` when an `AbortError` is caught in AI-related hooks to maintain synchronization between the UI and the underlying API state.

## 2026-06-08 - Distinguishing Abort Reasons in Async Components
**Learning:** When using `AbortController` in components that also implement a `timeOut`, it is critical to distinguish between a manual abort (e.g., due to dependency change or unmount) and a timeout abort. Indiscriminately ignoring all aborted promises in a `.catch` block can swallow legitimate timeout errors.
**Action:** Use `signal.reason` to filter aborts in `.catch` blocks: `if (signal.aborted && signal.reason !== 'Timeout') return`. This ensures race conditions are prevented while still allowing the component to transition to an error state upon timeout.
98 changes: 97 additions & 1 deletion lib/components/AsyncBlock/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import {
describe,
it,
expect,
beforeEach,
afterEach,
vi,
} from 'vitest'
import { render, cleanup, screen, act, fireEvent } from '@testing-library/react'
import { AsyncBlock } from './index'

describe('<AsyncBlock />', () => {
beforeEach(cleanup)

afterEach(() => {
vi.restoreAllMocks()
})

it('should render pending state', () => {
const pendingContent = 'Loading...'
render(
Expand Down Expand Up @@ -261,4 +272,89 @@ describe('<AsyncBlock />', () => {
expect(onError).not.toHaveBeenCalled()
vi.useRealTimers()
})

it('should not update state if aborted due to dependency change (race condition)', async () => {
let rejectFirstPromise: (reason?: unknown) => void
const firstPromise = new Promise((_, reject) => {
rejectFirstPromise = reject
})

const secondPromise = Promise.resolve('Success 2')

const promiseFn = vi
.fn()
.mockReturnValueOnce(firstPromise)
.mockReturnValueOnce(secondPromise)

const onError = vi.fn()
const onSuccess = vi.fn()

const { rerender } = render(
<AsyncBlock
promiseFn={promiseFn}
pending="Loading"
success={(data) => <div>{data}</div>}
error={() => <div>Error</div>}
deps={[1]}
onSuccess={onSuccess}
onError={onError}
/>,
)

expect(promiseFn).toHaveBeenCalledTimes(1)
expect(screen.getByText('Loading')).toBeDefined()

// Trigger dependency change, which aborts the first promise
rerender(
<AsyncBlock
promiseFn={promiseFn}
pending="Loading"
success={(data) => <div>{data}</div>}
error={() => <div>Error</div>}
deps={[2]}
onSuccess={onSuccess}
onError={onError}
/>,
)

expect(promiseFn).toHaveBeenCalledTimes(2)

// Now reject the first promise (which is already aborted)
await act(async () => {
rejectFirstPromise!(new Error('First promise failed'))
})

// It should NOT be in error state because the first promise was aborted
expect(screen.queryByText('Error')).toBeNull()
expect(onError).not.toHaveBeenCalled()

// Wait for the second promise to resolve
await screen.findByText('Success 2')
expect(onSuccess).toHaveBeenCalledWith('Success 2')
})

it('should render nothing if error occurs and no error component is provided', async () => {
const promiseFn = () => Promise.reject(new Error('Failed'))

const { container } = render(
<AsyncBlock
promiseFn={promiseFn}
pending="Loading"
success={() => 'Success'}
// error prop is missing
/>,
)

// Wait for it to fail (it starts with "Loading")
await act(async () => {
try {
await promiseFn()
} catch {
// Ignore
}
})

// It should render nothing
expect(container.firstChild).toBeNull()
})
})
31 changes: 24 additions & 7 deletions lib/components/AsyncBlock/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,15 @@ import {
/**
* AsyncBlock component – Declaratively renders asynchronous content with pending, success, and error states.
*
* @param {AsyncBlockProps<T>} props - Props to control the behavior and rendering of the async operation.
* @returns {JSX.Element | null} - Rendered result based on async state.
* This component manages the lifecycle of an asynchronous operation (e.g., a fetch request),
* automatically handling pending, success, and error states. It also supports timeouts,
* dependency-based re-execution, and manual reloads.
*
* Example usage:
* @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.
*
* @example
*
* ```tsx
* <AsyncBlock
Expand Down Expand Up @@ -53,13 +58,11 @@ export const AsyncBlock = <T,>({
setData(null)
setErr(null)

let didTimeOut = false
let timer: ReturnType<typeof setTimeout> | null = null
const signal = abortController.signal

if (timeOut) {
timer = setTimeout(() => {
didTimeOut = true
abortController.abort('Timeout')
if (isMounted.current) {
setState('error')
Expand All @@ -83,7 +86,7 @@ export const AsyncBlock = <T,>({
}
})
.catch((e) => {
if (signal.aborted && didTimeOut) return
if (signal.aborted && signal.reason !== 'Timeout') return

if (isMounted.current) {
if (timer) clearTimeout(timer)
Expand Down Expand Up @@ -137,29 +140,43 @@ export interface AsyncBlockProps<T> {
/**
* A function that returns a Promise to be executed.
* Optionally receives an AbortSignal that can be used to cancel the request.
*
* @param signal - An AbortSignal to handle request cancellation.
*/
promiseFn: (signal?: AbortSignal) => Promise<T>
/**
* Element or function to render while the promise is pending.
* Optionally receives a reload function to re-run the async operation.
*
* @param reload - Function to re-run the async operation.
*/
pending: ReactNode | ((reload: () => void) => ReactNode)
/**
* Function to render if the promise resolves successfully.
* Receives the data and a reload function.
*
* @param data - The data returned by the promise.
* @param reload - Function to re-run the async operation.
*/
success: (data: T, reload: () => void) => ReactNode
/**
* Function to render if the promise is rejected or times out.
* Receives the error and a reload function.
*
* @param err - The error returned by the promise or the timeout error.
* @param reload - Function to re-run the async operation.
*/
error: (err: unknown, reload: () => void) => ReactNode
error?: (err: unknown, reload: () => void) => ReactNode
/**
* Optional timeout in milliseconds before failing the promise.
*
* @default undefined
*/
timeOut?: number
/**
* Dependencies to determine when to re-run the promise.
*
* @default []
*/
deps?: unknown[]
/**
Expand Down
Loading