diff --git a/.axioma/quality.md b/.axioma/quality.md index f7da919..5e71f49 100644 --- a/.axioma/quality.md +++ b/.axioma/quality.md @@ -97,3 +97,7 @@ ## 2024-07-05 - [Ensuring Internal Event Handlers Precedence] **Learning:** When creating wrapper components that spread `restProps` onto a native element, placing internal event handlers (like `onSubmit` or `onChange`) *after* the spread ensures they are not accidentally overwritten by user-provided props. Additionally, destructuring the specific handler from props allows it to be manually invoked within the internal handler, preserving both functionalities. **Action:** Always spread `restProps` before specifying internal event handlers in wrapper components to maintain control over the component's core logic while still supporting custom callbacks. + +## 2024-07-10 - [Initial Mount Optimization and Robust Input Validation in Hooks] +**Learning:** React hooks managing timing-sensitive side effects (like `useDebounce` or `useThrottle`) should use an `isFirstRun` ref to skip redundant `setTimeout` calls on initial mount, as the state is already initialized with the starting value. Additionally, implementing defensive checks for numeric parameters (e.g., `delay <= 0` or `NaN`) ensures the hook falls back to an immediate update, providing a safe and predictable behavior across different edge cases. +**Action:** Always use an `isFirstRun` guard for initialization-safe side effects and validate numeric inputs to provide immediate fallbacks for invalid or non-positive values. diff --git a/lib/components/AsyncBlock/index.tsx b/lib/components/AsyncBlock/index.tsx index 75f4802..390e22b 100644 --- a/lib/components/AsyncBlock/index.tsx +++ b/lib/components/AsyncBlock/index.tsx @@ -153,6 +153,8 @@ export const AsyncBlock = ({ return null } +AsyncBlock.displayName = 'AsyncBlock' + /** * Props for the AsyncBlock component. */ diff --git a/lib/components/LazyRender/index.tsx b/lib/components/LazyRender/index.tsx index e63e5d7..830b79a 100644 --- a/lib/components/LazyRender/index.tsx +++ b/lib/components/LazyRender/index.tsx @@ -36,6 +36,8 @@ import { Observer } from '../Observer' ) } +LazyRender.displayName = 'LazyRender' + /** * LazyRenderProps – Props for the LazyRender component. * diff --git a/lib/components/Observer/index.tsx b/lib/components/Observer/index.tsx index 5ed8bfa..c9d8b13 100644 --- a/lib/components/Observer/index.tsx +++ b/lib/components/Observer/index.tsx @@ -80,6 +80,8 @@ export const Observer = (props: ObserverProps): ObserverReturn => { return createElement(wrapper, { ref, children }) } +Observer.displayName = 'Observer' + /** * The properties for the Observer component. * diff --git a/lib/hooks/useDebounce.test.ts b/lib/hooks/useDebounce.test.ts index 3be92c9..6ec583f 100644 --- a/lib/hooks/useDebounce.test.ts +++ b/lib/hooks/useDebounce.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { renderHook, act } from '@testing-library/react' -import { useDebounce } from '../main.ts' +import { useDebounce } from './useDebounce' describe('useDebounce', () => { beforeEach(() => { @@ -71,7 +71,15 @@ describe('useDebounce', () => { it('should clear timeout on unmount', () => { const spy = vi.spyOn(global, 'clearTimeout') - const { unmount } = renderHook(() => useDebounce('test', 200)) + const { unmount, rerender } = renderHook( + ({ val, delay }) => useDebounce(val, delay), + { + initialProps: { val: 'test', delay: 200 }, + }, + ) + + // Trigger an update to set the timeout + rerender({ val: 'updated', delay: 200 }) unmount() expect(spy).toHaveBeenCalled() @@ -104,4 +112,36 @@ describe('useDebounce', () => { }) expect(result.current).toBe('updated') }) + + it('should not call setTimeout on initial mount', () => { + const spy = vi.spyOn(global, 'setTimeout') + renderHook(() => useDebounce('initial', 500)) + + expect(spy).not.toHaveBeenCalled() + spy.mockRestore() + }) + + it('should handle negative delay by treating it as 0 (immediate update)', () => { + const { result, rerender } = renderHook( + ({ val, delay }) => useDebounce(val, delay), + { + initialProps: { val: 'initial', delay: -100 }, + }, + ) + + rerender({ val: 'updated', delay: -100 }) + expect(result.current).toBe('updated') + }) + + it('should handle NaN delay by treating it as 0 (immediate update)', () => { + const { result, rerender } = renderHook( + ({ val, delay }) => useDebounce(val, delay), + { + initialProps: { val: 'initial', delay: NaN }, + }, + ) + + rerender({ val: 'updated', delay: NaN }) + expect(result.current).toBe('updated') + }) }) diff --git a/lib/hooks/useDebounce.ts b/lib/hooks/useDebounce.ts index 989b757..94a21f3 100644 --- a/lib/hooks/useDebounce.ts +++ b/lib/hooks/useDebounce.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useState, useRef } from 'react' /** * Custom hook that debounces a value by a specified delay. @@ -32,9 +32,18 @@ import { useEffect, useState } from 'react' */ export function useDebounce(value: T, delay: number = 500): T { const [debouncedValue, setDebouncedValue] = useState(value) + const isFirstRun = useRef(true) useEffect(() => { - if (!delay) return setDebouncedValue(value) + if (isFirstRun.current) { + isFirstRun.current = false + return + } + + if (!delay || delay <= 0) { + setDebouncedValue(value) + return + } const handler = setTimeout(() => { setDebouncedValue(value)