Releases: childrentime/reactuse
Release list
v6.5.5
Follow-up to v6.5.4. The useScrollLock bug there had a root cause the fix didn't address: an effect that depends on a target which is a new function on every render. This release audits every hook that accepts a BasicTarget and fixes the three that had it — in two of them the consequence is worse than it was in useScrollLock.
🐞 Bug Fixes
useInfiniteScroll: no longer fires onLoadMore on every render
The load-more effect listed target in its dependencies. A getter target — () => element, one of the documented BasicTarget forms — is a new function every render, so the effect re-ran every render and called onLoadMore each time:
// three renders → three loads; loading appends data → renders → loads again…
useInfiniteScroll(() => containerEl, loadMore);Measured: 3 renders, 3 loads with a getter target, 0 with a ref. Because loading more appends to state and renders again, it never settled. The documented demo passes a ref, which is why the loop went unnoticed.
useCssVar: no longer rebuilds its observer and its setter every render
set and updateCssVar were memoised on [target, prop], and the effect that installs the MutationObserver depended on both plus target. A getter target invalidated all of them every render, so with observe: true:
- the observer was disconnected and reconstructed on every render — 4 observers across 3 renders, against 1 for a stable element — leaving a gap where mutations go unseen
- the
setreturned to callers changed identity every render, invalidating any consumer memo built on it
set is now stable for the lifetime of the hook, and the observer is built once per element.
useScrollLock: the effect is keyed on the element, not the getter
v6.5.4 made the effect idempotent, but it still re-ran on every render with the recommended () => document.body getter, re-writing overflow: hidden each time and leaving a trap for anything added to that effect later. The element-keyed snapshot from 6.5.4 stays as the invariant that guarantees one read per lock.
🔍 Audit
All three now resolve their target through useStableTarget, the utility useEventListener, useIntersectionObserver, useMutationObserver, useResizeObserver and useSticky already used. Those five were already correct; these three were the only hooks depending on a raw target. Every other hook that takes a target reaches the DOM through useEventListener, useResizeObserver or useEvent, all of which are already stable.
✅ Tests
378 tests, up from 358. useInfiniteScroll gains its first spec file (9 tests — no load on mount, loading on arrival at the bottom edge with both ref and getter targets, no load on unrelated re-renders with either, and preserveScrollPosition including its ordering against an async onLoadMore); useCssVar grows from a stub to 11; useScrollLock to 13. Five of the new tests fail on 6.5.4.
Full changelog: v6.5.4...v6.5.5
v6.5.4
🐞 Bug Fixes
useEventListener: passes the listener options back to removeEventListener
Registration called addEventListener(name, fn, options), but cleanup called removeEventListener(name, fn) with the options dropped. The DOM matches a listener for removal on (type, callback, capture), so anything registered with { capture: true } — or the boolean true form — was never detached:
function Modal() {
// intercept clicks before they reach the page
useEventListener("click", onCapture, () => document, { capture: true });
return <div role="dialog">…</div>;
}Close the modal and the handler keeps firing. The listener also survived a change of eventName or options, and accumulated one live listener per mount — five mount/unmount cycles left five orphaned handlers, all firing on a single click.
passive, once and { capture: false } were unaffected, since only capture participates in the removal match. Verified in jsdom and Chrome 151.
useScrollLock: snapshots the original overflow once per lock
The effect that applies the lock lists target in its dependencies, and the () => document.body getter the docs recommend for SSR safety is a fresh function on every render — so the effect re-ran on every render and re-executed:
initialOverflowRef.current = element.style.overflow;While locked, that reads back the hidden the hook itself just wrote. One unrelated re-render with the modal open was enough to poison the saved value, and then:
unlock()restoredoverflow: hidden— the page never scrolled again- an existing inline value such as
overflow: overlaywas swallowed - the v6.5.3 unmount cleanup restored the poisoned value too
Only the path with no render between lock and unlock behaved correctly, which is why hand testing never caught it.
The snapshot is now keyed on the locked element rather than on the effect firing, so it happens once per lock however often the effect re-runs — initialState: true included. If the target moves while locked, the element being released is restored before the new one is captured.
Thanks to @hayrullahkar for the report and the diagnosis.
✅ Tests
Twelve regression tests, all of which fail on 6.5.3: capture-listener detachment on unmount, the boolean capture form, the arguments handed to removeEventListener, dependency changes, accumulation across five mount cycles, and the non-capture control path; plus re-render while locked, a non-default inline value, unmount after a re-render, initialState with a re-render, repeated lock cycles, and a target swap mid-lock.
Full changelog: v6.5.3...v6.5.4
v6.5.3
🐞 Bug Fixes
useScrollLock: releases the lock when the owning component unmounts
The lock is an inline overflow: hidden written onto an element the hook doesn't own — usually document.body — plus, on iOS, a touchmove guard registered with passive: false. Neither was ever cleaned up: the effect that applies the style had no teardown, and the listener was only detached from unlock().
So unmounting while locked leaked both. The usual way in is a route change with the modal still open:
function Modal({ open }: { open: boolean }) {
const [, setLocked] = useScrollLock(() => document.body);
useEffect(() => { setLocked(open); }, [open, setLocked]);
// navigate away while open → the component goes, the lock stays
}The page is left permanently unscrollable, with nothing mounted that could release it. On iOS it is worse than a stray style: the leftover passive: false listener stays attached to the element and cancels every touch scroll for the rest of the session.
useUnmount now restores the exact inline overflow captured before locking, and detaches the guard:
| unmounting while… | before | after |
|---|---|---|
| locked — the style | overflow: hidden stays |
original inline value restored |
locked — the iOS touchmove guard |
listener stays on the element | listener removed |
| unlocked | untouched | untouched |
This is the behaviour the docs already promised — "the lock is automatically released when the component unmounts" — the implementation just never did it.
Pairing every lock with an unlock is still the right pattern, because unmounting is only half of it. The modal closing while the component stays mounted is yours either way:
useEffect(() => {
setLocked(open);
return () => setLocked(false);
}, [open, setLocked]);useScrollLock also picks up its first test suite (6 cases): the lock/unlock round-trip, release on unmount, no-op on an unlocked unmount, iOS listener teardown, initialState, and getter / null targets.
📝 Deep dive, including why overflow: hidden alone never stopped iOS rubber-banding: React useScrollLock Hook: Lock Body Scroll for Modals
Full Changelog: v6.5.2...v6.5.3
v6.5.2
🐞 Bug Fixes
useClipboard: works in non-secure contexts — #218, #219
navigator.clipboard is undefined whenever window.isSecureContext is false — plain http://, LAN IPs, some embedded webviews — so copy() rejected outright and the hook was simply unusable there.
Copying now falls back to a temporary <textarea> plus document.execCommand('copy'), and copy / cut events fall back to the current document selection when a Clipboard API read is unavailable or denied.
const [text, copy, isSupported] = useClipboard();
// isSupported === false on http:// — copy() still works via the legacy fallbackThe returned tuple gains a third element, isSupported, reporting whether navigator.clipboard exists. Destructuring stays backwards compatible; treat isSupported as a capability hint rather than a gate, since the copy fallback works either way.
One subtlety worth recording: the selection is read synchronously inside the event handler. document.execCommand('copy') dispatches its own bubbling copy event, and by the time an awaited continuation runs, the browser has already torn down the fallback textarea — or, on cut in Firefox, cleared the selection outright. Reading a microtask later returns an empty string, which silently blanked text.
Verified in Chromium, Firefox and WebKit with the real hook, real React and the Clipboard API removed:
| scenario (no Clipboard API) | before | after |
|---|---|---|
copy() → execCommand fallback |
✗ ✗ ✗ | ✓ ✓ ✓ |
user presses Ctrl+X |
✗ firefox | ✓ ✓ ✓ |
user presses Ctrl+C |
✓ ✓ ✓ | ✓ ✓ ✓ |
useCopyToClipboard is an alias for the same implementation, so it gains all of this too. useClipboard also picks up a test suite (8 cases) covering the fallback, both event paths, and a denied Clipboard API read.
Thanks @tanukihee for reporting the issue and seeing the fix all the way through.
🧹 Housekeeping
No effect on the published package — repo cleanup only:
- The deprecated
packages/website-docusaurussite is gone. reactuse.com has been served frompackages/website-astrofor a while, and the stale copy was actively misleading contributors into updating docs that never ship. - The generated API markdown moved with it, from
website-docusaurus/api/topackages/website-astro/api/— next to the site that actually renders it at the%%API%%marker. It is still produced bypnpm --filter @reactuses/core gendand still read bypackages/mcp.
Full Changelog: v6.5.1...v6.5.2
v6.5.1
🐞 Bug Fixes
useInterval: immediate no longer fires while paused — #128
immediate: true invoked the callback inside the effect unconditionally, so a delay flipping to null — the documented "stop the timer" value — still ran it once at the moment of pausing. The classic victim is a poll gated on visibility/network:
useInterval(refresh, visible && online ? 10_000 : null, { immediate: true });
// before: going offline triggered one `refresh()` — right when it can't succeedThe immediate call is now guarded on delay !== null. controls: true behaviour is unchanged. Thanks @vincerubinetti for the report (and for pushing back).
useEventSource: autoReconnect.retries is honored; no reconnect after unmount
open() reset the retry counter on every call, but the reconnect path was setTimeout(open, delay) — so every retry wiped the counter, retries could never be exceeded, and onFailed never fired (a flapping server retried forever). Reconnects now go through an internal connect() that keeps the count; a successful onopen resets it; the public open() starts a fresh budget.
The reconnect timer is also tracked and cleared from close(), so unmounting (or an explicit close()) during the retry delay no longer spawns a fresh EventSource for a component that's gone. useEventSource gains a test suite (mock EventSource, 10 cases) covering open / named events / close / reconnect / retry cap / unmount cancellation.
📝 Docs
useInterval—immediateandcontrolsare now described precisely:immediateruns the callback once whenever the interval starts (mount and everydelaychange, never whilenull);controlsswitches to manualresume()/pause()instead of auto-starting fromdelay(#128)- The site changelog (reactuse.com/changelog) is caught up — it had been stuck at 6.1.12; 6.3.0 → 6.5.1 are now listed
Full Changelog: v6.5.0...v6.5.1
v6.5.0
📦 Build
Per-module dist: optimizePackageImports now works — #216
The published dist previously inlined all 120+ hooks into a single bundle, so barrel-file optimizers had nothing to unroll: a Next.js (Turbopack) dev page importing just useDebounce pulled in every hook — a 552 kB client chunk. The build now ships one file per module (tsdown with unbundle, i.e. preserveModules) with the entry as a thin barrel of re-exports, and the same page loads only useDebounce and its real dependency chain: 64 kB (−88%).
With this release, Next.js users can enable:
// next.config.js
module.exports = {
experimental: {
optimizePackageImports: ['@reactuses/core']
}
}✨ Direct subpath imports
Every hook is now importable directly, skipping the barrel entirely — no bundler configuration needed:
import { useDebounce } from '@reactuses/core/useDebounce'Notes
requireentries now resolve to./dist/index.js/./dist/index.d.ts(previously.cjs/.d.cts);./useQRCodemoved to./dist/useQRCode/index.*. Both were only reachable through theexportsmap, so consumers are unaffected.- Build tool switched from bunchee to tsdown; bunchee dropped from the root workspace.
Full PR: #216
v6.4.2
🐛 Fixes
useOrientation: lockOrientation / unlockOrientation were no-ops — closes #215
Both methods guarded with if (isBrowser) return — inverted. They returned early in the browser and only proceeded during SSR, where the follow-up 'screen' in window check bailed anyway. The result: calling lockOrientation('landscape') or unlockOrientation() silently did nothing, everywhere. The guards now read if (!isBrowser) return, and the PR adds browser + SSR specs so the direction can't flip again unnoticed.
Full PR: #215
useInterval: a manually resumed interval survived unmount — closes #212
Two leaks in controls mode:
- With
controls: truethe main effect returns before registering a cleanup, so an interval started throughresume()kept firing after the component unmounted — despite the docs promising it's cleared on unmount. The cleanup now runs in its own mount-scoped effect, covering both modes without changing thedelay-update behavior. resume()overwrotetimer.currentwithout clearing the previous timer, so calling it twice left an interval that neitherpause()nor the unmount cleanup could reach. It now clears any running timer before starting a new one.
Full PR: #212
useMicrophone: level stayed frozen after stop() — closes #213
teardownAudioGraph() cancels the rAF loop, which is the only writer of level, so after stop() the value froze at whatever the last frame measured. A volume meter bound to it kept showing input long after the microphone was released. stop() now resets level to 0.
Full PR: #213
useElementByPoint: no more re-render on every frame in multiple mode — closes #214
document.elementsFromPoint() allocates a fresh array on every call, so storing its result as-is re-rendered the component on every frame of the rAF loop — even with the pointer sitting still. The hit list is now compared element-by-element inside a functional update, and the previous state is kept when nothing changed. The single-element mode needed no change: elementFromPoint() returns the same node and React bails out on its own.
Full PR: #214
Thanks @ostapondo for all four reports and fixes.
🔧 Internal
- CI now runs a dedicated typecheck gate (
tsc --noEmit), and TypeScript versions are aligned across the workspace (#211). As part of it,useGeolocation's placeholder coords gained thetoJSONthatGeolocationCoordinatescarries in lib.dom.
v6.4.1
🐛 Fixes
useScriptTag no longer emits an unhandled promise rejection — closes #206
The immediate auto-load called load() as a bare statement, so nothing was attached to the promise it returns. When the script failed — blocked by an ad blocker, offline, 404 — the error listener rejected that promise with no handler, and the rejection reached window.onunhandledrejection, where error trackers reported it. Setting status to 'error' does not mark a promise handled; setStatus and reject are independent paths.
Any useScriptTag pointing at analytics, a chat widget, or a third-party SDK hit this for every user running a blocklist.
The auto-load now attaches a no-op catch:
status === 'error'remains the reporting channel for a load nobody awaited.- Callers that hold the promise themselves are unaffected —
load()memoizes into_promise.current, so an explicitload()returns the same promise and still rejects for them.
Thanks @Faithfinder for the report and the fix.
Full PR: #206
v6.4.0
✨ Features
Same-tab component sync for storage & cookie hooks — closes #202
Two components bound to the same key in the same tab now stay in sync. The native storage event only fires in other tabs, so previously a header and footer useColorMode (or two useCookie on the same key) never updated each other.
useLocalStorage/useSessionStorage(createStorage): each write re-broadcasts a customwindowevent that sibling instances pick up viauseEventListener; cross-tab sync still rides the nativestorageevent.useCookie: same primitive — cookies fire no native event.refreshCookiestays for changes made outside the hook.useColorMode/useDarkMode: inherit it for free (built oncreateStorage).
Notes:
- Uses a
windowevent (not a module-level registry) so it survives the library being bundled more than once. listenToStorageChangesnow gates the cross-tab listener only; same-tab sync is always on.
🐛 Fixes
useTimeout/useTimeoutFnno longer flashpendingfalse → trueon mount — it seeds the real armed value fromimmediate. Closes #203.
📖 Docs
Corrected the useCookie / useLocalStorage / useSessionStorage sync notes and added clickable two-component live demos (en + zh-Hans + zh-Hant).
Full PR: #204
v6.3.2
Bug Fixes
- core: emit default values in the generated API docs. Five hooks used the non-standard
@defaultJSDoc tag, which the doc generator silently dropped (rendering the default column as-). Switched to the TSDoc-standard@defaultValuetag so defaults now show correctly.- Affected hooks:
useMicrophone,useElementBounding,useScroll,useScratch,useSpeechRecognition(anduseInfiniteScroll, which reuses theuseScrolloptions).
- Affected hooks:
Docs
- Add the
useWakeLockAPI reference, which was never generated/committed when the hook landed (#194), so its docs page no longer renders an empty API table.
Note: runtime bundle is unchanged from v6.3.1 — this release only corrects JSDoc/type metadata (.d.ts) and the generated documentation tables.