diff --git a/packages/core/changelog.md b/packages/core/changelog.md index 623ec82a..a2b14dd8 100644 --- a/packages/core/changelog.md +++ b/packages/core/changelog.md @@ -341,3 +341,13 @@ function Component() { ## 6.1.9(Jan 2026) - fix(useRafState): fix bug where multiple consecutive functional updates would only apply the last one. Now correctly accumulates all updates within the same animation frame, matching React's useState behavior. For example, calling `setState(n => n + 1)` three times consecutively will now correctly increase the value by 3 instead of 1. + +## 6.1.11(Jan 20, 2026) + +- fix(usePageLeave): fix infinite re-render issue caused by unstable handler references +- fix(useEventListener): improve stability to prevent unnecessary event listener re-bindings while maintaining support for ref-based targets. Uses stable element identifiers: for refs, tracks the ref object itself; for functions/direct elements, tracks the resolved actual element. This allows function-based targets like `() => document` to work without causing infinite loops, while still re-binding when the actual target element changes +- fix(useSticky): fix infinite re-render issue when passing unstable function references as target or scroll element +- fix(useMutationObserver): add missing `target` dependency - now correctly re-observes when target element changes +- fix(useResizeObserver): fix infinite re-render issue when passing unstable function references as target +- fix(useIntersectionObserver): add missing `target` dependency - now correctly re-observes when target element changes +- feat(useStableTarget): add new internal utility hook for creating stable identifiers for BasicTarget parameters that can be safely used in effect dependencies. This solves the common problem where passing unstable function references like `() => document` would cause infinite re-renders diff --git a/packages/core/package.json b/packages/core/package.json index f16827c8..38917494 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@reactuses/core", - "version": "6.1.9", + "version": "6.1.11", "license": "Unlicense", "homepage": "https://www.reactuse.com/", "repository": { diff --git a/packages/core/src/useEventListener/index.ts b/packages/core/src/useEventListener/index.ts index 1005a68e..34374835 100644 --- a/packages/core/src/useEventListener/index.ts +++ b/packages/core/src/useEventListener/index.ts @@ -4,11 +4,13 @@ import { defaultOptions } from '../utils/defaults' import type { BasicTarget } from '../utils/domTarget' import { getTargetElement } from '../utils/domTarget' import { useDeepCompareEffect } from '../useDeepCompareEffect' +import { isBrowser } from '../utils/is' +import { useStableTarget } from '../utils/useStableTarget' export type Target = BasicTarget // Overload 1 Window Event based useEventListener interface -export function useEventListener( +function useEventListenerImpl( eventName: K, handler: (event: WindowEventMap[K]) => void, element?: Window, @@ -16,7 +18,7 @@ export function useEventListener( ): void // Overload 2 Document Event based useEventListener interface -export function useEventListener( +function useEventListenerImpl( eventName: K, handler: (event: DocumentEventMap[K]) => void, element: Document, @@ -24,7 +26,7 @@ export function useEventListener( ): void // Overload 3 HTMLElement Event based useEventListener interface -export function useEventListener< +function useEventListenerImpl< K extends keyof HTMLElementEventMap, T extends HTMLElement = HTMLDivElement, >( @@ -35,7 +37,7 @@ export function useEventListener< ): void // Overload 4 Element Event based useEventListener interface -export function useEventListener( +function useEventListenerImpl( eventName: K, handler: (event: ElementEventMap[K]) => void, element: Element, @@ -43,7 +45,7 @@ export function useEventListener( ): void // Overload 5 Element Event based useEventListener interface -export function useEventListener( +function useEventListenerImpl( eventName: string, handler: (event: K) => void, element: EventTarget | null | undefined, @@ -51,23 +53,26 @@ export function useEventListener( ): void // Overload 6 -export function useEventListener( +function useEventListenerImpl( eventName: string, handler: (...p: any) => void, element?: Target, options?: boolean | AddEventListenerOptions ): void -export function useEventListener( +function useEventListenerImpl( eventName: string, handler: (...p: any) => void, element?: Target, options: boolean | AddEventListenerOptions = defaultOptions, ) { const savedHandler = useLatest(handler) + const { key: elementKey, ref: elementRef } = useStableTarget(element, defaultWindow) useDeepCompareEffect(() => { - const targetElement = getTargetElement(element, defaultWindow) + // Call getTargetElement inside effect to support ref-based targets + // (ref.current is null during render, only available in commit phase) + const targetElement = getTargetElement(elementRef.current, defaultWindow) if (!(targetElement && targetElement.addEventListener)) { return } @@ -83,5 +88,9 @@ export function useEventListener( } off(targetElement, eventName, eventListener) } - }, [eventName, element, options]) + }, [eventName, elementKey, options]) } + +function noop() {} + +export const useEventListener = isBrowser ? useEventListenerImpl : noop as typeof useEventListenerImpl diff --git a/packages/core/src/useIntersectionObserver/index.ts b/packages/core/src/useIntersectionObserver/index.ts index b974296f..974d3484 100644 --- a/packages/core/src/useIntersectionObserver/index.ts +++ b/packages/core/src/useIntersectionObserver/index.ts @@ -3,6 +3,7 @@ import { useLatest } from '../useLatest' import { defaultOptions } from '../utils/defaults' import { useDeepCompareEffect } from '../useDeepCompareEffect' import { getTargetElement } from '../utils/domTarget' +import { useStableTarget } from '../utils/useStableTarget' import type { UseIntersectionObserver } from './interface' export const useIntersectionObserver: UseIntersectionObserver = ( @@ -12,6 +13,7 @@ export const useIntersectionObserver: UseIntersectionObserver = ( ): () => void => { const savedCallback = useLatest(callback) const observerRef = useRef() + const { key: targetKey, ref: targetRef } = useStableTarget(target) const stop = useCallback(() => { if (observerRef.current) { @@ -20,7 +22,7 @@ export const useIntersectionObserver: UseIntersectionObserver = ( }, []) useDeepCompareEffect(() => { - const element = getTargetElement(target) + const element = getTargetElement(targetRef.current) if (!element) { return } @@ -32,7 +34,7 @@ export const useIntersectionObserver: UseIntersectionObserver = ( observerRef.current.observe(element) return stop - }, [options]) + }, [targetKey, options]) return stop } diff --git a/packages/core/src/useMutationObserver/index.ts b/packages/core/src/useMutationObserver/index.ts index 24b0b354..0bdaef16 100644 --- a/packages/core/src/useMutationObserver/index.ts +++ b/packages/core/src/useMutationObserver/index.ts @@ -3,6 +3,7 @@ import { useLatest } from '../useLatest' import { defaultOptions } from '../utils/defaults' import { useDeepCompareEffect } from '../useDeepCompareEffect' import { type BasicTarget, getTargetElement } from '../utils/domTarget' +import { useStableTarget } from '../utils/useStableTarget' import type { UseMutationObserver } from './interface' export const useMutationObserver: UseMutationObserver = ( @@ -12,6 +13,7 @@ export const useMutationObserver: UseMutationObserver = ( ): (() => void) => { const callbackRef = useLatest(callback) const observerRef = useRef() + const { key: targetKey, ref: targetRef } = useStableTarget(target) const stop = useCallback(() => { if (observerRef.current) { @@ -20,7 +22,7 @@ export const useMutationObserver: UseMutationObserver = ( }, []) useDeepCompareEffect(() => { - const element = getTargetElement(target) + const element = getTargetElement(targetRef.current) if (!element) { return } @@ -28,7 +30,7 @@ export const useMutationObserver: UseMutationObserver = ( observerRef.current.observe(element, options) return stop - }, [options]) + }, [targetKey, options]) return stop } diff --git a/packages/core/src/usePageLeave/index.ts b/packages/core/src/usePageLeave/index.ts index f895dbb1..16e03ad4 100644 --- a/packages/core/src/usePageLeave/index.ts +++ b/packages/core/src/usePageLeave/index.ts @@ -1,5 +1,6 @@ import { useState } from 'react' import { useEventListener } from '../useEventListener' +import { defaultDocument, defaultWindow } from '../utils/browser' export function usePageLeave(): boolean { const [isLeft, setIsLeft] = useState(false) @@ -14,9 +15,9 @@ export function usePageLeave(): boolean { setIsLeft(!from) } - useEventListener('mouseout', handler, () => window, { passive: true }) - useEventListener('mouseleave', handler, () => document, { passive: true }) - useEventListener('mouseenter', handler, () => document, { passive: true }) + useEventListener('mouseout', handler, defaultWindow, { passive: true }) + useEventListener('mouseleave', handler, defaultDocument, { passive: true }) + useEventListener('mouseenter', handler, defaultDocument, { passive: true }) return isLeft } diff --git a/packages/core/src/useResizeObserver/index.ts b/packages/core/src/useResizeObserver/index.ts index b7c272f1..fe9cf930 100644 --- a/packages/core/src/useResizeObserver/index.ts +++ b/packages/core/src/useResizeObserver/index.ts @@ -3,6 +3,7 @@ import { useLatest } from '../useLatest' import { defaultOptions } from '../utils/defaults' import { useDeepCompareEffect } from '../useDeepCompareEffect' import { getTargetElement } from '../utils/domTarget' +import { useStableTarget } from '../utils/useStableTarget' import type { UseResizeObserver } from './interface' export const useResizeObserver: UseResizeObserver = ( @@ -12,14 +13,16 @@ export const useResizeObserver: UseResizeObserver = ( ): () => void => { const savedCallback = useLatest(callback) const observerRef = useRef() + const { key: targetKey, ref: targetRef } = useStableTarget(target) const stop = useCallback(() => { if (observerRef.current) { observerRef.current.disconnect() } }, []) + useDeepCompareEffect(() => { - const element = getTargetElement(target) + const element = getTargetElement(targetRef.current) if (!element) { return } @@ -27,7 +30,7 @@ export const useResizeObserver: UseResizeObserver = ( observerRef.current.observe(element, options) return stop - }, [savedCallback, stop, target, options]) + }, [targetKey, options]) return stop } diff --git a/packages/core/src/useSticky/index.ts b/packages/core/src/useSticky/index.ts index 0f6191c8..76d4f04a 100644 --- a/packages/core/src/useSticky/index.ts +++ b/packages/core/src/useSticky/index.ts @@ -4,29 +4,35 @@ import type { BasicTarget } from '../utils/domTarget' import { getTargetElement } from '../utils/domTarget' import { useThrottleFn } from '../useThrottleFn' import { getScrollParent } from '../utils/scroll' +import { useStableTarget } from '../utils/useStableTarget' +import { useLatest } from '../useLatest' import type { UseStickyParams } from './interface' export function useSticky(targetElement: BasicTarget, { axis = 'y', nav = 0 }: UseStickyParams, scrollElement?: BasicTarget): [boolean, React.Dispatch>] { const [isSticky, setSticky] = useState(false) + const { key: targetKey, ref: targetRef } = useStableTarget(targetElement) + const { key: scrollKey, ref: scrollRef } = useStableTarget(scrollElement) + const axisRef = useLatest(axis) + const navRef = useLatest(nav) const { run: scrollHandler } = useThrottleFn(() => { - const element = getTargetElement(targetElement) + const element = getTargetElement(targetRef.current) if (!element) { return } const rect = element.getBoundingClientRect() - if (axis === 'y') { - setSticky(rect?.top <= nav) + if (axisRef.current === 'y') { + setSticky(rect?.top <= navRef.current) } else { - setSticky(rect?.left <= nav) + setSticky(rect?.left <= navRef.current) } }, 50) useEffect(() => { - const element = getTargetElement(targetElement) + const element = getTargetElement(targetRef.current) const scrollParent - = getTargetElement(scrollElement) || getScrollParent(axis, element) + = getTargetElement(scrollRef.current) || getScrollParent(axisRef.current, element) if (!element || !scrollParent) { return } @@ -36,6 +42,6 @@ export function useSticky(targetElement: BasicTarget, { axis = 'y', return () => { scrollParent.removeEventListener('scroll', scrollHandler) } - }, [axis, targetElement, scrollElement, scrollHandler]) + }, [targetKey, scrollKey, scrollHandler]) return [isSticky, setSticky] } diff --git a/packages/core/src/utils/useStableTarget.ts b/packages/core/src/utils/useStableTarget.ts new file mode 100644 index 00000000..2b10923b --- /dev/null +++ b/packages/core/src/utils/useStableTarget.ts @@ -0,0 +1,57 @@ +import { useRef } from 'react' +import type { BasicTarget } from './domTarget' +import { getTargetElement } from './domTarget' + +/** + * Creates a stable identifier for a BasicTarget that can be safely used in effect dependencies. + * + * This hook solves the problem where passing unstable function references like `() => document` + * would cause infinite re-renders when used directly in effect dependency arrays. + * + * @param target - The target element (ref, function, or direct element) + * @param defaultElement - Default element to use if target is undefined + * @returns A stable reference that only changes when the actual target element changes + * + * @example + * ```tsx + * // For ref objects: returns the ref itself (stable) + * const ref = useRef(null) + * const key = useStableTarget(ref) // key === ref (stable) + * + * // For functions: returns the resolved actual element + * const key = useStableTarget(() => document) // key === document (stable) + * + * // For direct elements: returns the element itself + * const key = useStableTarget(divElement) // key === divElement (stable) + * ``` + */ +export function useStableTarget( + target?: BasicTarget, + defaultElement?: T, +) { + const targetRef = useRef(target) + targetRef.current = target + + // Calculate stable key without memoization + // For ref objects: return the ref itself (always stable) + // For functions/direct elements: resolve to the actual element + let stableKey: any + if (!target) { + stableKey = defaultElement ?? null + } + else if (typeof target === 'object' && 'current' in target) { + // Ref object - use the ref itself as the stable key + stableKey = target + } + else { + // Function or direct element - resolve to actual element + stableKey = getTargetElement(target, defaultElement) + } + + return { + /** The stable key that can be safely used in effect dependencies */ + key: stableKey, + /** A ref containing the current target (useful for accessing in effects) */ + ref: targetRef, + } +} diff --git a/packages/website-docusaurus/docs/changelog.md b/packages/website-docusaurus/docs/changelog.md index 1d80f30f..b9aabc94 100644 --- a/packages/website-docusaurus/docs/changelog.md +++ b/packages/website-docusaurus/docs/changelog.md @@ -7,6 +7,16 @@ description: >- --- # ChangeLog +## 6.1.11(Jan 20, 2026) + +- fix(usePageLeave): fix infinite re-render issue caused by unstable handler references +- fix(useEventListener): improve stability to prevent unnecessary event listener re-bindings while maintaining support for ref-based targets. Uses stable element identifiers: for refs, tracks the ref object itself; for functions/direct elements, tracks the resolved actual element. This allows function-based targets like `() => document` to work without causing infinite loops, while still re-binding when the actual target element changes +- fix(useSticky): fix infinite re-render issue when passing unstable function references as target or scroll element +- fix(useMutationObserver): add missing `target` dependency - now correctly re-observes when target element changes +- fix(useResizeObserver): fix infinite re-render issue when passing unstable function references as target +- fix(useIntersectionObserver): add missing `target` dependency - now correctly re-observes when target element changes +- feat(useStableTarget): add new internal utility hook for creating stable identifiers for BasicTarget parameters that can be safely used in effect dependencies. This solves the common problem where passing unstable function references like `() => document` would cause infinite re-renders + ## 6.1.9(Jan 2026) - fix(useRafState): fix bug where multiple consecutive functional updates would only apply the last one. Now correctly accumulates all updates within the same animation frame, matching React's useState behavior. For example, calling `setState(n => n + 1)` three times consecutively will now correctly increase the value by 3 instead of 1. diff --git a/packages/website-docusaurus/static/llm.txt b/packages/website-docusaurus/static/llm.txt index f669c850..11d8b0fb 100644 --- a/packages/website-docusaurus/static/llm.txt +++ b/packages/website-docusaurus/static/llm.txt @@ -1006,8 +1006,8 @@ UnLicense - Use freely without restrictions ## Companies Using ReactUse -PDD (Pinduoduo) - E-Commerce | Ctrip - Travel Platform +PDD (Pinduoduo) - E-Commerce | Shopee - E-Commerce | Ctrip - Travel Platform | Bambu Lab - 3D Printing --- -Generated: 2026-01-13T15:19:40.594Z | Total Hooks: 112 +Generated: 2026-01-20T09:42:26.595Z | Total Hooks: 112