Skip to content
Open
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
6 changes: 3 additions & 3 deletions CONTRIBUTORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ We want to thank all the amazing contributors who have helped make TermUI what i

| Avatar | Contributor | Contributions |
| :---: | :--- | :---: |
| <img src="https://avatars.githubusercontent.com/u/99573351?v=4" width="40" height="40" style="border-radius: 50%;" alt="Karanjot786" /> | [@Karanjot786](https://github.com/Karanjot786) | 348 |
| <img src="https://avatars.githubusercontent.com/u/99573351?v=4" width="40" height="40" style="border-radius: 50%;" alt="Karanjot786" /> | [@Karanjot786](https://github.com/Karanjot786) | 349 |
| <img src="https://avatars.githubusercontent.com/u/179694969?v=4" width="40" height="40" style="border-radius: 50%;" alt="Tomeshwari-02" /> | [@Tomeshwari-02](https://github.com/Tomeshwari-02) | 155 |
| <img src="https://avatars.githubusercontent.com/u/201338831?v=4" width="40" height="40" style="border-radius: 50%;" alt="ionfwsrijan" /> | [@ionfwsrijan](https://github.com/ionfwsrijan) | 92 |
| <img src="https://avatars.githubusercontent.com/u/220186330?v=4" width="40" height="40" style="border-radius: 50%;" alt="srushti-panara" /> | [@srushti-panara](https://github.com/srushti-panara) | 86 |
Expand All @@ -13,7 +13,7 @@ We want to thank all the amazing contributors who have helped make TermUI what i
| <img src="https://avatars.githubusercontent.com/u/208418469?v=4" width="40" height="40" style="border-radius: 50%;" alt="Aryan-Agarwal-creator" /> | [@Aryan-Agarwal-creator](https://github.com/Aryan-Agarwal-creator) | 40 |
| <img src="https://avatars.githubusercontent.com/u/195310178?v=4" width="40" height="40" style="border-radius: 50%;" alt="ZainabTravadi" /> | [@ZainabTravadi](https://github.com/ZainabTravadi) | 38 |
| <img src="https://avatars.githubusercontent.com/u/203988207?v=4" width="40" height="40" style="border-radius: 50%;" alt="jainiksha" /> | [@jainiksha](https://github.com/jainiksha) | 31 |
| <img src="https://avatars.githubusercontent.com/u/260001286?v=4" width="40" height="40" style="border-radius: 50%;" alt="ashrion" /> | [@ashrion](https://github.com/ashrion) | 28 |
| <img src="https://avatars.githubusercontent.com/u/260001286?v=4" width="40" height="40" style="border-radius: 50%;" alt="ashroxy" /> | [@ashroxy](https://github.com/ashroxy) | 28 |
| <img src="https://avatars.githubusercontent.com/u/150170054?v=4" width="40" height="40" style="border-radius: 50%;" alt="realtushartyagi" /> | [@realtushartyagi](https://github.com/realtushartyagi) | 27 |
| <img src="https://avatars.githubusercontent.com/u/228184960?v=4" width="40" height="40" style="border-radius: 50%;" alt="riddhima25bet10005-a11y" /> | [@riddhima25bet10005-a11y](https://github.com/riddhima25bet10005-a11y) | 20 |
| <img src="https://avatars.githubusercontent.com/u/191742943?v=4" width="40" height="40" style="border-radius: 50%;" alt="Rish-2006" /> | [@Rish-2006](https://github.com/Rish-2006) | 18 |
Expand Down Expand Up @@ -78,7 +78,7 @@ We want to thank all the amazing contributors who have helped make TermUI what i
| <img src="https://avatars.githubusercontent.com/u/185586939?v=4" width="40" height="40" style="border-radius: 50%;" alt="KanchanWaldia" /> | [@KanchanWaldia](https://github.com/KanchanWaldia) | 4 |
| <img src="https://avatars.githubusercontent.com/u/191087385?v=4" width="40" height="40" style="border-radius: 50%;" alt="Abhik-Mudi" /> | [@Abhik-Mudi](https://github.com/Abhik-Mudi) | 4 |
| <img src="https://avatars.githubusercontent.com/u/248511551?v=4" width="40" height="40" style="border-radius: 50%;" alt="akshayad2006-cmd" /> | [@akshayad2006-cmd](https://github.com/akshayad2006-cmd) | 3 |
| <img src="https://avatars.githubusercontent.com/u/130930054?v=4" width="40" height="40" style="border-radius: 50%;" alt="siddiqui7864" /> | [@siddiqui7864](https://github.com/siddiqui7864) | 3 |
| <img src="https://avatars.githubusercontent.com/u/130930054?v=4" width="40" height="40" style="border-radius: 50%;" alt="sh4dr0x" /> | [@sh4dr0x](https://github.com/sh4dr0x) | 3 |
| <img src="https://avatars.githubusercontent.com/u/225655004?v=4" width="40" height="40" style="border-radius: 50%;" alt="YASHcode-IIITV" /> | [@YASHcode-IIITV](https://github.com/YASHcode-IIITV) | 3 |
| <img src="https://avatars.githubusercontent.com/u/193688804?v=4" width="40" height="40" style="border-radius: 50%;" alt="titax03" /> | [@titax03](https://github.com/titax03) | 3 |
| <img src="https://avatars.githubusercontent.com/u/286555306?v=4" width="40" height="40" style="border-radius: 50%;" alt="pixeltannu" /> | [@pixeltannu](https://github.com/pixeltannu) | 3 |
Expand Down
69 changes: 2 additions & 67 deletions packages/jsx/src/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -725,71 +725,6 @@ export function collectInputHandlers(fiber: Fiber): Array<(event: KeyEvent) => v
}

// ── Async Data Hook ──
export { useAsync } from './hooks/useAsync.js';
export type { AsyncState, UseAsyncOptions, UseAsyncResult } from './hooks/useAsync.js';

/**
* State shape returned by useAsync.
*/
export interface AsyncState<T> {
/** Resolved data (null while loading or on error) */
data: T | null;
/** True while the async function is executing */
loading: boolean;
/** Error object if the async function threw */
error: Error | null;
/** Call this to re-execute the async function */
refetch: () => void;
}

/**
* useAsync — load async data with automatic loading/error states.
*
* ```tsx
* function UserList() {
* const { data, loading, error } = useAsync(() => fetchUsers(), []);
* if (loading) return <Text>Loading...</Text>;
* if (error) return <Text color="red">Error: {error.message}</Text>;
* return <Table rows={data} columns={['name', 'email']} />;
* }
* ```
*/
export function useAsync<T>(
asyncFn: () => Promise<T>,
deps: any[] = [],
): AsyncState<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);

// Track a version counter to ignore stale responses
const versionRef = useRef(0);
// Always call the latest asyncFn to avoid stale closure
const asyncFnRef = useRef(asyncFn);
asyncFnRef.current = asyncFn;

const refetch = useCallback(() => {
const version = ++versionRef.current;
setLoading(true);
setError(null);

asyncFnRef.current()
.then((result) => {
// Only update if this is still the latest request
if (versionRef.current === version) {
setData(result);
setLoading(false);
}
})
.catch((err) => {
if (versionRef.current === version) {
setError(err instanceof Error ? err : new Error(String(err)));
setLoading(false);
}
});
}, deps);

useEffect(() => {
refetch();
}, deps);

return { data, loading, error, refetch };
}
206 changes: 206 additions & 0 deletions packages/jsx/src/hooks/useAsync.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
// ─────────────────────────────────────────────────────
// @termuijs/jsx — Tests for useAsync hook
// ─────────────────────────────────────────────────────
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
createFiber, setCurrentFiber, clearCurrentFiber,
setRequestRender, runEffects, destroyFiber,
} from '../hooks.js';
import { useAsync } from './useAsync.js';

function renderWithFiber<T>(fiber: ReturnType<typeof createFiber>, fn: () => T): T {
setCurrentFiber(fiber);
const result = fn();
clearCurrentFiber();
runEffects(fiber);
return result;
}

describe('useAsync', () => {
beforeEach(() => {
setRequestRender(() => {});
});

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

it('starts loading immediately and resolves data on success', async () => {
const fiber = createFiber();
const asyncFn = vi.fn().mockResolvedValue('hello world');

let res = renderWithFiber(fiber, () => useAsync(asyncFn));
expect(res.isLoading).toBe(true);
expect(res.loading).toBe(true);
expect(res.isIdle).toBe(false);
expect(res.data).toBeNull();

await asyncFn();

res = renderWithFiber(fiber, () => useAsync(asyncFn));
expect(res.isLoading).toBe(false);
expect(res.isSuccess).toBe(true);
expect(res.data).toBe('hello world');
expect(res.error).toBeNull();

destroyFiber(fiber);
});

it('captures error when async function rejects', async () => {
const fiber = createFiber();
const testError = new Error('Network failure');
const asyncFn = vi.fn().mockRejectedValue(testError);

let res = renderWithFiber(fiber, () => useAsync(asyncFn));
expect(res.isLoading).toBe(true);

try {
await asyncFn();
} catch {
// expected rejection
}

res = renderWithFiber(fiber, () => useAsync(asyncFn));
expect(res.isLoading).toBe(false);
expect(res.isError).toBe(true);
expect(res.error).toBe(testError);
expect(res.data).toBeNull();

destroyFiber(fiber);
});

it('respects immediate: false and starts in isIdle state', async () => {
const fiber = createFiber();
const asyncFn = vi.fn().mockResolvedValue(42);

let res = renderWithFiber(fiber, () => useAsync(asyncFn, { immediate: false }));
expect(res.isIdle).toBe(true);
expect(res.isLoading).toBe(false);
expect(asyncFn).not.toHaveBeenCalled();

res.execute();

res = renderWithFiber(fiber, () => useAsync(asyncFn, { immediate: false }));
expect(res.isLoading).toBe(true);

await asyncFn();

res = renderWithFiber(fiber, () => useAsync(asyncFn, { immediate: false }));
expect(res.isSuccess).toBe(true);
expect(res.data).toBe(42);

destroyFiber(fiber);
});

it('invokes onSuccess and onError callbacks', async () => {
const fiber = createFiber();
const onSuccess = vi.fn();
const onError = vi.fn();

const successFn = vi.fn().mockResolvedValue('data');
renderWithFiber(fiber, () => useAsync(successFn, { onSuccess }));
await successFn();
renderWithFiber(fiber, () => useAsync(successFn, { onSuccess }));
expect(onSuccess).toHaveBeenCalledWith('data');
destroyFiber(fiber);

const fiber2 = createFiber();
const errorObj = new Error('Failed');
const failFn = vi.fn().mockRejectedValue(errorObj);
renderWithFiber(fiber2, () => useAsync(failFn, { onError }));
try { await failFn(); } catch {}
renderWithFiber(fiber2, () => useAsync(failFn, { onError }));
expect(onError).toHaveBeenCalledWith(errorObj);
destroyFiber(fiber2);
});

it('reset() returns state back to initial idle state', async () => {
const fiber = createFiber();
const asyncFn = vi.fn().mockResolvedValue('result');

let res = renderWithFiber(fiber, () => useAsync(asyncFn, { initialData: 'initial' }));
await asyncFn();

res = renderWithFiber(fiber, () => useAsync(asyncFn, { initialData: 'initial' }));
expect(res.data).toBe('result');

res.reset();

res = renderWithFiber(fiber, () => useAsync(asyncFn, { initialData: 'initial' }));
expect(res.isIdle).toBe(true);
expect(res.data).toBe('initial');
expect(res.error).toBeNull();

destroyFiber(fiber);
});

it('supports refetch() as alias to execute()', async () => {
const fiber = createFiber();
let count = 0;
const asyncFn = vi.fn().mockImplementation(async () => ++count);

let res = renderWithFiber(fiber, () => useAsync(asyncFn, { immediate: false }));
expect(res.data).toBeNull();

await res.execute();
res = renderWithFiber(fiber, () => useAsync(asyncFn, { immediate: false }));
expect(res.data).toBe(1);

await res.refetch();
res = renderWithFiber(fiber, () => useAsync(asyncFn, { immediate: false }));
expect(res.data).toBe(2);

destroyFiber(fiber);
});

it('clears stale data during loading and error states on failed refetch', async () => {
const fiber = createFiber();
let shouldFail = false;
const asyncFn = vi.fn().mockImplementation(async () => {
if (shouldFail) {
throw new Error('Refetch failed');
}
return 'initial success data';
});

let res = renderWithFiber(fiber, () => useAsync(asyncFn, { immediate: false }));
await res.execute();

res = renderWithFiber(fiber, () => useAsync(asyncFn, { immediate: false }));
expect(res.isSuccess).toBe(true);
expect(res.data).toBe('initial success data');

shouldFail = true;
const failPromise = res.execute();

// While executing, stale data should be cleared to null
res = renderWithFiber(fiber, () => useAsync(asyncFn, { immediate: false }));
expect(res.isLoading).toBe(true);
expect(res.data).toBeNull();

await failPromise;

// After failure, state is error and data is null (not stale successful data)
res = renderWithFiber(fiber, () => useAsync(asyncFn, { immediate: false }));
expect(res.isError).toBe(true);
expect(res.error?.message).toBe('Refetch failed');
expect(res.data).toBeNull();

destroyFiber(fiber);
});

it('supports typed arguments in execute function', async () => {
const fiber = createFiber();
const asyncFn = vi.fn().mockImplementation(async (id: number, name: string) => `User #${id}: ${name}`);

let res = renderWithFiber(fiber, () => useAsync(asyncFn, { immediate: false }));
await res.execute(101, 'Alice');

res = renderWithFiber(fiber, () => useAsync(asyncFn, { immediate: false }));
expect(res.isSuccess).toBe(true);
expect(res.data).toBe('User #101: Alice');

destroyFiber(fiber);
});
});
Loading
Loading