From 496c5170ac804922cba109f19b650e01334f611d Mon Sep 17 00:00:00 2001 From: karthikj5453 <208207751+karthikj5453@users.noreply.github.com> Date: Sun, 9 Aug 2026 02:18:50 +0000 Subject: [PATCH 1/4] docs: auto-generate CONTRIBUTORS.md [skip ci] --- CONTRIBUTORS.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 | [@Karanjot786](https://github.com/Karanjot786) | 348 | +| Karanjot786 | [@Karanjot786](https://github.com/Karanjot786) | 349 | | Tomeshwari-02 | [@Tomeshwari-02](https://github.com/Tomeshwari-02) | 155 | | ionfwsrijan | [@ionfwsrijan](https://github.com/ionfwsrijan) | 92 | | srushti-panara | [@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 | [@Aryan-Agarwal-creator](https://github.com/Aryan-Agarwal-creator) | 40 | | ZainabTravadi | [@ZainabTravadi](https://github.com/ZainabTravadi) | 38 | | jainiksha | [@jainiksha](https://github.com/jainiksha) | 31 | -| ashrion | [@ashrion](https://github.com/ashrion) | 28 | +| ashroxy | [@ashroxy](https://github.com/ashroxy) | 28 | | realtushartyagi | [@realtushartyagi](https://github.com/realtushartyagi) | 27 | | riddhima25bet10005-a11y | [@riddhima25bet10005-a11y](https://github.com/riddhima25bet10005-a11y) | 20 | | Rish-2006 | [@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 | [@KanchanWaldia](https://github.com/KanchanWaldia) | 4 | | Abhik-Mudi | [@Abhik-Mudi](https://github.com/Abhik-Mudi) | 4 | | akshayad2006-cmd | [@akshayad2006-cmd](https://github.com/akshayad2006-cmd) | 3 | -| siddiqui7864 | [@siddiqui7864](https://github.com/siddiqui7864) | 3 | +| sh4dr0x | [@sh4dr0x](https://github.com/sh4dr0x) | 3 | | YASHcode-IIITV | [@YASHcode-IIITV](https://github.com/YASHcode-IIITV) | 3 | | titax03 | [@titax03](https://github.com/titax03) | 3 | | pixeltannu | [@pixeltannu](https://github.com/pixeltannu) | 3 | From e95c9e28a0eff5983c3ffe7c4487d2e6b39e0186 Mon Sep 17 00:00:00 2001 From: karthik Date: Wed, 12 Aug 2026 23:45:28 +0530 Subject: [PATCH 2/4] fix(ui): use stringWidth and truncate for Rating rendering --- packages/ui/src/Rating.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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); } } } From d73ac9776737125a92ef3b441f3523c31a26255f Mon Sep 17 00:00:00 2001 From: karthik Date: Thu, 13 Aug 2026 00:58:46 +0530 Subject: [PATCH 3/4] feat(jsx): add useAsync hook for managing asynchronous state and retries --- packages/jsx/src/hooks.ts | 69 +---------- packages/jsx/src/hooks/useAsync.test.ts | 156 ++++++++++++++++++++++++ packages/jsx/src/hooks/useAsync.ts | 143 ++++++++++++++++++++++ packages/jsx/src/index.ts | 2 +- 4 files changed, 302 insertions(+), 68 deletions(-) create mode 100644 packages/jsx/src/hooks/useAsync.test.ts create mode 100644 packages/jsx/src/hooks/useAsync.ts 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..27a23c3ed --- /dev/null +++ b/packages/jsx/src/hooks/useAsync.test.ts @@ -0,0 +1,156 @@ +// ───────────────────────────────────────────────────── +// @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); + }); +}); diff --git a/packages/jsx/src/hooks/useAsync.ts b/packages/jsx/src/hooks/useAsync.ts new file mode 100644 index 000000000..c303c1d29 --- /dev/null +++ b/packages/jsx/src/hooks/useAsync.ts @@ -0,0 +1,143 @@ +// ───────────────────────────────────────────────────── +// @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: any[]) => 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: any[]) => Promise, + optionsOrDeps?: UseAsyncOptions | any[], +): 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: any[]): Promise => { + const version = ++versionRef.current; + 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) { + 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(), [execute]); + + useEffect(() => { + if (immediate) { + execute(); + } + }, 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'; From 1b85608ef76294ccc81d231b48662a81fc50a538 Mon Sep 17 00:00:00 2001 From: karthik Date: Thu, 13 Aug 2026 02:02:35 +0530 Subject: [PATCH 4/4] fix(jsx): update useAsync type signature and clear stale data on error --- packages/jsx/src/hooks/useAsync.test.ts | 50 +++++++++++++++++++++++++ packages/jsx/src/hooks/useAsync.ts | 22 ++++++----- 2 files changed, 62 insertions(+), 10 deletions(-) diff --git a/packages/jsx/src/hooks/useAsync.test.ts b/packages/jsx/src/hooks/useAsync.test.ts index 27a23c3ed..9dd5ae86a 100644 --- a/packages/jsx/src/hooks/useAsync.test.ts +++ b/packages/jsx/src/hooks/useAsync.test.ts @@ -153,4 +153,54 @@ describe('useAsync', () => { 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 index c303c1d29..a87969f51 100644 --- a/packages/jsx/src/hooks/useAsync.ts +++ b/packages/jsx/src/hooks/useAsync.ts @@ -14,7 +14,7 @@ export interface UseAsyncOptions { onError?: (error: Error) => void; } -export interface UseAsyncResult { +export interface UseAsyncResult { /** Resolved data (null when loading, idle, or on error) */ data: T | null; /** Backward-compatible alias for isLoading */ @@ -32,13 +32,13 @@ export interface UseAsyncResult { /** Re-execute the async function (returns resolved data or null) */ refetch: () => Promise; /** Execute the async function with optional arguments */ - execute: (...args: any[]) => Promise; + execute: (...args: TArgs) => Promise; /** Reset hook state back to initial/idle */ reset: () => void; } /** Backward-compatible type alias */ -export type AsyncState = UseAsyncResult; +export type AsyncState = UseAsyncResult; type AsyncStatus = 'idle' | 'loading' | 'success' | 'error'; @@ -56,10 +56,10 @@ type AsyncStatus = 'idle' | 'loading' | 'success' | 'error'; * } * ``` */ -export function useAsync( - asyncFn: (...args: any[]) => Promise, - optionsOrDeps?: UseAsyncOptions | any[], -): UseAsyncResult { +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 ?? {}); @@ -87,8 +87,9 @@ export function useAsync( }, []); const execute = useCallback( - async (...args: any[]): Promise => { + async (...args: TArgs): Promise => { const version = ++versionRef.current; + setData(null); setStatus('loading'); setError(null); @@ -103,6 +104,7 @@ export function useAsync( } 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); @@ -120,11 +122,11 @@ export function useAsync( setError(null); }, [initialData]); - const refetch = useCallback(() => execute(), [execute]); + const refetch = useCallback(() => execute(...([] as unknown as TArgs)), [execute]); useEffect(() => { if (immediate) { - execute(); + execute(...([] as unknown as TArgs)); } }, deps ? [immediate, ...deps] : []);