Skip to content
Merged
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
4 changes: 4 additions & 0 deletions .axioma/quality.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 2 additions & 0 deletions lib/components/AsyncBlock/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,8 @@ export const AsyncBlock = <T,>({
return null
}

AsyncBlock.displayName = 'AsyncBlock'

/**
* Props for the AsyncBlock component.
*/
Expand Down
2 changes: 2 additions & 0 deletions lib/components/LazyRender/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ import { Observer } from '../Observer'
)
}

LazyRender.displayName = 'LazyRender'

/**
* LazyRenderProps – Props for the LazyRender component.
*
Expand Down
2 changes: 2 additions & 0 deletions lib/components/Observer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ export const Observer = (props: ObserverProps): ObserverReturn => {
return createElement(wrapper, { ref, children })
}

Observer.displayName = 'Observer'

/**
* The properties for the Observer component.
*
Expand Down
44 changes: 42 additions & 2 deletions lib/hooks/useDebounce.test.ts
Original file line number Diff line number Diff line change
@@ -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(() => {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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')
})
})
13 changes: 11 additions & 2 deletions lib/hooks/useDebounce.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -32,9 +32,18 @@ import { useEffect, useState } from 'react'
*/
export function useDebounce<T>(value: T, delay: number = 500): T {
const [debouncedValue, setDebouncedValue] = useState<T>(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)
Expand Down
Loading