From f310989e5b6e19a8434ee685d11a06063f360d30 Mon Sep 17 00:00:00 2001 From: lianwenwu Date: Tue, 20 Jan 2026 17:13:36 +0800 Subject: [PATCH 1/2] chore: release v6.1.10 --- packages/core/changelog.md | 5 ++++ packages/core/package.json | 2 +- packages/core/src/useEventListener/index.ts | 23 +++++++++++-------- packages/core/src/usePageLeave/index.ts | 7 +++--- packages/website-docusaurus/docs/changelog.md | 5 ++++ packages/website-docusaurus/static/llm.txt | 4 ++-- 6 files changed, 31 insertions(+), 15 deletions(-) diff --git a/packages/core/changelog.md b/packages/core/changelog.md index 623ec82a..1483923e 100644 --- a/packages/core/changelog.md +++ b/packages/core/changelog.md @@ -341,3 +341,8 @@ 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.10(Jan 20, 2026) + +- fix(usePageLeave): fix infinite re-render issue caused by unstable event listener references +- fix(useEventListener): improve parameter stability by moving `getTargetElement` call outside of effect to prevent unnecessary re-bindings diff --git a/packages/core/package.json b/packages/core/package.json index f16827c8..d1b01b37 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.10", "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..54c8abe3 100644 --- a/packages/core/src/useEventListener/index.ts +++ b/packages/core/src/useEventListener/index.ts @@ -4,11 +4,12 @@ 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' 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 +17,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 +25,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 +36,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 +44,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 +52,23 @@ 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 targetElement = getTargetElement(element, defaultWindow) useDeepCompareEffect(() => { - const targetElement = getTargetElement(element, defaultWindow) if (!(targetElement && targetElement.addEventListener)) { return } @@ -83,5 +84,9 @@ export function useEventListener( } off(targetElement, eventName, eventListener) } - }, [eventName, element, options]) + }, [eventName, targetElement, options]) } + +function noop() {} + +export const useEventListener = isBrowser ? useEventListenerImpl : noop as typeof useEventListenerImpl 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/website-docusaurus/docs/changelog.md b/packages/website-docusaurus/docs/changelog.md index 1d80f30f..13043f11 100644 --- a/packages/website-docusaurus/docs/changelog.md +++ b/packages/website-docusaurus/docs/changelog.md @@ -7,6 +7,11 @@ description: >- --- # ChangeLog +## 6.1.10(Jan 20, 2026) + +- fix(usePageLeave): fix infinite re-render issue caused by unstable event listener references +- fix(useEventListener): improve parameter stability by moving `getTargetElement` call outside of effect to prevent unnecessary re-bindings + ## 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..fe9cfafc 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-20T08:54:49.029Z | Total Hooks: 112 From 4b4e5311f2131e422438d4bfea0f1b345b366ece Mon Sep 17 00:00:00 2001 From: lianwenwu Date: Tue, 20 Jan 2026 17:49:50 +0800 Subject: [PATCH 2/2] chore: release v6.1.11 - fix(usePageLeave): resolve infinite re-render issue due to unstable handler references - fix(useEventListener): enhance stability to prevent unnecessary re-bindings while supporting ref-based targets - fix(useSticky): address infinite re-render issue with unstable function references as target or scroll element - fix(useMutationObserver): add missing target dependency for correct re-observation - fix(useResizeObserver): fix infinite re-render issue with unstable function references as target - fix(useIntersectionObserver): add missing target dependency for correct re-observation - feat(useStableTarget): introduce new utility hook for creating stable identifiers for BasicTarget parameters to prevent infinite re-renders --- packages/core/changelog.md | 13 +++-- packages/core/package.json | 2 +- packages/core/src/useEventListener/index.ts | 8 ++- .../core/src/useIntersectionObserver/index.ts | 6 +- .../core/src/useMutationObserver/index.ts | 6 +- packages/core/src/useResizeObserver/index.ts | 7 ++- packages/core/src/useSticky/index.ts | 20 ++++--- packages/core/src/utils/useStableTarget.ts | 57 +++++++++++++++++++ packages/website-docusaurus/docs/changelog.md | 13 +++-- packages/website-docusaurus/static/llm.txt | 2 +- 10 files changed, 109 insertions(+), 25 deletions(-) create mode 100644 packages/core/src/utils/useStableTarget.ts diff --git a/packages/core/changelog.md b/packages/core/changelog.md index 1483923e..a2b14dd8 100644 --- a/packages/core/changelog.md +++ b/packages/core/changelog.md @@ -342,7 +342,12 @@ function Component() { - 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.10(Jan 20, 2026) - -- fix(usePageLeave): fix infinite re-render issue caused by unstable event listener references -- fix(useEventListener): improve parameter stability by moving `getTargetElement` call outside of effect to prevent unnecessary re-bindings +## 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 d1b01b37..38917494 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@reactuses/core", - "version": "6.1.10", + "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 54c8abe3..34374835 100644 --- a/packages/core/src/useEventListener/index.ts +++ b/packages/core/src/useEventListener/index.ts @@ -5,6 +5,7 @@ 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 @@ -66,9 +67,12 @@ function useEventListenerImpl( options: boolean | AddEventListenerOptions = defaultOptions, ) { const savedHandler = useLatest(handler) - const targetElement = getTargetElement(element, defaultWindow) + const { key: elementKey, ref: elementRef } = useStableTarget(element, defaultWindow) useDeepCompareEffect(() => { + // 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 } @@ -84,7 +88,7 @@ function useEventListenerImpl( } off(targetElement, eventName, eventListener) } - }, [eventName, targetElement, options]) + }, [eventName, elementKey, options]) } function noop() {} 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/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 13043f11..b9aabc94 100644 --- a/packages/website-docusaurus/docs/changelog.md +++ b/packages/website-docusaurus/docs/changelog.md @@ -7,10 +7,15 @@ description: >- --- # ChangeLog -## 6.1.10(Jan 20, 2026) - -- fix(usePageLeave): fix infinite re-render issue caused by unstable event listener references -- fix(useEventListener): improve parameter stability by moving `getTargetElement` call outside of effect to prevent unnecessary re-bindings +## 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) diff --git a/packages/website-docusaurus/static/llm.txt b/packages/website-docusaurus/static/llm.txt index fe9cfafc..11d8b0fb 100644 --- a/packages/website-docusaurus/static/llm.txt +++ b/packages/website-docusaurus/static/llm.txt @@ -1010,4 +1010,4 @@ PDD (Pinduoduo) - E-Commerce | Shopee - E-Commerce | Ctrip - Travel Platform | B --- -Generated: 2026-01-20T08:54:49.029Z | Total Hooks: 112 +Generated: 2026-01-20T09:42:26.595Z | Total Hooks: 112