Skip to content

Releases: childrentime/reactuse

v6.5.5

Choose a tag to compare

@childrentime childrentime released this 20 Aug 09:39

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 set returned 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

Choose a tag to compare

@childrentime childrentime released this 20 Aug 09:30

🐞 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() restored overflow: hidden — the page never scrolled again
  • an existing inline value such as overflow: overlay was 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

Choose a tag to compare

@childrentime childrentime released this 19 Aug 09:05

🐞 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

Choose a tag to compare

@childrentime childrentime released this 17 Aug 12:17

🐞 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 fallback

The 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-docusaurus site is gone. reactuse.com has been served from packages/website-astro for 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/ to packages/website-astro/api/ — next to the site that actually renders it at the %%API%% marker. It is still produced by pnpm --filter @reactuses/core gend and still read by packages/mcp.

Full Changelog: v6.5.1...v6.5.2

v6.5.1

Choose a tag to compare

@childrentime childrentime released this 15 Aug 07:11

🐞 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 succeed

The 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

  • useIntervalimmediate and controls are now described precisely: immediate runs the callback once whenever the interval starts (mount and every delay change, never while null); controls switches to manual resume() / pause() instead of auto-starting from delay (#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

Choose a tag to compare

@childrentime childrentime released this 05 Aug 11:59

📦 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

  • require entries now resolve to ./dist/index.js / ./dist/index.d.ts (previously .cjs / .d.cts); ./useQRCode moved to ./dist/useQRCode/index.*. Both were only reachable through the exports map, so consumers are unaffected.
  • Build tool switched from bunchee to tsdown; bunchee dropped from the root workspace.

Full PR: #216

v6.4.2

Choose a tag to compare

@childrentime childrentime released this 31 Jul 02:31

🐛 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: true the main effect returns before registering a cleanup, so an interval started through resume() 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 the delay-update behavior.
  • resume() overwrote timer.current without clearing the previous timer, so calling it twice left an interval that neither pause() 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 the toJSON that GeolocationCoordinates carries in lib.dom.

v6.4.1

Choose a tag to compare

@childrentime childrentime released this 28 Jul 16:03

🐛 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 explicit load() returns the same promise and still rejects for them.

Thanks @Faithfinder for the report and the fix.

Full PR: #206

v6.4.0

Choose a tag to compare

@childrentime childrentime released this 25 Jun 11:47

✨ 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 custom window event that sibling instances pick up via useEventListener; cross-tab sync still rides the native storage event.
  • useCookie: same primitive — cookies fire no native event. refreshCookie stays for changes made outside the hook.
  • useColorMode / useDarkMode: inherit it for free (built on createStorage).

Notes:

  • Uses a window event (not a module-level registry) so it survives the library being bundled more than once.
  • listenToStorageChanges now gates the cross-tab listener only; same-tab sync is always on.

🐛 Fixes

  • useTimeout / useTimeoutFn no longer flash pending false → true on mount — it seeds the real armed value from immediate. 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

Choose a tag to compare

@childrentime childrentime released this 22 May 01:31

Bug Fixes

  • core: emit default values in the generated API docs. Five hooks used the non-standard @default JSDoc tag, which the doc generator silently dropped (rendering the default column as -). Switched to the TSDoc-standard @defaultValue tag so defaults now show correctly.
    • Affected hooks: useMicrophone, useElementBounding, useScroll, useScratch, useSpeechRecognition (and useInfiniteScroll, which reuses the useScroll options).

Docs

  • Add the useWakeLock API 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.