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
448 changes: 225 additions & 223 deletions README.md

Large diffs are not rendered by default.

31 changes: 31 additions & 0 deletions __tests__/useDebouncedRefHistory.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,35 @@ describe('useDebouncedRefHistory', () => {

expect(result.current.history).toEqual([1, 3])
})

it('undo, redo, and clear sync the displayed value', () => {
const { result } = renderHook(() => useDebouncedRefHistory('a', { delay: 100 }))

act(() => {
result.current.set('b')
})
act(() => {
jest.advanceTimersByTime(100)
})
expect(result.current.value).toBe('b')
expect(result.current.canUndo).toBe(true)

act(() => {
result.current.undo()
})
expect(result.current.value).toBe('a')
expect(result.current.canRedo).toBe(true)

act(() => {
result.current.redo()
})
expect(result.current.value).toBe('b')

act(() => {
result.current.clear()
})
expect(result.current.history).toEqual(['b'])
expect(result.current.pointer).toBe(0)
expect(result.current.value).toBe('b')
})
})
38 changes: 38 additions & 0 deletions __tests__/useStorage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,42 @@ describe('useStorage', () => {
expect(window.localStorage.getItem(key)).toBeNull()
expect(result.current[0]).toBe(1)
})

it('remove clears key when value already equals initial (avoids setState bail without re-render)', () => {
const key = 'use-storage-remove-same-as-initial'
window.localStorage.setItem(key, '1')
const { result } = renderHook(() => useStorage(key, 1))
expect(result.current[0]).toBe(1)

act(() => {
result.current[2]()
})
expect(window.localStorage.getItem(key)).toBeNull()
expect(result.current[0]).toBe(1)
})

it('set to empty string clears hook value and stores JSON for ""', () => {
const key = 'use-storage-string-empty'
window.localStorage.removeItem(key)
const { result } = renderHook(() => useStorage(key, 'hello'))
expect(result.current[0]).toBe('hello')
act(() => {
result.current[1]('')
})
expect(result.current[0]).toBe('')
expect(window.localStorage.getItem(key)).toBe('""')
})

it('re-reads storage when the key changes (same hook instance)', () => {
const a = 'use-storage-key-a'
const b = 'use-storage-key-b'
window.localStorage.setItem(a, '7')
window.localStorage.setItem(b, '9')

const { result, rerender } = renderHook(({ k }: { k: string }) => useStorage(k, 0), { initialProps: { k: a } })
expect(result.current[0]).toBe(7)

rerender({ k: b })
expect(result.current[0]).toBe(9)
})
})
29 changes: 29 additions & 0 deletions __tests__/useThrottledRefHistory.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,33 @@ describe('useThrottledRefHistory', () => {
expect(result.current.history.length).toBeGreaterThanOrEqual(2)
expect(result.current.history).toContain(3)
})

it('undo, redo, and clear sync the displayed value', () => {
const { result } = renderHook(() => useThrottledRefHistory('a', { delay: 100 }))

act(() => {
result.current.set('b')
})
act(() => {
jest.advanceTimersByTime(100)
})
expect(result.current.value).toBe('b')
expect(result.current.canUndo).toBe(true)

act(() => {
result.current.undo()
})
expect(result.current.value).toBe('a')

act(() => {
result.current.redo()
})
expect(result.current.value).toBe('b')

act(() => {
result.current.clear()
})
expect(result.current.value).toBe('b')
expect(result.current.history).toEqual(['b'])
})
})
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@dedalik/use-react",
"version": "1.1.0",
"version": "1.1.1",
"description": "Collection of React Hook Utilities",
"main": "./dist/cjs/index.js",
"module": "./dist/esm/index.js",
Expand Down
70 changes: 41 additions & 29 deletions src/hooks/useDebouncedRefHistory.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'

export interface UseDebouncedRefHistoryOptions {
delay?: number
Expand All @@ -17,6 +17,8 @@ export interface UseDebouncedRefHistoryReturn<T> {
clear: () => void
}

type Bundle<T> = { value: T; history: T[]; pointer: number }

/**
* Records history snapshots after debounced state changes.
*/
Expand All @@ -25,55 +27,65 @@ export default function useDebouncedRefHistory<T>(
options: UseDebouncedRefHistoryOptions = {},
): UseDebouncedRefHistoryReturn<T> {
const { delay = 200, capacity = 10 } = options
const [value, setValue] = useState(initialValue)
const [state, setState] = useState(() => ({ history: [initialValue] as T[], pointer: 0 }))
const [b, setB] = useState<Bundle<T>>(() => ({
value: initialValue,
history: [initialValue] as T[],
pointer: 0,
}))

useEffect(() => {
const id = window.setTimeout(
() => {
setState((prev) => {
setB((prev) => {
const current = prev.history[prev.pointer]
if (Object.is(current, value)) return prev
if (Object.is(current, prev.value)) return prev
const base = prev.history.slice(0, prev.pointer + 1)
const nextHistory = [...base, value]
const nextHistory = [...base, prev.value]
const max = Math.max(1, capacity)
const trimmed = nextHistory.length > max ? nextHistory.slice(nextHistory.length - max) : nextHistory
return { history: trimmed, pointer: trimmed.length - 1 }
return { ...prev, history: trimmed, pointer: trimmed.length - 1 }
})
},
Math.max(0, delay),
)

return () => window.clearTimeout(id)
}, [capacity, delay, value])
}, [b.value, capacity, delay])

const set = useCallback((next: T) => {
setB((prev) => ({ ...prev, value: next }))
}, [])

const undo = () => {
setState((prev) => {
const pointer = Math.max(0, prev.pointer - 1)
setValue(prev.history[pointer])
return { ...prev, pointer }
const undo = useCallback(() => {
setB((prev) => {
if (prev.pointer === 0) return prev
const pointer = prev.pointer - 1
return { ...prev, pointer, value: prev.history[pointer] }
})
}
}, [])

const redo = () => {
setState((prev) => {
const pointer = Math.min(prev.history.length - 1, prev.pointer + 1)
setValue(prev.history[pointer])
return { ...prev, pointer }
const redo = useCallback(() => {
setB((prev) => {
if (prev.pointer >= prev.history.length - 1) return prev
const pointer = prev.pointer + 1
return { ...prev, pointer, value: prev.history[pointer] }
})
}
}, [])

const clear = () => {
setState((prev) => ({ history: [prev.history[prev.pointer]], pointer: 0 }))
}
const clear = useCallback(() => {
setB((prev) => {
const v = prev.history[prev.pointer]
return { ...prev, history: [v], pointer: 0, value: v }
})
}, [])

return {
value,
set: setValue,
history: state.history,
pointer: state.pointer,
canUndo: state.pointer > 0,
canRedo: state.pointer < state.history.length - 1,
value: b.value,
set,
history: b.history,
pointer: b.pointer,
canUndo: b.pointer > 0,
canRedo: b.pointer < b.history.length - 1,
undo,
redo,
clear,
Expand Down
16 changes: 14 additions & 2 deletions src/hooks/useOnMount.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,23 @@
import { useEffect } from 'react'
import { useEffect, useRef } from 'react'

type Fn = () => void

/**
* Runs a callback once after mount. Uses the `fn` from the first render; it is not re-run when the
* function’s identity changes (unlike `useEffect(..., [fn])`, which re-fires for inline functions).
* A ref avoids a second `fn()` in React 18 dev StrictMode’s double effect pass.
*/
export default function useOnMount(fn: Fn): void {
const didRun = useRef(false)
useEffect(() => {
if (didRun.current) {
return
}
didRun.current = true
if (typeof fn === 'function') {
fn()
}
}, [fn])
// Mount only - an inline `fn` must not retrigger this effect.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
}
34 changes: 28 additions & 6 deletions src/hooks/useStorage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,30 +15,51 @@ export default function useStorage<T>(
options: UseStorageOptions<T> = {},
): [T, (next: T) => void, () => void] {
const { serializer = JSON.stringify, parser = JSON.parse, storage = window.localStorage } = options
const skipPersistRef = useRef(false)
/** >0: skip the next N persist `useEffect` runs after `remove()`. Set to 2 so both React 18 StrictMode effect invocations skip; `set()` clears the counter before persisting user changes. */
const skipPersistCountRef = useRef(0)
/** Bumps on every `remove` so we re-render and run the skip-persist effect even when `setValue(initialValue)` bails. */
const [removeVersion, setRemoveVersion] = useState(0)

const [value, setValue] = useState<T>(() => {
try {
const raw = storage.getItem(key)
return raw == null ? initialValue : parser(raw)
if (raw == null || raw === '') return initialValue
return parser(raw)
} catch {
return initialValue
}
})

const didKeySyncOnMount = useRef(false)
// When `key` (or `storage` / `initialValue`) changes without a full remount, re-read; initial mount is handled by `useState` above.
useEffect(() => {
if (skipPersistRef.current) {
skipPersistRef.current = false
if (!didKeySyncOnMount.current) {
didKeySyncOnMount.current = true
return
}
try {
const raw = storage.getItem(key)
if (raw == null || raw === '') setValue(initialValue)
else setValue(parser(raw))
} catch {
setValue(initialValue)
}
}, [key, initialValue, parser, storage])

useEffect(() => {
if (skipPersistCountRef.current > 0) {
skipPersistCountRef.current -= 1
return
}
try {
storage.setItem(key, serializer(value))
} catch {
// ignore storage write failures
}
}, [key, serializer, storage, value])
}, [key, removeVersion, serializer, storage, value])

const set = useCallback((next: T) => {
skipPersistCountRef.current = 0
setValue(next)
}, [])

Expand All @@ -48,8 +69,9 @@ export default function useStorage<T>(
} catch {
// ignore storage remove failures
}
skipPersistRef.current = true
skipPersistCountRef.current = 2
setValue(initialValue)
setRemoveVersion((n) => n + 1)
}, [initialValue, key, storage])

return [value, set, remove]
Expand Down
Loading