diff --git a/index.d.ts b/index.d.ts index d692b55..ef51770 100644 --- a/index.d.ts +++ b/index.d.ts @@ -451,5 +451,134 @@ import {Join} from 'react-extras'; */ export function Join(props: JoinProps): JSX.Element; +// eslint-disable-next-line @typescript-eslint/ban-types +type NullishTarget = T | null | undefined; +type ReferenceTarget = {readonly current: NullishTarget}; + +/** +Adds an event listener to an element and automatically removes it on cleanup. + +The handler always has access to the latest props/state without needing to specify dependencies. + +@param target - The target to add the event listener to. +@param eventName - The name of the event to listen for. +@param handler - The event handler function. +@param options - Options to pass to `addEventListener`. + +@example +``` +import {useEventListener} from 'react-extras'; + +function Component() { + useEventListener(document.body, 'click', event => { + console.log('Body clicked!', event); + }); + + return
Click anywhere
; +} +``` +*/ +export function useEventListener( + target: NullishTarget | ReferenceTarget, + eventName: K, + handler: (event: HTMLElementEventMap[K]) => void, + options?: boolean | AddEventListenerOptions +): void; +export function useEventListener( + target: NullishTarget | ReferenceTarget, + eventName: K, + handler: (event: WindowEventMap[K]) => void, + options?: boolean | AddEventListenerOptions +): void; +export function useEventListener( + target: NullishTarget | ReferenceTarget, + eventName: K, + handler: (event: DocumentEventMap[K]) => void, + options?: boolean | AddEventListenerOptions +): void; +export function useEventListener( + target: NullishTarget | ReferenceTarget, + eventName: K, + handler: (event: MediaQueryListEventMap[K]) => void, + options?: boolean | AddEventListenerOptions +): void; +export function useEventListener( + target: NullishTarget | ReferenceTarget, + eventName: string, + handler: (event: Event) => void, + options?: boolean | AddEventListenerOptions +): void; + +/** +Convenience hook for `useEventListener(window, …)` that is SSR-safe. + +The handler always has access to the latest props/state without needing to specify dependencies. + +@param eventName - The name of the event to listen for. +@param handler - The event handler function. +@param options - Options to pass to `addEventListener`. + +@example +``` +import {useWindowEvent} from 'react-extras'; + +function Component() { + useWindowEvent('resize', event => { + console.log('Window resized!', event); + }); + + return
Resize the window
; +} +``` + +@example +``` +import {useWindowEvent} from 'react-extras'; + +function Component() { + useWindowEvent('keydown', event => { + if (event.key === 'Escape') { + console.log('Escape pressed!'); + } + }); + + return
Press Escape
; +} +``` +*/ +export function useWindowEvent( + eventName: K, + handler: (event: WindowEventMap[K]) => void, + options?: boolean | AddEventListenerOptions +): void; + +/** +Convenience hook for `useEventListener(document, …)` that is SSR-safe. + +The handler always has access to the latest props/state without needing to specify dependencies. + +@param eventName - The name of the event to listen for. +@param handler - The event handler function. +@param options - Options to pass to `addEventListener`. + +@example +``` +import {useDocumentEvent} from 'react-extras'; + +function Component() { + useDocumentEvent('visibilitychange', () => { + console.log('Visibility changed:', document.visibilityState); + }); + + return
Switch tabs to see visibility changes
; +} +``` +*/ +export function useDocumentEvent( + eventName: K, + handler: (event: DocumentEventMap[K]) => void, + options?: boolean | AddEventListenerOptions +): void; + export {default as classNames} from '@sindresorhus/class-names'; export {default as autoBind} from 'auto-bind/react'; diff --git a/package.json b/package.json index 8d9d69d..db63626 100644 --- a/package.json +++ b/package.json @@ -59,14 +59,14 @@ "@babel/core": "^7.28.4", "@babel/preset-react": "^7.27.1", "@babel/register": "^7.28.3", - "@testing-library/react": "^16.3.0", - "@types/react": "^19.1.12", + "@testing-library/react": "^16.3.2", + "@types/react": "^19.2.10", "ava": "^6.4.1", "browser-env": "^3.3.0", "esm": "^3.2.25", "jest-prop-type-error": "^1.1.0", - "react": "^19.1.1", - "react-dom": "^19.1.1", + "react": "^19.2.4", + "react-dom": "^19.2.4", "tsd": "^0.33.0", "xo": "^0.59.0" }, diff --git a/readme.md b/readme.md index f9d60e1..fe05f6c 100644 --- a/readme.md +++ b/readme.md @@ -327,6 +327,56 @@ import {Join} from 'react-extras'; // => Apple, Orange and Banana ``` +### useEventListener(target, eventName, handler, options?) + +Adds an event listener to an element and automatically removes it on cleanup. + +The handler always has access to the latest props/state without needing to specify dependencies. + +```jsx +import {useEventListener} from 'react-extras'; + +function Component() { + useEventListener(document.body, 'click', event => { + console.log('Body clicked!', event); + }); + + return
Click anywhere
; +} +``` + +### useWindowEvent(eventName, handler, options?) + +Convenience hook for `useEventListener(window, …)` that is SSR-safe. + +```jsx +import {useWindowEvent} from 'react-extras'; + +function Component() { + useWindowEvent('resize', event => { + console.log('Window resized!', event); + }); + + return
Resize the window
; +} +``` + +### useDocumentEvent(eventName, handler, options?) + +Convenience hook for `useEventListener(document, …)` that is SSR-safe. + +```jsx +import {useDocumentEvent} from 'react-extras'; + +function Component() { + useDocumentEvent('visibilitychange', () => { + console.log('Visibility changed:', document.visibilityState); + }); + + return
Switch tabs to see visibility changes
; +} +``` + ### isStatelessComponent(Component) Returns a boolean of whether the given `Component` is a [functional stateless component](https://javascriptplayground.com/functional-stateless-components-react/). diff --git a/source/index.js b/source/index.js index b1596e7..a26ae50 100644 --- a/source/index.js +++ b/source/index.js @@ -26,3 +26,4 @@ export {default as Image} from './image.js'; export {default as RootClass} from './root-class.js'; export {default as BodyClass} from './body-class.js'; export {intersperse, Join} from './intersperse.js'; +export {useEventListener, useWindowEvent, useDocumentEvent} from './use-event-listener.js'; diff --git a/source/use-event-listener.js b/source/use-event-listener.js new file mode 100644 index 0000000..736dbbb --- /dev/null +++ b/source/use-event-listener.js @@ -0,0 +1,182 @@ +import { + useEffect, + useLayoutEffect, + useRef, +} from 'react'; + +const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect; + +// Normalize to a stable shape so options equality is value-based, not identity-based. +const normalizeOptions = options => { + if (options === undefined || options === null) { + return {type: 'none'}; + } + + if (typeof options === 'boolean') { + return { + type: 'boolean', + capture: options, + }; + } + + if (typeof options !== 'object') { + return {type: 'none'}; + } + + return { + type: 'object', + capture: options.capture ?? false, + passive: options.passive, + once: options.once ?? false, + signal: options.signal, + }; +}; + +const createEffectOptions = options => { + if (options.type === 'none') { + return undefined; + } + + if (options.type === 'boolean') { + return options.capture; + } + + const effectOptions = {capture: options.capture, once: options.once}; + + if (options.passive !== undefined) { + effectOptions.passive = options.passive; + } + + if (options.signal !== undefined) { + effectOptions.signal = options.signal; + } + + return effectOptions; +}; + +const areOptionsEqual = (firstOptions, secondOptions) => { + if (firstOptions?.type !== secondOptions?.type) { + return false; + } + + if (firstOptions?.type === 'none') { + return true; + } + + if (firstOptions?.type === 'boolean') { + return firstOptions.capture === secondOptions.capture; + } + + return firstOptions.capture === secondOptions.capture + && firstOptions.passive === secondOptions.passive + && firstOptions.once === secondOptions.once + && firstOptions.signal === secondOptions.signal; +}; + +// Support refs by returning their current value, treating null as no target. +const resolveTarget = target => { + if (!target || typeof target !== 'object') { + return target; + } + + if (!Object.hasOwn(target, 'current')) { + return target; + } + + return target.current; +}; + +export function useEventListener(target, eventName, handler, options) { + const handlerReference = useRef(handler); + const listenerReference = useRef(event => handlerReference.current(event)); + const subscriptionReference = useRef({ + target: undefined, + eventName: undefined, + options: undefined, + }); + + useIsomorphicLayoutEffect(() => { + handlerReference.current = handler; + }, [handler]); + + useIsomorphicLayoutEffect(() => { + const nextTarget = resolveTarget(target); + const previousSubscription = subscriptionReference.current; + const normalizedOptions = normalizeOptions(options); + const hasSameTarget = previousSubscription.target === nextTarget; + const hasSameEventName = previousSubscription.eventName === eventName; + const hasSameOptions = areOptionsEqual(previousSubscription.options, normalizedOptions); + + // Avoid re-subscribing when target, event, and options are unchanged. + if (hasSameTarget && hasSameEventName && hasSameOptions) { + return; + } + + const listener = listenerReference.current; + + // Tear down the previous subscription before attaching a new one. + if (previousSubscription.target) { + const previousEffectOptions = createEffectOptions(previousSubscription.options); + + if (previousEffectOptions === undefined) { + previousSubscription.target.removeEventListener(previousSubscription.eventName, listener); + } else { + previousSubscription.target.removeEventListener(previousSubscription.eventName, listener, previousEffectOptions); + } + } + + // Subscribe when a target exists, otherwise treat as no-op. + if (nextTarget) { + const nextEffectOptions = createEffectOptions(normalizedOptions); + + if (nextEffectOptions === undefined) { + nextTarget.addEventListener(eventName, listener); + } else { + nextTarget.addEventListener(eventName, listener, nextEffectOptions); + } + } + + subscriptionReference.current = { + target: nextTarget, + eventName, + options: normalizedOptions, + }; + }); + + // Cleanup in layout effect so unmounts before passive effects still detach. + useIsomorphicLayoutEffect(() => () => { + const currentSubscription = subscriptionReference.current; + + if (!currentSubscription.target) { + subscriptionReference.current = { + target: undefined, + eventName: undefined, + options: undefined, + }; + return; + } + + const listener = listenerReference.current; + const effectOptions = createEffectOptions(currentSubscription.options); + + if (effectOptions === undefined) { + currentSubscription.target.removeEventListener(currentSubscription.eventName, listener); + } else { + currentSubscription.target.removeEventListener(currentSubscription.eventName, listener, effectOptions); + } + + subscriptionReference.current = { + target: undefined, + eventName: undefined, + options: undefined, + }; + }, []); +} + +export function useWindowEvent(eventName, handler, options) { + useEventListener(typeof window === 'undefined' ? undefined : window, eventName, handler, options); +} + +export function useDocumentEvent(eventName, handler, options) { + useEventListener(typeof document === 'undefined' ? undefined : document, eventName, handler, options); +} diff --git a/test-d/index.test-d.tsx b/test-d/index.test-d.tsx index f0cd166..581f3b4 100644 --- a/test-d/index.test-d.tsx +++ b/test-d/index.test-d.tsx @@ -15,6 +15,7 @@ import { BodyClass, intersperse, Join, + useEventListener, } from '../index.js'; class Bar extends ReactComponent { @@ -129,3 +130,18 @@ const JoinWithFunctionSeparator = ( Banana ); + +const maybeButton = document.querySelector('button'); +useEventListener(maybeButton, 'click', event => { + event.preventDefault(); +}); + +const maybeTarget = document.querySelector('div'); +useEventListener(maybeTarget, 'custom', event => { + event.preventDefault(); +}); + +const buttonReference = React.createRef(); +useEventListener(buttonReference, 'click', event => { + event.preventDefault(); +}); diff --git a/test/test.js b/test/test.js index 77825c6..4b28d3d 100644 --- a/test/test.js +++ b/test/test.js @@ -1,5 +1,5 @@ import test from 'ava'; -import React from 'react'; +import React, {act} from 'react'; import {createRoot} from 'react-dom/client'; import {flushSync} from 'react-dom'; import {renderToStaticMarkup} from 'react-dom/server'; @@ -16,20 +16,47 @@ import { getDisplayName, intersperse, Join, + useEventListener, + useWindowEvent, + useDocumentEvent, } from '../dist/index.js'; const renderIntoDocument = element => { const div = document.createElement('div'); document.body.append(div); const root = createRoot(div); - flushSync(() => { - root.render(element); + act(() => { + flushSync(() => { + root.render(element); + }); }); return element; }; +// Helper that returns a render function and unmount function for testing lifecycle +const createTestRoot = () => { + const div = document.createElement('div'); + document.body.append(div); + const root = createRoot(div); + return { + async render(element) { + await act(async () => { + root.render(element); + }); + }, + async unmount() { + await act(async () => { + root.unmount(); + }); + div.remove(); + }, + }; +}; + browserEnv(); +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + // Helper to verify component renders without errors const verifyRenders = (t, jsx) => { const html = renderToStaticMarkup(jsx); @@ -270,3 +297,539 @@ test('', t => { ); t.regex(html3, /Only child/); }); + +test('useEventListener()', t => { + let clickCount = 0; + const button = document.createElement('button'); + document.body.append(button); + + const TestComponent = () => { + useEventListener(button, 'click', () => { + clickCount++; + }); + return null; + }; + + renderIntoDocument(); + + button.click(); + t.is(clickCount, 1); + + button.click(); + t.is(clickCount, 2); + + button.remove(); +}); + +test('useWindowEvent()', t => { + let resizeCount = 0; + + const TestComponent = () => { + useWindowEvent('resize', () => { + resizeCount++; + }); + return null; + }; + + renderIntoDocument(); + + window.dispatchEvent(new window.Event('resize')); + t.is(resizeCount, 1); + + window.dispatchEvent(new window.Event('resize')); + t.is(resizeCount, 2); +}); + +test('useDocumentEvent()', t => { + let keydownCount = 0; + + const TestComponent = () => { + useDocumentEvent('keydown', () => { + keydownCount++; + }); + return null; + }; + + renderIntoDocument(); + + document.dispatchEvent(new KeyboardEvent('keydown', {key: 'a'})); + t.is(keydownCount, 1); + + document.dispatchEvent(new KeyboardEvent('keydown', {key: 'b'})); + t.is(keydownCount, 2); +}); + +test.serial('useEventListener() - cleanup on unmount', async t => { + let clickCount = 0; + const button = document.createElement('button'); + document.body.append(button); + + const TestComponent = () => { + useEventListener(button, 'click', () => { + clickCount++; + }); + return null; + }; + + const {render, unmount} = createTestRoot(); + await render(); + + button.click(); + t.is(clickCount, 1); + + await unmount(); + + // After unmount, listener should be removed + button.click(); + t.is(clickCount, 1); // Should still be 1, not 2 + + button.remove(); +}); + +test.serial('useEventListener() - cleanup runs without passive effects', t => { + let addCallCount = 0; + let removeCallCount = 0; + const target = { + addEventListener() { + addCallCount++; + }, + removeEventListener() { + removeCallCount++; + }, + }; + + const container = document.createElement('div'); + document.body.append(container); + const root = createRoot(container); + + const TestComponent = () => { + useEventListener(target, 'click', () => {}); + return null; + }; + + const previousActEnvironment = globalThis.IS_REACT_ACT_ENVIRONMENT; + globalThis.IS_REACT_ACT_ENVIRONMENT = false; + + try { + flushSync(() => { + root.render(); + }); + flushSync(() => { + root.unmount(); + }); + } finally { + globalThis.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment; + } + + t.is(addCallCount, 1); + t.is(removeCallCount, 1); + + container.remove(); +}); + +test.serial('useEventListener() - handler updates are respected', async t => { + const calls = []; + const button = document.createElement('button'); + document.body.append(button); + + const TestComponent = ({value}) => { + useEventListener(button, 'click', () => { + calls.push(value); + }); + return null; + }; + + const {render, unmount} = createTestRoot(); + + await render(); + button.click(); + t.deepEqual(calls, ['first']); + + // Re-render with new handler that captures different value + await render(); + button.click(); + t.deepEqual(calls, ['first', 'second']); // Should use updated handler + + await unmount(); + button.remove(); +}); + +test.serial('useEventListener() - useEffectEvent handler sees latest values', async t => { + const calls = []; + const button = document.createElement('button'); + document.body.append(button); + + const TestComponent = ({value}) => { + const onClick = React.useEffectEvent(() => { + calls.push(value); + }); + + useEventListener(button, 'click', onClick); + return null; + }; + + const {render, unmount} = createTestRoot(); + + await render(); + button.click(); + t.deepEqual(calls, ['first']); + + await render(); + button.click(); + t.deepEqual(calls, ['first', 'second']); + + await unmount(); + button.remove(); +}); + +test.serial('useEventListener() - handler updates before events after commit', async t => { + const calls = []; + const button = document.createElement('button'); + const container = document.createElement('div'); + document.body.append(button); + document.body.append(container); + + const root = createRoot(container); + + const TestComponent = ({value}) => { + useEventListener(button, 'click', () => { + calls.push(value); + }); + return null; + }; + + await act(async () => { + root.render(); + }); + + await act(async () => { + flushSync(() => { + root.render(); + }); + + button.click(); + }); + + t.deepEqual(calls, ['second']); + + await act(async () => { + root.unmount(); + }); + + container.remove(); + button.remove(); +}); + +test.serial('useEventListener() - ref target attaches after mount', async t => { + let clickCount = 0; + const buttonReference = React.createRef(); + + const TestComponent = () => { + useEventListener(buttonReference, 'click', () => { + clickCount++; + }); + return ; + }; + + const {render, unmount} = createTestRoot(); + await render(); + + buttonReference.current.click(); + t.is(clickCount, 1); + + await unmount(); +}); + +test.serial('useEventListener() - ref target can be null before mount', async t => { + let clickCount = 0; + const buttonReference = React.createRef(); + let setShowButton; + + const TestComponent = () => { + const [showButton, setShowButtonState] = React.useState(false); + setShowButton = setShowButtonState; + + useEventListener(buttonReference, 'click', () => { + clickCount++; + }); + + return showButton ? : null; + }; + + const {render, unmount} = createTestRoot(); + + await t.notThrowsAsync(async () => { + await render(); + }); + + await act(async () => { + setShowButton(true); + }); + + await act(async () => {}); + + t.truthy(buttonReference.current); + buttonReference.current.click(); + t.is(clickCount, 1); + + await unmount(); +}); + +test.serial('useEventListener() - reattaches after StrictMode replay', async t => { + let clickCount = 0; + let addCallCount = 0; + let removeCallCount = 0; + const button = document.createElement('button'); + const originalAddEventListener = button.addEventListener; + const originalRemoveEventListener = button.removeEventListener; + document.body.append(button); + + button.addEventListener = (...listenerArguments) => { + addCallCount++; + return originalAddEventListener.call(button, ...listenerArguments); + }; + + button.removeEventListener = (...listenerArguments) => { + removeCallCount++; + return originalRemoveEventListener.call(button, ...listenerArguments); + }; + + const TestComponent = () => { + useEventListener(button, 'click', () => { + clickCount++; + }); + return null; + }; + + const {render, unmount} = createTestRoot(); + await render( + + + , + ); + + t.true(removeCallCount > 0); + t.is(addCallCount, removeCallCount + 1); + + button.click(); + t.is(clickCount, 1); + + await unmount(); + button.addEventListener = originalAddEventListener; + button.removeEventListener = originalRemoveEventListener; + button.remove(); +}); + +test.serial('useEventListener() - ref target changes resubscribe', async t => { + const calls = []; + const buttonReference = React.createRef(); + let setShowFirstButton; + + const TestComponent = () => { + const [showFirstButton, setShowFirstButtonState] = React.useState(true); + setShowFirstButton = setShowFirstButtonState; + + useEventListener(buttonReference, 'click', () => { + calls.push(showFirstButton ? 'first' : 'second'); + }); + + return showFirstButton ? ( + + ) : ( + + ); + }; + + const {render, unmount} = createTestRoot(); + await render(); + + const firstButton = buttonReference.current; + + await act(async () => { + setShowFirstButton(false); + }); + + await act(async () => {}); + + const secondButton = buttonReference.current; + t.not(secondButton, firstButton); + + t.deepEqual(calls, []); + secondButton.click(); + + t.deepEqual(calls, ['second']); + + await unmount(); +}); + +test.serial('useEventListener() - does not pass options when omitted', async t => { + const button = document.createElement('button'); + const originalAddEventListener = button.addEventListener; + let listenerArgumentsLength; + document.body.append(button); + + button.addEventListener = (...listenerArguments) => { + listenerArgumentsLength = listenerArguments.length; + return originalAddEventListener.call(button, ...listenerArguments); + }; + + const TestComponent = () => { + useEventListener(button, 'click', () => {}); + return null; + }; + + const {render, unmount} = createTestRoot(); + await render(); + + t.is(listenerArgumentsLength, 2); + + await unmount(); + button.addEventListener = originalAddEventListener; + button.remove(); +}); + +test.serial('useEventListener() - options are passed correctly', async t => { + const phases = []; + const parent = document.createElement('div'); + const child = document.createElement('button'); + parent.append(child); + document.body.append(parent); + + const TestComponent = () => { + // Capture phase listener on parent + useEventListener(parent, 'click', () => { + phases.push('parent-capture'); + }, {capture: true}); + + // Bubble phase listener on parent + useEventListener(parent, 'click', () => { + phases.push('parent-bubble'); + }, {capture: false}); + + return null; + }; + + const {render, unmount} = createTestRoot(); + await render(); + + child.click(); + // Capture phase should fire before bubble phase + t.deepEqual(phases, ['parent-capture', 'parent-bubble']); + + await unmount(); + parent.remove(); +}); + +test.serial('useEventListener() - boolean options are passed as capture', async t => { + const button = document.createElement('button'); + const originalAddEventListener = button.addEventListener; + let listenerArguments; + document.body.append(button); + + button.addEventListener = (...nextListenerArguments) => { + listenerArguments = nextListenerArguments; + return originalAddEventListener.call(button, ...nextListenerArguments); + }; + + const TestComponent = () => { + useEventListener(button, 'click', () => {}, true); + return null; + }; + + const {render, unmount} = createTestRoot(); + await render(); + + t.is(listenerArguments.length, 3); + t.is(listenerArguments[2], true); + + await unmount(); + button.addEventListener = originalAddEventListener; + button.remove(); +}); + +test('useEventListener() - null options are handled', t => { + const button = document.createElement('button'); + document.body.append(button); + + const TestComponent = () => { + useEventListener(button, 'click', () => {}, null); + return null; + }; + + t.notThrows(() => { + renderIntoDocument(); + }); + + button.remove(); +}); + +test.serial('useEventListener() - handler stays at last commit during render', async t => { + const calls = []; + const button = document.createElement('button'); + document.body.append(button); + + const TestComponent = ({value, triggerDuringRender}) => { + useEventListener(button, 'click', () => { + calls.push(value); + }); + + if (triggerDuringRender) { + button.click(); + } + + return null; + }; + + const {render, unmount} = createTestRoot(); + + await render(); + t.deepEqual(calls, []); + + await render(); + t.deepEqual(calls, ['committed']); + + await unmount(); + button.remove(); +}); + +test.serial('useEventListener() - abort signal is forwarded', async t => { + const button = document.createElement('button'); + const abortController = new AbortController(); + const originalAddEventListener = button.addEventListener; + let forwardedSignal; + document.body.append(button); + + button.addEventListener = (type, listener, options) => { + if (options && typeof options === 'object') { + forwardedSignal = options.signal; + } + + return originalAddEventListener.call(button, type, listener, options); + }; + + const TestComponent = () => { + useEventListener(button, 'click', () => {}, {signal: abortController.signal}); + return null; + }; + + const {render, unmount} = createTestRoot(); + await render(); + + t.is(forwardedSignal, abortController.signal); + + await unmount(); + button.addEventListener = originalAddEventListener; + button.remove(); +}); + +test('useEventListener() - undefined target is handled', t => { + // Should not throw when target is undefined (SSR scenario) + const TestComponent = () => { + useEventListener(undefined, 'click', () => {}); + return null; + }; + + t.notThrows(() => { + renderIntoDocument(); + }); +});