diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md
index bf1af24d7..92f5d65cd 100644
--- a/CONTRIBUTORS.md
+++ b/CONTRIBUTORS.md
@@ -4,7 +4,7 @@ We want to thank all the amazing contributors who have helped make TermUI what i
| Avatar | Contributor | Contributions |
| :---: | :--- | :---: |
-|
| [@Karanjot786](https://github.com/Karanjot786) | 348 |
+|
| [@Karanjot786](https://github.com/Karanjot786) | 349 |
|
| [@Tomeshwari-02](https://github.com/Tomeshwari-02) | 155 |
|
| [@ionfwsrijan](https://github.com/ionfwsrijan) | 92 |
|
| [@srushti-panara](https://github.com/srushti-panara) | 86 |
@@ -13,7 +13,7 @@ We want to thank all the amazing contributors who have helped make TermUI what i
|
| [@Aryan-Agarwal-creator](https://github.com/Aryan-Agarwal-creator) | 40 |
|
| [@ZainabTravadi](https://github.com/ZainabTravadi) | 38 |
|
| [@jainiksha](https://github.com/jainiksha) | 31 |
-|
| [@ashrion](https://github.com/ashrion) | 28 |
+|
| [@ashroxy](https://github.com/ashroxy) | 28 |
|
| [@realtushartyagi](https://github.com/realtushartyagi) | 27 |
|
| [@riddhima25bet10005-a11y](https://github.com/riddhima25bet10005-a11y) | 20 |
|
| [@Rish-2006](https://github.com/Rish-2006) | 18 |
@@ -78,7 +78,7 @@ We want to thank all the amazing contributors who have helped make TermUI what i
|
| [@KanchanWaldia](https://github.com/KanchanWaldia) | 4 |
|
| [@Abhik-Mudi](https://github.com/Abhik-Mudi) | 4 |
|
| [@akshayad2006-cmd](https://github.com/akshayad2006-cmd) | 3 |
-|
| [@siddiqui7864](https://github.com/siddiqui7864) | 3 |
+|
| [@sh4dr0x](https://github.com/sh4dr0x) | 3 |
|
| [@YASHcode-IIITV](https://github.com/YASHcode-IIITV) | 3 |
|
| [@titax03](https://github.com/titax03) | 3 |
|
| [@pixeltannu](https://github.com/pixeltannu) | 3 |
diff --git a/packages/jsx/src/hooks.ts b/packages/jsx/src/hooks.ts
index 58d60ae94..bf74634ae 100644
--- a/packages/jsx/src/hooks.ts
+++ b/packages/jsx/src/hooks.ts
@@ -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 {
- /** 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 Loading...;
- * if (error) return Error: {error.message};
- * return ;
- * }
- * ```
- */
-export function useAsync(
- asyncFn: () => Promise,
- deps: any[] = [],
-): AsyncState {
- const [data, setData] = useState(null);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(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 };
-}
diff --git a/packages/jsx/src/hooks/useAsync.test.ts b/packages/jsx/src/hooks/useAsync.test.ts
new file mode 100644
index 000000000..9dd5ae86a
--- /dev/null
+++ b/packages/jsx/src/hooks/useAsync.test.ts
@@ -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(fiber: ReturnType, 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);
+ });
+});
diff --git a/packages/jsx/src/hooks/useAsync.ts b/packages/jsx/src/hooks/useAsync.ts
new file mode 100644
index 000000000..a87969f51
--- /dev/null
+++ b/packages/jsx/src/hooks/useAsync.ts
@@ -0,0 +1,145 @@
+// ─────────────────────────────────────────────────────
+// @termuijs/jsx — useAsync hook
+// ─────────────────────────────────────────────────────
+import { useState, useEffect, useRef, useCallback } from '../hooks.js';
+
+export interface UseAsyncOptions {
+ /** Whether to execute immediately on mount. Default: true */
+ immediate?: boolean;
+ /** Initial data value */
+ initialData?: T;
+ /** Callback fired upon successful promise resolution */
+ onSuccess?: (data: T) => void;
+ /** Callback fired upon promise rejection */
+ onError?: (error: Error) => void;
+}
+
+export interface UseAsyncResult {
+ /** Resolved data (null when loading, idle, or on error) */
+ data: T | null;
+ /** Backward-compatible alias for isLoading */
+ loading: boolean;
+ /** Error object if the async function threw */
+ error: Error | null;
+ /** True while the async function is executing */
+ isLoading: boolean;
+ /** True if the async function resolved successfully */
+ isSuccess: boolean;
+ /** True if the async function rejected */
+ isError: boolean;
+ /** True before initial execution when immediate is false */
+ isIdle: boolean;
+ /** Re-execute the async function (returns resolved data or null) */
+ refetch: () => Promise;
+ /** Execute the async function with optional arguments */
+ execute: (...args: TArgs) => Promise;
+ /** Reset hook state back to initial/idle */
+ reset: () => void;
+}
+
+/** Backward-compatible type alias */
+export type AsyncState = UseAsyncResult;
+
+type AsyncStatus = 'idle' | 'loading' | 'success' | 'error';
+
+/**
+ * useAsync — load async data with automatic loading/error states, retries, and unmount safety.
+ *
+ * Supports both traditional dependency array syntax and options object syntax.
+ *
+ * ```tsx
+ * function UserList() {
+ * const { data, isLoading, error } = useAsync(fetchUsers, { immediate: true });
+ * if (isLoading) return Loading...;
+ * if (error) return Error: {error.message};
+ * return ;
+ * }
+ * ```
+ */
+export function useAsync(
+ asyncFn: (...args: TArgs) => Promise,
+ optionsOrDeps?: UseAsyncOptions | unknown[],
+): UseAsyncResult {
+ const isDeps = Array.isArray(optionsOrDeps);
+ const deps = isDeps ? optionsOrDeps : undefined;
+ const options: UseAsyncOptions = isDeps ? {} : (optionsOrDeps ?? {});
+
+ const immediate = options.immediate ?? true;
+ const initialData = options.initialData ?? null;
+
+ const [data, setData] = useState(initialData);
+ const [status, setStatus] = useState(immediate ? 'loading' : 'idle');
+ const [error, setError] = useState(null);
+
+ const versionRef = useRef(0);
+ const asyncFnRef = useRef(asyncFn);
+ asyncFnRef.current = asyncFn;
+
+ const optionsRef = useRef(options);
+ optionsRef.current = options;
+
+ const mountedRef = useRef(true);
+ useEffect(() => {
+ mountedRef.current = true;
+ return () => {
+ mountedRef.current = false;
+ };
+ }, []);
+
+ const execute = useCallback(
+ async (...args: TArgs): Promise => {
+ const version = ++versionRef.current;
+ setData(null);
+ setStatus('loading');
+ setError(null);
+
+ try {
+ const result = await asyncFnRef.current(...args);
+ if (mountedRef.current && versionRef.current === version) {
+ setData(result);
+ setStatus('success');
+ optionsRef.current.onSuccess?.(result);
+ }
+ return result;
+ } catch (err) {
+ const errorObj = err instanceof Error ? err : new Error(String(err));
+ if (mountedRef.current && versionRef.current === version) {
+ setData(null);
+ setError(errorObj);
+ setStatus('error');
+ optionsRef.current.onError?.(errorObj);
+ }
+ return null;
+ }
+ },
+ deps ?? [asyncFn],
+ );
+
+ const reset = useCallback(() => {
+ versionRef.current++;
+ setData(initialData);
+ setStatus('idle');
+ setError(null);
+ }, [initialData]);
+
+ const refetch = useCallback(() => execute(...([] as unknown as TArgs)), [execute]);
+
+ useEffect(() => {
+ if (immediate) {
+ execute(...([] as unknown as TArgs));
+ }
+ }, deps ? [immediate, ...deps] : []);
+
+ return {
+ data,
+ loading: status === 'loading',
+ error,
+ isLoading: status === 'loading',
+ isSuccess: status === 'success',
+ isError: status === 'error',
+ isIdle: status === 'idle',
+ refetch,
+ execute,
+ reset,
+ };
+}
diff --git a/packages/jsx/src/index.ts b/packages/jsx/src/index.ts
index 1cd8c81db..388fa436e 100644
--- a/packages/jsx/src/index.ts
+++ b/packages/jsx/src/index.ts
@@ -33,7 +33,7 @@ export {
export { useToggle } from './hooks/useToggle.js';
export { useAnimation } from './hooks/useAnimation.js';
export type { UseAnimationConfig } from './hooks/useAnimation.js';
-export type { AsyncState, KeyBinding, MotionPreferences } from './hooks.js';
+export type { AsyncState, UseAsyncOptions, UseAsyncResult, KeyBinding, MotionPreferences } from './hooks.js';
export { useCounter } from './hooks/useCounter.js';
export type { UseCounterActions, UseCounterOptions } from './hooks/useCounter.js';
export { useBoolean } from './hooks/useBoolean.js';
diff --git a/packages/ui/src/Rating.ts b/packages/ui/src/Rating.ts
index c176f38bc..c09effa2c 100644
--- a/packages/ui/src/Rating.ts
+++ b/packages/ui/src/Rating.ts
@@ -16,6 +16,8 @@ import {
defaultStyle,
styleToCellAttrs,
caps,
+ stringWidth,
+ truncate,
} from '@termuijs/core';
export interface RatingOptions {
@@ -241,12 +243,12 @@ export class Rating extends Widget {
const cellAttrs = fgColor ? { ...attrs, fg: fgColor } : attrs;
screen.writeString(currentX, y, charToRender, cellAttrs);
- currentX += charToRender.length;
+ currentX += stringWidth(charToRender);
}
if (this._showLabel && currentX < maxX) {
const label = ` (${this._value}/${this._max})`;
- screen.writeString(currentX, y, label.slice(0, maxX - currentX), attrs);
+ screen.writeString(currentX, y, truncate(label, maxX - currentX), attrs);
}
}
}