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 | 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); } } }